Zero-downtime deployments with Docker Compose
Two app replicas behind Caddy, health checks, retries and a 25-line script: a zero-downtime rollout with plain Docker Compose, tested under constant load, including a broken release and a rollback.
TL;DR
docker compose up -dstops the old container before the new one answers, which in our test cost 8 failed requests out of 1,225 over about five seconds.- Run two copies of the app as two Compose services, put Caddy (opens in a new tab) in front with an active health check and
lb_try_duration, and update one copy at a time. - Under constant load, three updates with this setup served 11,226, 21,534 and 21,218 requests with none failing; without
lb_try_duration, 145 of 10,282 failed. - A release that fails its health check stops the rollout while the other copy keeps serving the old version, and rolling back is running the script with the previous tag.
On this page
Zero-downtime deployment with Docker (opens in a new tab) Compose needs two copies of your app and a reverse proxy in front that sends traffic only to copies that pass a health check and retries on the other copy when one goes away. Declare the copies as two Compose services, configure Caddy with an active health check and lb_try_duration, and update one copy at a time with a short script. We tested this under constant load on 11 November 2026: in three updates, none of 53,978 requests failed.
This article is part of our guide to deploying a web app to a VPS. It starts from the single-container setup in Deploy a Docker Compose app to Hetzner with GitHub Actions and the Next.js (opens in a new tab) image from Deploy Next.js to a VPS, but nothing here is specific to Next.js or to a provider.
Why docker compose up -d causes downtime
When the image tag changes, docker compose up -d recreates the app container: it stops the old container, then creates and starts the new one. Until the new one listens, the proxy has nowhere to send requests.
We measured it: with the single-container stack (app, PostgreSQL (opens in a new tab), Caddy) and four loops of requests running against the health route through Caddy, an update from one release to the next failed 8 of 1,225 requests, all 502, within about five seconds. A broken release is worse: when we deployed an image that could not pass its health check, Caddy answered 502 until we deployed the previous tag again.
A few seconds per deploy is acceptable for many small apps.
How zero-downtime deployment with Docker Compose works
┌── app-blue (v1 → v2 first)
Caddy ──────────┤
health checks └── app-green (v1 → v2 second)
+ retries- Two copies of the app run all the time, as two Compose services with the same definition.
- Caddy checks both every two seconds on the health route and sends traffic only to copies that answer.
- Caddy retries. If a connection to one copy fails (it is stopping, or not listening yet), Caddy tries the other copy instead of returning an error.
- The rollout recreates one copy, waits until it is healthy and Caddy has seen it, then does the other.
1. Give the image a health check
The app needs a route that answers 200 only when the copy can serve, and the image needs a HEALTHCHECK that calls it. For the Next.js app in our tests:
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]Docker then reports each container as starting, healthy or unhealthy, which the rollout script reads. Keep the route cheap and independent of slow dependencies: if it fails whenever the database blinks, both copies fail together.
2. Define two app services
A YAML anchor (x-app: &app) declares the app once and reuses it for both copies:
x-app: &app
image: ghcr.io/your-name/myapp:${APP_TAG:-latest}
restart: unless-stopped
environment:
APP_VERSION: ${APP_TAG:-latest}
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
stop_grace_period: 30s
depends_on:
db:
condition: service_healthy
services:
app-blue: *app
app-green: *app
db:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
volumes:
db-data:
caddy-data:
caddy-config:stop_grace_period: 30s gives a stopping copy up to 30 seconds to finish its requests after SIGTERM before Docker kills it. The Next.js self-hosting guide says its server finishes in-flight requests when it gets SIGINT or SIGTERM, and recommends a drain period of 10 to 30 seconds; check that your own framework shuts down cleanly too.
Two named services let a script recreate one copy, wait until it is healthy, and only then touch the other.
3. Configure Caddy to balance and retry
example.com {
encode zstd gzip
reverse_proxy app-blue:3000 app-green:3000 {
lb_policy round_robin
lb_try_duration 10s
fail_duration 30s
health_uri /api/health
health_interval 2s
health_timeout 2s
}
}What each line does, from Caddy's reverse_proxy documentation:
lb_policy round_robinspreads requests over both copies.lb_try_duration 10sturns retries on (they are off by default): Caddy keeps trying other upstreams for up to 10 seconds when the one it picked is down. A request whose connection failed is always retried; a request whose connection succeeded but whose round trip failed is retried only if it is aGET, unless you changelb_retry_match.fail_duration 30sturns on passive health checks: a copy that failed a request is avoided for 30 seconds.health_uri,health_interval,health_timeoutturn on active health checks: Caddy asks each copy for/api/healthevery 2 seconds (the default is 30) and stops sending traffic to a copy that does not answer within 2 seconds.
We checked this file with caddy validate. On a real server Caddy gets the certificate as usual; in the local test we added tls internal inside the site block, the only difference.
4. Roll out one copy at a time
deploy.sh, which passes ShellCheck 0.11.0:
#!/bin/sh
# Usage: ./deploy.sh <image tag>
# Updates one replica at a time; Caddy keeps serving from the other one.
set -eu
export APP_TAG="$1"
docker compose pull app-blue app-green
for svc in app-blue app-green; do
docker compose up -d --no-deps "$svc"
id="$(docker compose ps -q "$svc")"
deadline=$(( $(date +%s) + 120 ))
while :; do
status="$(docker inspect -f '{{.State.Health.Status}}' "$id")"
[ "$status" = healthy ] && break
if [ "$status" = unhealthy ] || [ "$(date +%s)" -gt "$deadline" ]; then
echo "$svc is $status; stopping. The other replica still serves the previous version." >&2
exit 1
fi
sleep 2
done
sleep 5 # let Caddy's health check (every 2s) see this replica before the next one restarts
done
echo "deployed $APP_TAG"docker compose pullfetches the new image first, so the switch does not wait for a download.up -d --no-deps "$svc"recreates only that copy and leaves the database and Caddy alone.- The loop reads Docker's health status for the new container.
healthymoves on;unhealthy, or two minutes without an answer, stops the rollout before the second copy is touched.
To keep the release across reboots and manual restarts, also write APP_TAG into .env (as the Hetzner (opens in a new tab) tutorial's script does) once the rollout succeeds. To call it from GitHub Actions (opens in a new tab), run it over SSH exactly as that tutorial does.
How we tested it
Locally, with Docker 29.9.3 and Compose 5.7, we ran the stack above with two tags of the same Next.js 16.4.6 app (the second with a changed page), PostgreSQL 18.6 and Caddy 2.11.9. A separate container ran four parallel loops of curl against Caddy, alternating the home page and the health route, from five seconds before each update until five seconds after it, and recorded every status code. Only the image pull was left out, because the images were built locally, and the first and last rows ran with an earlier version of the waiting loop that counted tries instead of using a deadline. The machine was a busy laptop running Docker Desktop, so the update times below are slow; the counts are what matter.
| Update | Requests during the update | Failed | Update time |
|---|---|---|---|
| v1 to v2 | 11,226 | 0 | 112 s |
| v2 back to v1 (a rollback) | 21,534 | 0 | 198 s |
| v1 to a broken v3 (stopped after the first copy) | 6,784 | 0 | 51 s |
| broken state to v2 | 21,218 | 0 | 185 s |
v2 to v1 without lb_try_duration | 10,282 | 145 (2 × 502, 143 × 503) | 103 s |
The broken release was the v2 image told to listen on another port, so its health check could never pass. The script recreated app-blue with it, saw Docker mark it unhealthy, printed its message and stopped. Caddy's health check kept app-blue out of rotation and app-green served v1 throughout. The next deploy (to v2) replaced the broken copy normally.
The last row is the same rollout with only lb_try_duration removed from the Caddyfile. Without retries, a request that Caddy sent to a copy as it stopped failed instead of moving to the other copy.
What the test does not cover: requests other than GET whose connection was already open when a copy stopped (Caddy does not retry those by default), long-running requests and WebSockets, and real network latency. Test your own app's heaviest routes the same way before you rely on it.
Rolling back
Rolling back is a deploy of the previous tag, through the same health-checked switch:
./deploy.sh v1This works only if the previous version still runs against the current database schema, which is the next point.
The limits
Migrations must suit both versions. During a rollout the old and new versions serve at the same time. Make schema changes in two steps: add (new columns, new tables, nullable or with defaults) in one release, and remove what the old version needed in a later release. Run migrations once, before the first copy switches, for example as a one-off docker compose run --rm app-blue <migrate command> with the new tag.
Memory for two copies. Two copies run all the time, not only during a deploy. Our Next.js copies used roughly 50 to 65 MB each at idle; yours will use more under traffic. Check docker stats and leave room for the database.
WebSockets and long requests. An open WebSocket stays on the copy it connected to and closes when that copy stops, so clients must reconnect. A request longer than stop_grace_period is cut.
State in memory. Sessions or caches kept in one copy's memory are not in the other's. Keep sessions in the database, a cache server or signed cookies.
Next.js specifics. The Next.js self-hosting guide warns that all instances must share the Server Functions encryption key (NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, set at build time), or a Server Function encrypted by one instance cannot be decrypted by another. It also recommends a deploymentId, so a browser holding assets of the previous release does a full reload instead of failing during and after a rolling deploy.
Other ways to get there
- docker-rollout, a Docker CLI plugin, replaces
docker compose up -d <service>withdocker rollout <service>: its README says it scales the service to twice its number of containers, waits for the new ones to be ready, and removes the old ones. It needs a proxy that finds containers by itself, and the README notes that draining in-flight requests needs extra setup. - Docker Swarm has rolling updates built in: a service's
update_configcan useorder: start-first, which starts the new task before stopping the old one. - kamal-proxy, from the makers of Kamal, is a small proxy built for this: you deploy a new instance to it and it switches traffic once the instance is healthy, with no cooperation needed from the app.
Doing this with OpsCrew
OpsCrew, our open-source deploy tool, does the switch for you with kamal-proxy behind Caddy. Its deploys run the release command (your migrations) in a one-off container first, start the new copy next to the running one, send it traffic only after its health check passes within 60 seconds, give the old copy 30 seconds to finish its requests (configurable), then check the site from outside through your domain and switch back by itself if that fails. On servers with less than 1 GB of memory it restarts the app in place instead, holding requests for up to 90 seconds while the new version starts, and it says so. Rolling back redeploys the previous image by its digest through the same switch.
For the rest of the path, from choosing the server to previews of pull requests, see the complete guide to deploying an app to a VPS and a preview of every pull request, on your own server.
Key facts
| Fact | Value | Source |
|---|---|---|
| Failed requests, single container, docker compose up -d | 8 of 1,225All 502 from Caddy, within about five seconds while the app container was replaced. Local test, 11 November 2026. | Verified on |
| Failed requests, two replicas with health checks and retries | 0 of 53,978Three rolling updates under constant load: 11,226, 21,534 and 21,218 requests. | Verified on |
| Failed requests, same rollout without lb_try_duration | 145 of 10,2822 returned 502 and 143 returned 503. | Verified on |
| Caddy retries by default | Off (lb_try_duration 0); when on, only GET requests are retried after a failed round trip, any request after a failed connection | Caddy reverse_proxy directive (opens in a new tab)Verified on |
| Versions tested | Docker 29.9.3, Compose 5.7, Caddy 2.11.9, PostgreSQL 18.6, Next.js 16.4.6 on Node.js (opens in a new tab) 24.22.2 | Verified on |
Frequently asked questions
Should I use Docker Swarm for zero-downtime deploys?
Swarm supports rolling updates natively: a service's update_config can set order to start-first, so the new task starts before the old one stops. It also changes how you deploy (docker stack deploy) and run things. For one server, two Compose services and a proxy, as here, are less to learn; Swarm makes sense when you want its scheduling across several nodes.
Is docker-rollout better than this?
docker-rollout is a Docker CLI plugin that scales a single Compose service to twice its size, waits for the new containers to be healthy and removes the old ones. It keeps one service definition instead of two, but needs a proxy that discovers containers, and its README says draining in-flight requests needs extra setup. We did not test it here.
Sources
- reverse_proxy directive (opens in a new tab)
- Compose Deploy Specification (update_config) (opens in a new tab)
- How to self-host your Next.js application (multi-server deployments, version skew) (opens in a new tab)
- docker-rollout (opens in a new tab)
- kamal-proxy (opens in a new tab)
- ShellCheck (opens in a new tab)
Related articles
- Deploying
Deploy Next.js to a VPS with Docker and HTTPS
A tested setup for Next.js 16 on your own server: standalone output, a small multi-stage Docker image, PostgreSQL and Caddy with automatic HTTPS, and the build-time traps that break apps after the first deploy.
- Deploying
Deploy a Docker app to a DigitalOcean Droplet
Create a Droplet, lock it down with a Cloud Firewall, install Docker from Docker's own repository, and run your app with Compose and automatic HTTPS. Every command tested; prices checked on 11 November 2026.
- Deploying
How to deploy a web app to a VPS: the complete guide
From an empty server to a production app: server size, Docker, a reverse proxy with HTTPS, DNS, CI/CD, secrets, backups, zero-downtime updates, previews, logs and hardening.