Skip to main content
RunBook Academy

Docker & ContainersXXIII Β· High AvailabilityDeployment

Zero-downtime deploys without an orchestrator

Advanced⏱ ~24 min

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

Not yet marked complete on this device.

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.

Configuration changecompose.yaml
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: edge

The cutover, in full:

Service impact possibleblue/green cutover
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.

  1. Deploy the standby colour and wait for health. Nothing user-visible has happened yet, so a failure here is a non-event.
  2. Smoke-test the standby directly, bypassing the proxy. A readiness endpoint plus one real request against a known-good input.
  3. Cut the proxy over and reload. This is the only step with user impact and it is measured in milliseconds.
  4. Watch error rate and latency for an agreed soak period β€” ten minutes is a common choice.
  5. 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.

Configuration changegrace period
# 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 app

The 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 COLUMN applied 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

  1. Q1. Which Compose flag blocks a deploy script until the new project is running or healthy, so the cutover is gated rather than raced?

  2. Q2. A blue/green deploy still drops a handful of requests at every cutover. Which ordering error is the most likely cause?

  3. Q3. Blue/green requires both versions to run simultaneously against one database. Which practices follow from that? Select all that apply.

  4. 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.