Zero-downtime deploys on a single server: what it actually takes
Most zero-downtime articles open with a five-node cluster. But most products live on one server with docker compose, and that is exactly where downtime hurts most, because nothing else can pick up the traffic. The good news: zero downtime is reachable there too, and the real work is not in the orchestrator — it is in how you write migrations.
Why even a simple deploy drops requests
The classic sequence is docker compose up -d --build. The old container stops, the new one takes 8–20 seconds to warm up, and every request in that window gets a 502 from nginx. If a user was submitting a form, that enquiry is gone and you will never hear about it.
The second cause is worse: the migration. You rename a column, apply it, and while the new image builds the old code keeps writing to a column that no longer exists. Errors in the log, data on the floor.
Migrations that work in both directions
This is the important part, and it has nothing to do with infrastructure. One rule: a migration must work with both the old code and the new code. That gives you expand/contract across three releases:
- Expand. Add the new column, nullable, with a default. Old code cannot see it and does not break. New code writes to both.
- Migrate. Backfill in a background job, not in the migration. A million rows inside a migration means a table lock — the same downtime under a different name.
- Contract. Drop the old column in a later release, once no instance reads it.
Three deploys instead of one feels excessive until the first time you roll back at two in the morning. A single-step rename is a deploy you cannot undo: nobody has ever tested the down migration.
A migration you cannot roll back is not a migration, it is a one-way door. Walk through it deliberately, not by accident.
The right order of operations
The order matters more than the tool:
1. build build the new image (the old one still serves traffic)
2. migrate apply backward-compatible migrations
3. start bring the new container up beside the old one
4. wait block until /health/ready returns 200
5. switch point the nginx upstream at the new container
6. drain give the old one 10 seconds to finish in-flight requests
7. stop stop the old containerSteps 3–5 are what docker compose will not do for you. The cheapest way to get them without Kubernetes: two services in compose (app_blue and app_green) and an nginx upstream read from a file you swap, followed by nginx -s reload. A reload does not cut live connections — old workers finish their requests, new workers pick up the new config.
A health check that tells the truth
The most common mistake is a health check that returns 200 the moment the process starts. You then switch traffic to a container with no database connection and get the same outage, except now it looks like a 500 instead of a 502.
Split it in two:
/health/live— the process is alive. If this fails, restart the container./health/ready— the database is connected, Redis answers, the queue is reachable. If this fails, take the container out of the load balancer but do not restart it.
Confusing the two is how you get cascading failures: the database slows down, readiness fails, the orchestrator restarts every container, and on boot they all hammer the database that was already struggling.
Warm-up
An SSR app compiles routes on first request and starts with an empty content cache. The first user after a deploy waits three seconds instead of 300 milliseconds. Technically there was no downtime; practically the site is slow at exactly the moment someone is looking at it.
Ten lines fix it: after the container passes readiness but before you switch traffic, request the handful of pages and cache-filling endpoints that matter. Cache warm-up is part of the deploy, not an optimisation for later.
A rollback that actually works
A rollback is not "put the previous image back". It is "put the previous image back and be sure it works against the current database schema". That is why expand/contract matters more than the deploy script: with backward-compatible migrations a rollback is one command. Without them it is a restore from backup.
The minimum worth having: image tags from the commit SHA (never latest), the previous tag kept around, and a smoke test after the switch. If the smoke test is red, point the upstream back while the old container is still alive. That is why step 7 is last.
What to do this week
- Split liveness and readiness if you have a single
/health. - Review your last five migrations: would the old code have survived them?
- Add a warm-up after start and a smoke test after the switch.
- Replace
latestwith a commit-SHA tag.
Four small changes, and together they buy more than moving to an orchestrator you would then have to operate.
- Zero downtime is decided by your migrations, not your orchestrator
- Expand / migrate / contract: three releases instead of one risky step
- Liveness and readiness are different endpoints with different consequences
- Cache warm-up belongs to the deploy, or the first user sees a slow site
Yes. You need two containers running in parallel for a few seconds, nginx reload instead of restart, and a readiness check that waits for the database. An orchestrator gives you this out of the box, but the mechanism does not depend on one.
A blue/green deploy script with warm-up is roughly a day of work. Moving to expand/contract migrations is a change of habit rather than a one-off task: the first few releases feel slower, then it becomes normal.
