All posts
6 min read

Zero-Downtime Deploys for a Small App

You don't need Kubernetes to deploy without dropping requests. What actually causes downtime during a deploy — stopping before starting, no health checks, killed requests, and database changes the old code can't handle — and the four practices that fix each one.

deploymentinfrastructuredatabases

For a lot of small apps, "deploying" means a minute where the site shows an error page. Users mid-checkout get a failure. A form submission vanishes. Someone refreshes and sees a 502. It's brief, so it's tolerated — until you have enough users that there's always someone mid-request.

Zero-downtime deploys sound like big-company infrastructure. They're not. The causes of deploy downtime are few and specific, and each has a simple fix.

Cause 1: stopping the old version before the new one is ready

The simplest deploy stops the app, updates the code, and starts it again. Between stop and "ready," nobody is serving requests — and "ready" can take a while: installing dependencies, building, warming up, connecting to the database.

Fix: start new, then stop old. Bring up the new version alongside the old one. Only when the new one is ready does traffic switch over, and only then does the old one stop. If you run several instances, do it one at a time — a rolling deploy. Most container platforms and PaaS hosts do this for you, as long as they know when "ready" is, which is the next cause.

Cause 2: the platform can't tell when the app is ready

A process that has started isn't necessarily ready. It may still be connecting to the database or warming a cache. If traffic switches the moment the process exists, the first requests fail.

Fix: a health check. Give your app an endpoint — /health — that returns success only when it can genuinely serve requests. A good one checks the database connection. Tell your platform to use it: new instances get traffic only once the check passes, and instances that start failing are taken out of rotation.

Keep it cheap. It's called often, so a quick SELECT 1 against the database is plenty; don't run heavy queries in it.

Cause 3: killing requests mid-flight

When the old version is stopped, it may be halfway through handling requests: a payment, an upload, a slow report. Killed abruptly, those requests fail.

Fix: graceful shutdown. When a platform wants to stop an instance, it sends a signal (SIGTERM), waits a grace period, and then force-kills it. Your app should use that window:

  1. Stop accepting new requests (the platform should already be routing traffic away).
  2. Finish the requests in progress.
  3. Close database connections cleanly, then exit.

Many frameworks and servers handle this if you let them; some need a few lines of code to listen for SIGTERM. Make sure the platform's grace period is longer than your slowest normal request.

Background workers need the same care: finish (or safely hand back) the current job before exiting, so it isn't half-done. Job libraries with retries make this much safer — see Your App Needs Background Jobs.

Cause 4: database changes the old code can't handle

This is the one that catches careful teams. During a rolling deploy, old and new versions of your code run at the same time, against the same database. And migrations usually run just before or during the deploy.

So if a migration renames the name column to full_name:

  • The migration runs. The column is now full_name.
  • Old instances — still serving traffic for a minute — query name. They crash.

Or the reverse: new code expects a column the migration hasn't created yet.

Fix: every migration must work with both the old and the new code. In practice, that means splitting breaking changes into steps — the expand, then contract pattern:

  1. Expand: add the new column (nullable or with a default). Deploy. Old code ignores it; new code starts writing both columns.
  2. Migrate: backfill the new column from the old one.
  3. Switch: deploy code that reads the new column.
  4. Contract: in a later deploy, once nothing uses the old column, drop it.

Every step is safe to run while the previous version is still live. It takes more deploys, but none of them break anything. What Are Database Migrations? covers this in more detail.

The same thinking applies to APIs between your frontend and backend. For a moment, an old frontend in someone's browser will call a new backend. Add new fields and endpoints before removing old ones.

Frontend version skew

Single-page apps and frameworks like Next.js load JavaScript files with unique names per build. If a user loaded the page before the deploy and then navigates, their browser may request a file from the old build that the new servers no longer have. The result is a broken page until they refresh.

Fixes: keep the previous build's static files available for a while after deploying (a CDN or object storage helps), or use your framework's version-skew handling to force a reload when the version changes. Self-Hosting Next.js Without Vercel covers the Next.js settings.

Rolling back

Zero-downtime deploys make rolling back safe too — as long as you can:

  • Keep the previous version deployable. Container images tagged by commit, or your platform's release history. Rolling back should be one command or click.
  • Keep migrations backward-compatible, so the previous code still works against the new database. Expand-then-contract gives you this for free. A migration that dropped a column cannot be rolled back by redeploying old code; that needs a backup.
  • Deploy small and often. A deploy with one change is easy to reason about and easy to reverse. A deploy with forty changes is neither.

Do you need all of this?

For a side project with a handful of users, a few seconds of downtime at 3am is a perfectly reasonable trade. The checklist below is ordered by value — the first three items are cheap and prevent most user-visible failures.

The checklist

  • New version starts and passes a health check before the old one stops
  • Health check verifies the database connection
  • App shuts down gracefully on SIGTERM; grace period longer than the slowest request
  • Workers finish or safely return their current job before exiting
  • Migrations compatible with both old and new code (expand, then contract)
  • Previous build's static assets available briefly after deploy
  • Rolling back is one step, and has been tried once

EasySpawn runs your app, workers, and managed Postgres in a persistent workspace with automatic SSL and your own domain, with preview deployments per branch on Pro — so you can check a change against the real app before it replaces the live one. See how it works or join the waitlist.

Related: Preview Environments for Every Branch · How to Know When Your App Is Down · Feature Flags for Small Teams · Postgres Migrations on Large Tables · Reverse Proxies Explained

Keep reading