Zero-Downtime Blue-Green Deployments with docker-rollout
Ship to a single Docker host without dropping a request. This guide wires up docker-rollout for blue-green cutovers, readiness healthchecks and graceful draining, Traefik routing that follows container health, and expand/contract migrations that never break the running version.
You can ship to a single Docker Compose host without dropping a request. The recipe is docker-rollout, a reverse proxy that routes on health, an application that knows when it is ready, and migrations that never break the running version.
docker compose up -d web causes downtime by design. Docker stops the old container before the new one exists. For a service that takes 3–8 seconds to boot, that is a burst of 502s on every deploy.
Blue-green deployment fixes this by running the new version (green) next to the old one (blue), moving traffic to green once it is ready, and only then retiring blue. Kubernetes gives you this with Deployments and readiness probes. On single VM setups, you get it with docker-rollout, which is a single-file shell plugin for the Docker CLI.
docker-rollout scales the service to twice its replica count and then removes the old half, so it is a short-lived blue-green cutover rather than two permanent environments. For a single-host Compose setup, that is exactly what you want.
This guide covers four pieces, in the order you set them up: installation and use, application healthchecks, reverse proxying with Traefik or NGINX, and database migrations. The examples use FastAPI, PostgreSQL and Alembic, but every pattern should carry over to any stack.
How docker-rollout works
Running docker rollout web performs five steps:
- Count the running containers for the
webservice (say, 1). - Scale
webto double that count with the new image (project-web-1keeps running;project-web-2starts). - Wait for the new containers to report
healthy, up to--timeoutseconds (default 60). Without a healthcheck, it sleeps--waitseconds (default 10) and hopes. - Run the optional
--pre-stop-hookinside the old containers, then stop and remove them. - If the new containers never become healthy, remove them instead and leave the old ones serving. The deployment fails and production does not.
IT is important to note that this tool does not route traffic. Your reverse proxy does, which is why the proxy and the healthcheck carry most of the weight in this setup. Each deploy also increments the container number (web-1 → web-2 → web-3), so never reference containers by name.
Part 1: Database migrations
This is where most zero-downtime setups actually go down. During a rollout, the old and new versions of your code run against the same database at the same time. If a rollout fails its healthcheck, the old version keeps serving against the new schema indefinitely. Every migration must therefore work with both the code before it and the code after it.
Run migrations as a separate step
Migrate with the new image, in a one-shot container, before the rollout starts:
docker compose run --rm api alembic upgrade head
docker rollout apiNever run migrations on application startup. With docker-rollout, two containers boot during every deploy, and both would race to migrate. A migration failure would also turn into a healthcheck failure, which is harder to read than a failed deploy step.
Safe and unsafe schema changes
Change | Safe in one deploy? | Zero-downtime alternative |
|---|---|---|
Add a nullable column | Yes | — |
Add a column with a constant default | Yes (Postgres 11+) | — |
Add a table | Yes | — |
Add an index | No: blocks writes |
|
Add a NOT NULL column without default | No: old code inserts fail | Add nullable, backfill, enforce later |
Rename a column or table | No: old code breaks | Expand/contract across 3 deploys |
Drop a column | No: old code still reads it | Stop using it first, drop next deploy |
Change a column type | No: rewrites the table | New column, dual-write, backfill, switch |
Add a foreign key or check constraint | No: scans under lock | Add |
Expand/contract: a column rename
Renaming tenants.phone to tenants.phone_number takes three deploys. Each one is compatible with the version on either side of it.
- Expand. Migration adds
phone_numberas nullable. Code writes to both columns and reads fromphone. - Migrate. A batched backfill copies
phoneintophone_numberfor existing rows. Code switches reads tophone_numberand still writes both. - Contract. Code stops touching
phone. The migration in the following deploy drops it.
Backfill in batches so no single statement holds row locks for long:
UPDATE tenants
SET phone_number = phone
WHERE id IN (
SELECT id FROM tenants
WHERE phone_number IS NULL AND phone IS NOT NULL
LIMIT 5000
);
-- repeat until 0 rows updatedFail fast on locks
The dangerous migration is not the slow one. It is the one that waits for a lock: an ALTER TABLE queued behind a long-running query blocks every query that arrives after it, and your API stalls. Set a lock_timeout for the migration connection so it gives up after 5 seconds instead:
# alembic/env.py (psycopg)
connectable = create_engine(
config.get_main_option("sqlalchemy.url"),
connect_args={"options": "-c lock_timeout=5s"},
)
# asyncpg: connect_args={"server_settings": {"lock_timeout": "5s"}}A failed migration fails the deploy before docker rollout runs, and production keeps serving the old version. Rerun it when traffic is lower.
Concurrent indexes in Alembic
CREATE INDEX CONCURRENTLY cannot run inside a transaction, and Alembic wraps each migration in one. Use an autocommit block:
def upgrade() -> None:
with op.get_context().autocommit_block():
op.create_index(
"ix_payments_tenant_id",
"payments",
["tenant_id"],
postgresql_concurrently=True,
if_not_exists=True,
)A concurrent build that fails leaves an INVALID index behind. if_not_exists will not rebuild it, so drop it with DROP INDEX CONCURRENTLY before rerunning.
Roll forward, not back
Do not run alembic downgrade in production. Because every schema change is backward compatible, rolling back the application is just redeploying the previous image tag against the current schema. Fix a bad migration with a new migration.
Part 2: Application healthchecks
The healthcheck is the contract between your app and the deploy. docker-rollout waits on it, and your revers proxy routes on it. A container that reports healthy before it can serve requests turns zero-downtime into a race.
Liveness vs readiness
Expose two endpoints. Liveness answers "is the process alive?" Readiness answers "should this container receive traffic right now?" Docker has a single health status, so wire it to readiness.
# app/health.py
import asyncio
from pathlib import Path
from fastapi import APIRouter, Response
from sqlalchemy import text
from app.db import engine
from app.cache import redis
router = APIRouter(prefix="/health", tags=["health"])
DRAIN_FILE = Path("/tmp/drain")
@router.get("/live")
async def live() -> dict:
return {"status": "ok"}
@router.get("/ready")
async def ready(response: Response) -> dict:
if DRAIN_FILE.exists():
response.status_code = 503
return {"status": "draining"}
try:
async with asyncio.timeout(2):
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
await redis.ping()
except Exception as exc:
response.status_code = 503
return {"status": "unavailable", "reason": type(exc).__name__}
return {"status": "ready"}Three rules for the readiness check:
Check only hard dependencies. Postgres and Redis, yes. A third-party payment API or SMS gateway, no. An outage there would mark every container unhealthy and take your whole API offline.
- Bound it with a timeout. A hung connection must fail in 2 seconds, not hang the probe.
- Do the drain check first. The drain file flips readiness to 503 on purpose during a rollout (see below), regardless of dependencies.
If startup does real work (warming caches, loading an ML model), set a module-level ready flag at the end of FastAPI's lifespan startup and return 503 until it is set.
The Docker healthcheck
Slim Python images ship without curl, so probe with Python itself:
services:
api:
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://localhost:8000/health/ready', timeout=3)"
interval: 5s
timeout: 4s
retries: 2
start_period: 30s
start_interval: 1surlopen raises on any non-2xx response, so a 503 exits non-zero and Docker counts a failure. start_period gives the app 30 seconds to boot without failures counting, and start_interval: 1s probes every second during that window so the container turns healthy as soon as it is ready instead of waiting for the next 5-second tick. start_interval needs Docker Engine 25 or newer.
Graceful shutdown
When docker-rollout removes the old container, Docker sends SIGTERM, waits stop_grace_period, then sends SIGKILL. Your app must receive that SIGTERM and finish in-flight requests within the window.
# Exec form: uvicorn runs as PID 1 and receives SIGTERM directly
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", \
"--timeout-graceful-shutdown", "25"]The shell form (CMD uvicorn app.main:app) wraps the process in /bin/sh, which swallows SIGTERM; Docker then kills the app after the grace period with requests still open. Set stop_grace_period: 30s on the service so Docker waits longer than uvicorn's 25-second drain.
Draining before stopping
Graceful shutdown finishes requests already in flight. It does nothing about new requests the proxy is still sending to the old container. Draining closes that gap: mark the old container unhealthy first, wait for the proxy to stop routing to it, then stop it.
docker rollout api --pre-stop-hook "touch /tmp/drain && sleep 15"The hook creates /tmp/drain in the old container, readiness returns 503, Docker marks it unhealthy after interval × retries (5 s × 2 = 10 s), and Traefik drops it from the pool. Size the sleep as:
t_{sleep} > t_{interval} \times n_{retries} + t_{longest\ request}With a 5-second interval, 2 retries and requests that finish within 5 seconds, 15 seconds is enough. Put it on the service as a label so every deploy uses it:
labels:
docker-rollout.pre-stop-hook: "touch /tmp/drain && sleep 15"Part 3: Reverse proxying (with Traefik)
The proxy is what makes the cutover invisible. It must discover containers as they appear, route only to healthy ones, and forget containers the moment they turn unhealthy. Traefik's Docker provider does all three from container labels, with no reload.
The proxy service
services:
traefik:
image: traefik:v3 # pin a minor version in production
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=web
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.tlschallenge=true
- [email protected]
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt:/letsencrypt
networks: [web]
restart: unless-stopped
networks:
web:
internal:
volumes:
letsencrypt:Traefik is the only service that publishes ports. exposedbydefault=false means nothing is routed unless it opts in with traefik.enable=true, so Postgres and Redis on the internal network stay unreachable from outside.
Routing the application
services:
api:
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`api.example.com`)
- traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le
- traefik.http.services.api.loadbalancer.server.port=8000
# Active health check: a second, faster signal than Docker's
- traefik.http.services.api.loadbalancer.healthcheck.path=/health/ready
- traefik.http.services.api.loadbalancer.healthcheck.interval=2s
- traefik.http.services.api.loadbalancer.healthcheck.timeout=1s
# Retry on connection errors to a container that just went away
- traefik.http.routers.api.middlewares=api-retry
- traefik.http.middlewares.api-retry.retry.attempts=2
- traefik.http.middlewares.api-retry.retry.initialinterval=100ms
- docker-rollout.pre-stop-hook=touch /tmp/drain && sleep 15The router and service names (api) are defined once, on the Compose service, not per container. When docker-rollout starts api-2, Traefik sees a second container carrying the same labels and adds it as another server behind the same service. That is the whole blue-green mechanism: two servers in one load balancer, then one.
Health-aware routing
Traefik's Docker provider skips containers whose Docker health status is starting or unhealthy. The new container therefore receives no traffic until its readiness check passes, and the draining container leaves the pool as soon as Docker marks it unhealthy.
The Traefik-level healthcheck labels add an active probe every 2 seconds from the proxy itself. It reacts faster than Docker's 5-second interval and catches failures Docker misses, such as a container that is healthy from inside but unreachable over the network.
The retry middleware covers the last edge case: a request sent to a container in the instant it stops accepting connections. Traefik retries only when it cannot reach the server at all, not when your app returns an error, so a request your app handled is not replayed. Keep your webhook and payment-callback handlers idempotent regardless.
What about nginx-proxy?
nginx-proxy bundles NGINX and docker-gen in one image and routes by VIRTUAL_HOST environment variables. It is an alternative proxy, with two catches. Its stock template builds upstreams without reading container health, and it mounts the Docker socket into the internet-facing container.
Part 4: Installation and use
docker-rollout installs as a Docker CLI plugin.
mkdir -p ~/.docker/cli-plugins
curl https://raw.githubusercontent.com/wowu/docker-rollout/main/docker-rollout \
-o ~/.docker/cli-plugins/docker-rollout
chmod +x ~/.docker/cli-plugins/docker-rollout
docker rollout --help # verifyIf your deploy user runs Docker through sudo, install to /usr/local/lib/docker/cli-plugins/ instead so every user sees it. In production, pin a release tag rather than main (replace main in the URL with e.g. v0.14), so a new upstream commit never changes your deploy behaviour unannounced.
Compose requirements
Two things break the moment a second container of the same service starts:
container_namemust go. Docker will not create two containers with the same name.portsmust go. Two containers cannot bind the same host port. The proxy reaches the app over the Compose network instead.
A rollout-ready service should look something like this:
services:
api:
image: ghcr.io/acme/api:${IMAGE_TAG}
env_file: .env
networks: [web, internal]
stop_grace_period: 30s
# no container_name, no portsCommand-line options
Option | Default | Use it for |
|---|---|---|
|
| Compose file(s), repeatable |
| 60 s | Max wait for the new container to turn healthy |
| 10 s | Fixed wait when no healthcheck exists |
| 0 s | Grace period after healthy, before old containers stop |
| none | Command run in old containers before stopping (draining) |
| none | Env file(s), repeatable |
| directory name | Compose project name |
| none | Compose profile to enable |
The pre-stop hook can also live on the service as a label, docker-rollout.pre-stop-hook. docker-rollout reads labels from the old container, so a newly added label takes effect on the deploy after the one that introduces it.
What to roll out, and what not to
Roll out stateless HTTP services: your API, your web frontend. Do not roll out Postgres, Redis or anything holding a volume lock. Workers and Kafka consumers can be rolled out only if two copies running side by side for a few seconds is safe, which it is when consumers are idempotent; otherwise deploy them with plain docker compose up -d.
Putting it together
One script runs the whole deploy: pull the image, migrate, roll out, verify. Each step fails the deploy before the next runs, so a broken release never reaches traffic.
#!/usr/bin/env bash
# deploy.sh <image-tag>
set -euo pipefail
cd /opt/app
export IMAGE_TAG="${1:?usage: deploy.sh <image-tag>}"
PREVIOUS_TAG="$(cat .current-tag 2>/dev/null || true)"
echo "==> Pulling ${IMAGE_TAG}"
docker compose pull api
echo "==> Migrating"
docker compose run --rm api alembic upgrade head
echo "==> Rolling out"
docker rollout api --timeout 90 --wait-after-healthy 3
echo "==> Smoke test"
if ! curl -fsS --retry 3 --retry-delay 2 https://api.example.com/health/ready > /dev/null; then
echo "Smoke test failed" >&2
if [[ -n "${PREVIOUS_TAG}" ]]; then
echo "==> Rolling back to ${PREVIOUS_TAG}" >&2
IMAGE_TAG="${PREVIOUS_TAG}" docker rollout api --timeout 90
fi
exit 1
fi
echo "${IMAGE_TAG}" > .current-tag
echo "==> Deployed ${IMAGE_TAG}"Tag images by git SHA, never latest. The tag is what makes rollback a one-liner (./deploy.sh <previous-sha>), and the backward-compatible migrations are what make that rollback safe. Call the script from CI over SSH after the image build and push succeed:
# .github/workflows/deploy.yml (deploy job)
- name: Deploy
run: ssh deploy@${{ secrets.DEPLOY_HOST }} "/opt/app/deploy.sh ${{ github.sha }}"Limitations of this setup
docker-rollout gives you zero-downtime deploys, not high availability. It is important to know wehere this stops:
- One host is still one host. A kernel update, a reboot or a disk failure takes everything down. The next step is a second host behind a load balancer.
- The proxy is not rolled out. Replacing the Traefik or NGINX container drops connections for a second or two. Do it deliberately, off-peak, separate from app deploys.
- Memory doubles during a rollout. Two copies of the service run for the length of the deploy. Size the host for it.
- Long-lived connections are cut. WebSockets and server-sent event streams outlive any reasonable drain sleep. Clients must reconnect.
- Migration discipline is on you. Nothing stops a careless
DROP COLUMN. Review migrations against the table above before merging.
Within those limits, this setup ships to a single Compose host as often as you like, with no dropped requests and no Kubernetes.
Sources
- docker-rollout on GitHub: installation, CLI options, caveats and container draining
- docker-rollout v0.14 release
- docker-gen on GitHub: watched events, template context and flags
- nginx-proxy stock template
- NGINX 1.28 changelog: upstream
resolve(1.27.3) and non-idempotent retry behaviour (1.9.13)