Never Stop the Old Container First
A plain docker compose up costs you a minute of 502s, because recreate means stop then start. Here is the blue-green pipeline - GitHub Actions, rsync, Compose overlays and Traefik as the load balancer - that ships to a single VPS without a user ever seeing a failed request.
Every deployment guide starts with the happy path. This one starts with the gap.
You have a container serving production. You push a release. Compose stops the old container, starts a new one, and waits for it to report healthy. Somewhere in the middle of that sentence is a window - thirty seconds, sixty on a bad day - where the reverse proxy has nothing to route to and every request in flight comes back a 502.
For a marketing site that window is invisible. For anything transactional it is not. If the deploy window catches a checkout, a payment callback, or a desktop client flushing its offline queue, the failure is not cosmetic - it is a retry storm and a support ticket.
This is the pipeline I use to close that window: GitHub Actions for the quality gate, rsync for delivery, Docker Compose for the containers, and Traefik as the thing that makes cutover free. No Kubernetes, no second load balancer, one VPS.
The gap nobody budgets for
The naive deploy is one line, and it is one line because Compose is doing something reasonable:
docker compose up -d --no-deps api
Compose sees a changed image, so it recreates the container. Recreate means stop, then start. The proxy watches the Docker socket, sees the container disappear, and pulls it from the routing pool. There is nothing else in the pool.
Timeline of a naive recreate
────────────────────────────────────────────────────────
Old container ████████████████████
│ stop
▼
Gap ··············
│ healthy
▼
New container ████████████████████
Client sees: 200 200 200 ... 502 502 502 ... 200 200 200
Two common workarounds do not work here.
docker compose up --scale api=2 is the textbook answer, and it fails the moment you set a fixed container_name. Compose cannot start two containers with the same name, and dropping the fixed name costs you every ops habit built on docker logs api instead of docker logs project-api-1.
A longer healthcheck interval does not help either. The gap is not a health-detection problem, it is an ordering problem. The old container is already gone.
The fix is to invert the order. Never stop the old container until something else is already healthy and already registered with the proxy.
What zero downtime actually means
Worth being precise, because the phrase gets used loosely. My working definition:
For the entire duration of a deploy, every client request receives a successful response from some version of the application.
Note what that does not promise. It does not promise that all clients see the same version at the same instant - during the overlap window, two requests from the same browser can land on different builds. It does not promise atomicity across services. And it does not extend to your background workers or your database, which have their own answers.
That looseness is the price. In exchange you get a deploy you can run at 2pm on a Tuesday instead of scheduling for 3am.
The shape of the system
Two repositories - an API and a web front end - each with its own workflow, both deploying to the same VPS and attaching to an external Docker network the proxy owns.
┌──────────────────────────────────┐
│ Internet / clients │
└────────────────┬─────────────────┘
│ :80 / :443
▼
┌──────────────────────────────────┐
│ Traefik │
│ TLS, routing, middleware, LB │
│ network: edge │
└──────┬────────────────┬──────────┘
│ │
PathPrefix(/api) │ everything else
▼ ▼
┌──────────────┐ ┌──────────────┐
│ app-api │ │ app-web │
│ :8000 │ │ :80 (nginx) │
└──────┬───────┘ └──────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
PostgreSQL PgBouncer Redis
(+ background workers, recreated after API cutover)
Each layer has exactly one job in the zero-downtime story:
| Layer | Job |
|---|---|
| GitHub Actions | Gate on lint, types and tests, then deliver and invoke |
| rsync over SSH | Put the CI checkout on the server - no git remote on the VPS |
| Docker Compose | Build images, run the candidate alongside the live container |
| Traefik | Discover containers by label; balance across both during overlap |
| Healthchecks | Gate promotion - the only thing standing between a bad build and users |
stop_grace_period |
Let in-flight requests finish when the old container is drained |
Secrets live only on the server. The .env files are never in git and never in the rsync payload - CI has no way to overwrite them, which is deliberate. A deploy pipeline that can rewrite production credentials is a deploy pipeline that will, once.
Blue-green without Kubernetes
Blue-green usually implies two full environments and a switch between them. On a single VPS that is unaffordable, and it is also more than the problem needs. What I actually run is blue-green scoped to one service, for about ninety seconds:
| Live | The container serving production right now, under the canonical name |
| Candidate | A second container from this deploy’s image, under a -candidate name |
The candidate comes up beside the live container. Both serve. Then the live one goes away and the canonical name is recreated on the new image. The candidate is scaffolding - it exists for the duration of the swap and is removed at the end.
Phase A - build Phase B - overlap Phase C - promote
──────────────── ────────────────── ─────────────────
Traefik ──► live ● Traefik ──► live ● Traefik ──► live ● (new image)
Traefik ──► candidate ●
candidate: building (both healthy) candidate: removed
The candidate is defined in an overlay file, docker-compose.deploy.yml, layered on top of the production Compose file. It extends the live service and changes four things:
services:
api_candidate:
extends: { file: docker-compose.prod.yml, service: api }
container_name: app-api-candidate
image: app-api:${IMAGE_TAG}
restart: 'no'
labels:
- traefik.http.routers.api-candidate.rule=PathPrefix(`/api`)
- traefik.http.routers.api-candidate.service=api
restart: 'no' matters. A candidate that crash-loops should stay down so the healthcheck gate catches it, not restart until the timeout expires and confuses the diagnosis.
One service name, two containers
This is the part that makes the whole thing work, and it is three lines of labels.
Traefik requires router names to be unique - two containers cannot both claim api. But routers and services are separate concepts, and nothing stops two distinct routers from pointing at the same Traefik service:
# on the live container
traefik.http.routers.api.rule=PathPrefix(`/api`)
traefik.http.routers.api.service=api
# on the candidate
traefik.http.routers.api-candidate.rule=PathPrefix(`/api`)
traefik.http.routers.api-candidate.service=api # <- same service
traefik.http.services.api.loadbalancer.server.port=8000
Traefik resolves both containers into one service’s server pool and round-robins across them. You get load balancing during the overlap for free, from the proxy that is already terminating TLS.
During overlap
Request ──► Traefik service "api"
│
┌─────────┴─────────┐
▼ ▼
app-api app-api-candidate
(old image) (IMAGE_TAG = git SHA)
When the live container is stopped, Traefik gets the Docker event, drops that server from the pool, and sends 100% of traffic to a candidate that has already been warm and serving for a minute. There is no cold start at the cutover instant - which is the other half of why this is smooth, and the half that a DNS or symlink switch does not give you.
The pipeline, end to end
Each repo has one workflow on the production branch.
| Event | What runs |
|---|---|
PR into production |
CI only - lint, types, tests, build |
Push to production |
CI, then deploy |
workflow_dispatch |
Manual re-run, which is also the rollback path |
push to production
│
▼
┌─────────────────────────────────────┐
│ job: ci │
│ API: lint, format, types, │
│ migration drift check │
│ Web: eslint, tsc, unit, build │
└──────────────┬──────────────────────┘
│ needs: ci
▼
┌─────────────────────────────────────┐
│ job: deploy │
│ concurrency: production-deploy │
│ cancel-in-progress: false │
│ │
│ 1. checkout │
│ 2. ssh-agent, load deploy key │
│ 3. rsync → DEPLOY_PATH │
│ 4. ssh: IMAGE_TAG=$GITHUB_SHA \ │
│ ./scripts/deploy/prod.sh │
└─────────────────────────────────────┘
cancel-in-progress: false is not a detail. The default in most concurrency configs is to cancel the older run, and cancelling a deploy mid-cutover is how you end up with no live container and a half-promoted candidate. Deploys queue. They never race.
Why rsync and not git pull
The obvious pattern is to SSH in and git pull. I do not, for three reasons.
Actions has already checked the code out - pulling again means putting a deploy key with repo access on the server, which is a credential I would rather not have sitting there. It also makes the server’s state depend on a network call to GitHub at deploy time, adding a failure mode to a step that has enough of them. And a git working tree on a production box invites someone to fix a bug in place at 1am, which then vanishes on the next deploy with no record it existed.
rsync -az --delete \
--exclude '.git/' \
--exclude '.env*' \
--exclude 'node_modules/' \
--exclude 'media/' \
./ "${VPS_USER}@${VPS_HOST}:${DEPLOY_PATH}/"
ssh "${VPS_USER}@${VPS_HOST}" \
"cd '${DEPLOY_PATH}' && IMAGE_TAG='${GITHUB_SHA}' ./scripts/deploy/production.sh"
Consequences worth writing on the runbook: the deploy directory is rsync-managed, not a git checkout. --delete means anything not in the repo is removed, so runtime state - uploads, .env, caches - must be excluded or mounted from outside the tree. And since there is no git history on the server, the deploy script writes the shipped SHA to .deploy-revision, which is the only way to answer “what is actually running right now”.
The tag is the contract
IMAGE_TAG = GITHUB_SHA
The candidate is built with that tag and the promotion step uses the same tag rather than rebuilding. This sounds pedantic and is not: if promote rebuilds, you have health-checked one artefact and shipped a different one. Same idea as building a release artefact once and promoting it rather than rebuilding per target.
The API deploy, phase by phase
| Phase | Action | Live API up? |
|---|---|---|
| 0 | Clean the deploy tree, write .deploy-revision |
yes |
| 1 | compose build api |
yes |
| 2 | Run migrations as a one-off container | yes |
| 3 | Start the candidate | yes |
| 4 | Wait for candidate Docker health, ~300s timeout | yes |
| 5 | Smoke test https://example.com/api/health/ through the proxy |
yes |
| 6 | docker stop -t 30 the live container |
candidate serves 100% |
| 7 | compose up -d --no-deps api on the new image |
yes |
| 8 | Wait for the canonical container to report healthy | yes |
| 9 | Remove the candidate | yes |
| 10 | Recreate background workers | yes |
| 11 | Final smoke test | yes |
Time ─────────────────────────────────────────────────────────────►
Live ████████████████████████████████████░░ stop
│
Candidate ░░ build ░░ ████ health ██████████████░░ remove
│ smoke │ promote
Migrate ████ one-off │ │
Workers ░░ recreate ░░
Client traffic ───────────────────────────────────────────────────►
Two phases carry most of the weight.
Phase 4 is the gate. Docker’s own health status is the signal, which means the healthcheck in the Dockerfile is load-bearing infrastructure, not decoration. A healthcheck that only checks the process is alive will cheerfully promote a container that cannot reach the database. Mine hits a readiness endpoint that touches the DB and the cache.
Phase 5 is the second gate, and it exists because Phase 4 can pass while the request still fails. The container being healthy on its own network is not the same as the request path working - labels can be wrong, the container can be on the wrong network, TLS can be misconfigured. Phase 5 goes out through the public hostname and comes back in through Traefik, which is the path a user takes.
Only after both gates pass does anything stop. Everything before Phase 6 is reversible by deleting a container nobody was told about.
The web deploy
Same shape, fewer moving parts - no migrations, no workers.
| Phase | Action | Live UI up? |
|---|---|---|
| 1 | Build the image at IMAGE_TAG |
yes |
| 2 | Start the candidate | yes |
| 3 | Wait for health | yes |
| 4 | In-container smoke: wget -S /, expect 200, not a redirect |
yes |
| 5 | docker stop -t 30 live |
candidate serves 100% |
| 6 | Promote canonical | yes |
| 7 | Wait for health, remove candidate | yes |
| 8 | Brief pause for label rediscovery, then external smoke | yes |
The front end’s smoke test is fussier about content than the API’s. A static server will happily return 200 for a build that routed / to the sign-in page, or shipped an asset manifest referencing chunks that are not in the image. Checking the status code alone passes both. So the in-container check asserts the response is a 200 and not a redirect, before anything is drained.
Routing stays unambiguous by keeping the API rule specific and the web rule broad:
PathPrefix(`/api`) → service "api" (higher priority)
Host(`example.com`) → service "web" (everything else)
The web front end never gets a chance to swallow /api on a bad day.
Migrating while the old code still serves
Phase 2 runs migrations while the old version is still the only thing serving traffic. That is a deliberate choice with a hard constraint attached:
Between Phase 2 and Phase 6, the old code is running against the new schema. If that combination does not work, you do not have a zero-downtime deploy - you have a broken one with extra steps.
Which sorts every schema change into two piles:
| Safe during overlap | Needs a window, or a second release |
|---|---|
| Add a nullable column | Drop a column the old code still selects |
| Add a table | Rename a column in one release |
| Add an index concurrently | NOT NULL without a default while old writers omit it |
| Backfill in a separate pass | Destructive rewrites only the new code understands |
The discipline is expand, then contract. Release one adds the new column and writes to both. Release two reads from the new column. Release three drops the old one. Three deploys to rename a field feels absurd until the alternative is a maintenance window, and the whole point of this pipeline is not needing one.
The migration step is also the last fully safe abort point. It runs before the candidate exists, so a failure there leaves the live container untouched and the deploy simply stops. On multi-tenant setups where migrations run per schema, this phase is the long pole - and worth thinking about early, because a per-tenant migration that takes minutes changes the shape of the overlap window.
Failing closed, and rolling back
The deploy script traps errors, and the response depends on one boolean: has the live container been stopped yet?
deploy error
│
┌─────────────┴─────────────┐
▼ ▼
DRAINED = 0 DRAINED = 1
(before phase 6) (after live was stopped)
│ │
▼ ▼
remove candidate do NOT clean up
live never stopped log loudly, exit non-zero
users unaffected operator checks canonical
Before drain, cleanup is safe and automatic - the candidate is removed and nobody outside the pipeline ever knew it existed. After drain, the script deliberately stops helping. An automated cleanup at that point could remove the only container serving traffic. Loud failure beats clever recovery when the blast radius is production.
| Failure | Outcome |
|---|---|
| Build fails | Live untouched |
| Migration fails | Live untouched, deploy aborts |
| Candidate never healthy | Candidate removed, live untouched |
| Smoke fails before drain | Candidate removed, live untouched |
| Failure after drain | Rare, and the one case that pages a human |
Rollback uses the same machinery, which is the point of building it this way. Point production at the last known-good commit and re-run the workflow - it blue-green deploys that SHA exactly like any other release, with the same gates. There is no separate, less-tested rollback path to discover is broken at the worst possible moment.
What you cannot do is git reset on the server. The deploy directory is not a git repo, and treating it like one is how you end up with files from two releases. Roll back through the pipeline.
If the deploy failed before drain, there is usually nothing to roll back at all. Live never changed.
Proving it actually held
Claiming zero downtime and measuring it are different activities. From any machine with network access, during a real deploy:
while curl -sf https://example.com/api/health/ >/dev/null; do
printf '.'
sleep 1
done
echo "FAILED at $(date -Is)"
The loop exits on the first non-success. A clean cutover keeps it running for the entire deploy. Run it in a second terminal while you watch the Actions log - it takes ten seconds to set up and it is the only evidence that matters.
Afterwards, on the server:
cd "$DEPLOY_PATH"
cat .deploy-revision # matches the Actions run SHA?
docker ps # canonical container healthy, no orphan candidate
curl -sf https://example.com/api/health/
The one drill worth running deliberately: break a candidate’s healthcheck on purpose in a staging copy and run the deploy. Confirm it aborts at Phase 4 and the live container is still serving. A safety mechanism you have never seen fire is a safety mechanism you are guessing about.
What this does not cover
Being honest about the boundary is more useful than the claim.
| Component | During an app deploy | Why |
|---|---|---|
| API | Up throughout | Blue-green plus proxy load balancing |
| Web UI | Up throughout | Same |
| Background workers | Brief restart | Recreated after cutover - queued jobs delay, they do not vanish |
| PostgreSQL, Redis, PgBouncer | Untouched | --no-deps keeps the data plane out of the app recreate |
| The proxy itself | Separate lifecycle | Traefik is not rebuilt on every app push |
| Desktop or mobile clients | Separate pipeline | Different release cadence entirely |
Workers are the honest gap. A brief worker restart is acceptable because a durable queue makes it a latency event rather than a correctness one - which only holds if the jobs are idempotent and the queue survives the restart. If that is not true of your workers, fix that before you worry about the API’s 502s. It is the larger problem.
The other gap is cross-service atomicity. Two repos deploy independently, so there is no instant where the front end and API switch together. If a release needs both, the API ships the new field first in a backward-compatible release, and the UI follows once it is live. That is release discipline, not infrastructure - no pipeline will save you from a breaking API change shipped in lockstep.
The operator checklist
Once per server
- Docker and the Compose plugin installed, deploy user in the
dockergroup - The external proxy network exists and the proxy is running on it
- Deploy directories created,
.envfiles present and not in git - Initial stack brought up once by hand, so the deploy script only ever swaps
Once per repo
- Secrets: host, user, SSH key, deploy path
- Workflow on the production branch, with
concurrencyset to queue rather than cancel - Branch protection so the only way in is a reviewed merge
Every release
- CI green before merge
- Migrations reviewed against the expand-only table above
- Watch the phase log through cutover
- Confirm
.deploy-revisionand the external health URL - Optional, and cheap: run the curl loop during the deploy
When something looks wrong
| Symptom | First thing to check |
|---|---|
| SSH fails in Actions | Host, key, firewall, user in the docker group |
| Candidate never healthy | docker logs app-*-candidate - almost always config or DB reachability |
| 502 after cutover | Container health, proxy labels, is it on the right Docker network |
| Two versions serving forever | Orphaned candidate from a failed run - docker ps will show it |
| Mystery files on the server | Something was edited in place; rsync --delete will remove it next deploy |
The sequence is the guarantee
There is no flag for this. Strip the pipeline down and what is left is an ordering rule and two gates:
gate on quality in CI
→
deliver code, build the new image (live still serving)
→
migrate compatibly (live still serving)
→
start candidate → health → smoke (live still serving)
→
drain live → promote → clean up
→
smoke again
Every step before the drain is reversible. The drain only happens after two independent checks agree the new version works, one of them through the same path a user takes. And the data services stay out of the recreate entirely.
The rest is discipline: migrations that respect the overlap window, workers whose restart is a latency event rather than a data-loss event, and a rollback path that is the deploy path so it cannot rot. Get those right and deploying stops being an event you schedule around, which is worth more than the uptime figure.