Zero-Downtime Deployments: Shipping Without the Maintenance Page

How to deploy new code without dropping requests — health checks, rolling and blue-green strategies, backward-compatible migrations, and the database changes that quietly break everything.

Building & Shipping: Zero-Downtime Deployments: Shipping Without the Maintenance Page

The goal

A deploy should be invisible to users. No “we’ll be back at 3am” banner, no dropped requests, no failed checkouts mid-purchase. Achieving that isn’t about one magic tool — it’s a set of habits that let old and new code coexist for a few minutes without anyone noticing.

Why naive deploys drop requests

The simplest deploy — stop the old process, start the new one — has a gap:

14:00:00  Old server handling requests
14:00:01  Stop old server        ← in-flight requests dropped
14:00:02  Start new server        ← requests fail: connection refused
14:00:05  New server ready        ← service restored

Those few seconds mean real errors for real users. Under load, “a few seconds” can be hundreds of failed requests. Zero-downtime deployment closes that gap by ensuring something can always serve traffic.

Foundation: a real health check

Everything else depends on knowing when a new instance is actually ready. Not “the process started” — ready to serve correct responses. That means dependencies are reachable too.

app.get('/health', async (req, res) => {
  try {
    // Check the things a real request depends on
    await db.query('SELECT 1');
    await redis.ping();
    res.status(200).json({ status: 'ok' });
  } catch (err) {
    // Not ready — don't send traffic here yet
    res.status(503).json({ status: 'unhealthy', error: err.message });
  }
});

Distinguish two checks, because they answer different questions:

  • Liveness — is the process alive? If it fails, restart the instance.
  • Readiness — can it serve traffic right now? If it fails, route traffic elsewhere but don’t kill it (it might just be warming up or briefly overloaded).

Conflating them causes a nasty failure: a momentary database blip fails your health check, the orchestrator kills the pod, the restart hammers the recovering database, and you’ve turned a 2-second blip into a cascading outage. Liveness should be cheap and dependency-free; readiness can check dependencies.

Graceful shutdown

The other half of the gap: when told to stop, a server should finish what it’s doing instead of dropping connections.

const server = app.listen(3000);

process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');

  // Stop accepting new connections, finish in-flight ones
  server.close(() => {
    console.log('All requests finished, closing resources');
    db.end();
    redis.quit();
    process.exit(0);
  });

  // Safety net: force-exit if a slow request hangs shutdown
  setTimeout(() => {
    console.error('Forced shutdown after timeout');
    process.exit(1);
  }, 30_000);
});

server.close() stops accepting new connections but lets active ones complete. The timeout is essential — without it, one stuck long-poll request keeps the old process alive forever and your deploy hangs. Match the timeout to your load balancer’s connection-draining window.

Strategy 1: Rolling deployment

Replace instances one at a time. At every moment, most instances are up and serving.

Start:  [v1] [v1] [v1] [v1]   all serving
Step 1: [v2] [v1] [v1] [v1]   replace one, wait for its health check
Step 2: [v2] [v2] [v1] [v1]   continue once it's healthy
Step 3: [v2] [v2] [v2] [v1]
Done:   [v2] [v2] [v2] [v2]

Kubernetes does this natively:

spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1          # at most 1 extra pod during rollout
      maxUnavailable: 0    # never drop below the desired count
  template:
    spec:
      containers:
        - name: app
          readinessProbe:
            httpGet: { path: /health, port: 3000 }
            initialDelaySeconds: 5
            periodSeconds: 5

maxUnavailable: 0 is the key line — it guarantees full capacity throughout the rollout by adding a new pod before removing an old one. The readiness probe ensures traffic only reaches a pod once it passes /health.

The tradeoff: for a window of a minute or two, both v1 and v2 are live and serving simultaneously. Your code has to tolerate that — which brings us to the part that actually breaks deploys.

The hard part: database migrations

Code deploys are easy to reverse. Database schema changes are not — and during a rolling deploy, old and new code hit the same database at once. A migration that assumes only new code is running will break the old instances still serving traffic.

The breaking rename:

-- ❌ Renaming a column in one step
ALTER TABLE users RENAME COLUMN email TO email_address;

The instant this runs, every v1 instance still querying email starts throwing errors — and they’re still serving live traffic. You’ve caused an outage with a “zero-downtime” deploy.

The fix: expand and contract. Split the change across multiple deploys so old and new code always share a compatible schema.

Deploy 1 — expand. Add the new column, keep the old:

ALTER TABLE users ADD COLUMN email_address TEXT;
UPDATE users SET email_address = email WHERE email_address IS NULL;

Ship code that writes both columns and reads the old one. Now both versions work.

Deploy 2 — migrate reads. Ship code that reads the new column, still writing both. Backfill any stragglers.

Deploy 3 — contract. Once no running code references the old column, drop it:

ALTER TABLE users DROP COLUMN email;

Three deploys to rename a column feels tedious. It’s also the difference between an invisible change and a 3am incident. The rule underneath it: every schema change must be backward-compatible with the currently-running code. Add before you remove; make columns nullable or give them defaults before you require them.

Adding a NOT NULL column safely

-- ❌ Breaks old code that inserts without this column
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL;

-- ✅ Add nullable with a default first
ALTER TABLE orders ADD COLUMN status TEXT DEFAULT 'pending';
-- backfill, deploy code that sets it, THEN add the constraint later
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;

On large tables there’s a second trap: adding a column with a volatile default, or adding NOT NULL on Postgres versions that rewrite the whole table, can lock it for the duration. Check your database’s specific locking behavior before altering a table with millions of rows — a full-table lock is downtime by another name.

Strategy 2: Blue-green deployment

Run two complete environments. Blue serves production; green gets the new version. Test green in isolation, then flip all traffic at once.

Blue  (v1) ← 100% traffic
Green (v2) ← 0%, deploy and smoke-test here

Flip the router:
Blue  (v1) ← 0%
Green (v2) ← 100% traffic

Advantages: the switch is instant, and rollback is just flipping back to blue — which is still running, untouched. The cost is double the infrastructure during the deploy, and the database is still shared, so the migration rules above still apply in full.

Blue-green shines when you want a clean, instant cutover and cheap rollback. Rolling is more resource-efficient. Most teams do rolling by default and reserve blue-green for high-stakes releases.

Feature flags: decouple deploy from release

The most powerful technique isn’t a deploy strategy at all — it’s separating shipping code from turning it on.

if (featureFlags.isEnabled('new-checkout', user)) {
  return renderNewCheckout();
}
return renderOldCheckout();

Now you deploy the new checkout to production off, enable it for 1% of users, watch your error rates and metrics, and ramp to 100% — or kill it instantly without a deploy if something’s wrong. The risky moment (turning it on) is decoupled from the mechanical one (deploying), and the kill switch is a config change, not a rollback.

This also makes the expand-contract migration safer: you can gate the new-column read path behind a flag and flip it independently of the deploy.

Rollback plan

Every deploy needs a known way back:

  • Keep the previous version deployable. Tag releases; make redeploying the last good build a one-command operation.
  • Remember migrations don’t auto-rollback. If deploy 2 goes bad, rolling back the code is fine — but only because deploy 1 kept the schema backward-compatible. That compatibility is your rollback safety.
  • Practice it. A rollback path you’ve never exercised is a hope, not a plan.

Putting it together

A zero-downtime deploy of a schema-affecting change looks like:

  1. Deploy backward-compatible migration (expand)
  2. Rolling-deploy code that works with both schemas, behind a feature flag if risky
  3. Verify health checks green, error rates flat
  4. Enable the feature gradually via flag
  5. In a later deploy, contract the schema once old code is gone

It’s more steps than “push and pray,” but each step is small, reversible, and observable. That’s the whole point: no single moment where the site can go dark.

When you don’t need any of this

A personal blog with occasional traffic doesn’t need blue-green infrastructure. A brief blip at 4am when nobody’s reading is fine. Match the effort to the stakes — zero-downtime discipline earns its cost when downtime costs money or trust, and it’s over-engineering when it doesn’t. Know which situation you’re in before building the machinery.