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:
- Deploy backward-compatible migration (expand)
- Rolling-deploy code that works with both schemas, behind a feature flag if risky
- Verify health checks green, error rates flat
- Enable the feature gradually via flag
- 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.
