Docker & ContainersXXIII Β· High AvailabilityDeployment
Zero-downtime deploys without an orchestrator
What you'll learn
- Explain why docker compose up -d produces a service gap
- Run a blue/green cutover gated on health, with a rollback path
- Sequence deregistration, draining and shutdown so no request is dropped
Prerequisites
Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11
The availability lesson put a number on it: a recreate-in-place deploy costs roughly thirty seconds, and twenty deploys a month is more downtime than a 99.99% budget allows for the entire month. If your deploy cadence is the largest line in your downtime table β and for well-run teams it usually is β this is the highest-value thing you can fix, and you can fix it without adopting an orchestrator.
Why the gap exists
docker compose up -d on a changed service stops the old container
and starts the new one. There is no overlap by design: the service
name, the published port and often a named volume can only belong to
one container at a time.
sequenceDiagram
participant U as User
participant P as Proxy
participant O as Old container
participant N as New container
U->>P: request
P->>O: forwarded, 200
Note over O: docker compose up -d
O--xO: SIGTERM, stops
U->>P: request
P--xU: 502, nothing to forward to
Note over N: image pull, start, warm up
N->>P: listening
U->>P: request
P->>N: forwarded, 200
The gap is the pull, the start, and the warm-up. You cannot shorten it to zero. You can arrange for something else to be serving during it.
Blue/green with two Compose projects
Composeβs -p flag names a project. Two projects from the same file
are two complete, independent stacks with their own containers,
networks and volume namespaces.
services:
app:
image: myapp:${APP_VERSION:?set APP_VERSION}
restart: unless-stopped
stop_grace_period: 45s
networks: [edge]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health/ready"]
interval: 5s
timeout: 3s
retries: 3
start_period: 20s
networks:
edge:
external: true
name: edgeThe cutover, in full:
set -euo pipefail
LIVE=blue
NEXT=green
export APP_VERSION=1.5.0
# 1. Bring up the standby colour and block until it is healthy.
docker compose -p "app-$NEXT" up -d --wait --wait-timeout 180
# 2. Prove it from outside, on its own address, before it takes traffic.
NEXT_IP=$(docker inspect "app-$NEXT-app-1" \
--format '{{(index .NetworkSettings.Networks "edge").IPAddress}}')
curl -fsS "http://$NEXT_IP:8080/health/ready"
# 3. Point the proxy at the new colour and reload.
sed -i "s/app-$LIVE/app-$NEXT/" /etc/nginx/conf.d/upstream.conf
docker exec proxy nginx -t
docker kill -s HUP proxy
# 4. Leave the old colour running as the rollback path.
echo "cutover done; $LIVE still up for rollback"--wait is what makes this a gated cutover rather than a hopeful
one. It blocks until every service in the project is running or
healthy, and --wait-timeout bounds how long you are prepared to
wait before treating the deploy as failed. Without it the script
races the applicationβs startup and sometimes wins.
Rollback is step 3 with the two names swapped, and it takes as long as an nginx reload. That is the real prize here: not the absence of a gap on the way in, but the presence of a thirty-second way back out.
- Deploy the standby colour and wait for health. Nothing user-visible has happened yet, so a failure here is a non-event.
- Smoke-test the standby directly, bypassing the proxy. A readiness endpoint plus one real request against a known-good input.
- Cut the proxy over and reload. This is the only step with user impact and it is measured in milliseconds.
- Watch error rate and latency for an agreed soak period β ten minutes is a common choice.
- Either roll back by reloading the proxy at the old colour, or tear the old colour down and record which colour is now live.
Draining: the part everyone gets backwards
A βzero-downtimeβ deploy that drops requests usually has the shutdown sequence in the wrong order.
The container is receiving traffic. You send SIGTERM. The
application starts shutting down. The proxy, which has not been told
anything, keeps sending new requests for another few seconds β and
they land on a server that is closing its listener.
The correct order puts deregistration first:
1. Proxy stops sending NEW connections to the instance (deregister)
2. Wait for in-flight requests to complete (drain)
3. SIGTERM to the container (shutdown)
4. Application finishes remaining work, exits (grace)
5. SIGKILL if it has not exited (timeout)
Docker gives you control of steps 3 to 5. Step 1 belongs to the proxy, and step 2 is a wait you have to insert deliberately.
# Compose
# stop_grace_period: 45s
#
# Equivalent at the CLI:
docker run -d --name app --stop-signal SIGTERM --stop-timeout 45 myapp:1.5.0
# And when stopping ad hoc, override per invocation:
docker stop --timeout 45 appThe grace period must exceed your slowest legitimate request. If a report endpoint takes forty seconds and the grace period is ten, every deploy kills reports in progress β an outage that shows up as a handful of angry users rather than a graph, which is why it can persist for years.
The database constrains everything
Blue/green means both versions run at once, against one database. That is a hard requirement on your migrations, not a detail:
- Expand, then contract. Add the new column, deploy code that writes both and reads the new one, cut over, and only remove the old column in a later release. Never in the same deploy.
- No destructive migration during the overlap. A
DROP COLUMNapplied before cutover breaks blue, which is your rollback path. You now have no way back. - A rollback must be safe against the migrated schema. If it is not, you do not have a rollback; you have a hope.
If the migration cannot be made backward compatible, the deploy is not zero-downtime and pretending otherwise is worse than scheduling a window.
Sanity check
Knowledge check Β· 4 questions
Q1. Which Compose flag blocks a deploy script until the new project is running or healthy, so the cutover is gated rather than raced?
Q2. A blue/green deploy still drops a handful of requests at every cutover. Which ordering error is the most likely cause?
Q3. Blue/green requires both versions to run simultaneously against one database. Which practices follow from that? Select all that apply.
Q4. A blue/green setup on a single Docker host gives you automatic rollback when the error rate rises after a cutover.
Passing score: 75%. Answers are checked in this browser.