Zero-downtime deployment with Docker Compose means the new container is healthy and already receiving traffic before the old one stops. This guide builds a blue-green setup on a Linux VPS: two app slots, an Nginx proxy in front, and a deploy script that moves traffic with a reload instead of a restart.
Before you start
You need:
- A Linux VPS running Ubuntu 22.04/24.04, Debian 12 or AlmaLinux/Rocky 8–9 with Docker and the Compose plugin installed.
- An app image that exposes an HTTP health endpoint, for example
/healthz, and returns200when it is ready to serve. - A working directory, for example
/opt/app, owned by a non-root user such asdeploy. - Port 80 free on the host, or a plan to terminate TLS elsewhere.
If Docker is not installed yet, follow Install Docker and Docker Compose on a Linux VPS first.
Note: This pattern assumes your app can run two copies at once against the same database. If it cannot, fix that first — a rolling deploy is impossible otherwise.
How zero-downtime deployment works with Docker Compose
Compose alone cannot do this. docker compose up -d recreates a container, and during that window the service name resolves to nothing, so requests fail. The fix is to stop treating the container as the unit of deployment and treat the proxy upstream as the unit instead.
You run two identical services, app-blue and app-green. Only one is in the Nginx upstream at any time. A deploy starts the idle slot, waits for its health check to report healthy, rewrites the upstream file, and runs nginx -s reload. Nginx keeps existing connections open while it loads the new config, so in-flight requests finish against the old slot. Only after a drain period do you stop the old container.
Step 1: define two app slots with health checks
Create /opt/app/docker-compose.yml:
services:
app-blue:
image: ghcr.io/example/app:${APP_TAG:-1.4.2}
restart: unless-stopped
environment:
APP_ENV: production
expose:
- "3000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
interval: 5s
timeout: 3s
retries: 6
start_period: 20s
networks: [edge]
app-green:
image: ghcr.io/example/app:${APP_TAG:-1.4.2}
restart: unless-stopped
environment:
APP_ENV: production
expose:
- "3000"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
interval: 5s
timeout: 3s
retries: 6
start_period: 20s
networks: [edge]
proxy:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/upstream.conf:/etc/nginx/conf.d/upstream.conf:ro
networks: [edge]
networks:
edge:expose publishes nothing to the host — the proxy reaches the app over the edge network by service name. start_period gives a slow app time to boot before failures count.
Tip: If your image has no curl, use ["CMD-SHELL", "wget -qO- http://localhost:3000/healthz || exit 1"] instead. Health check details are covered in Docker Compose depends_on and healthchecks.
Step 2: point Nginx at the active slot
Create /opt/app/nginx/nginx.conf:
worker_processes auto;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
access_log /var/log/nginx/access.log;
include /etc/nginx/conf.d/upstream.conf;
server {
listen 80;
location / {
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
}
}
}Create /opt/app/nginx/upstream.conf:
upstream app_backend {
server app-blue:3000 max_fails=2 fail_timeout=5s;
keepalive 16;
}Start the stack:
cd /opt/app
docker compose up -d
docker compose psBoth app slots come up, but only app-blue receives traffic.
Step 3: run the deploy
Save this as /opt/app/deploy.sh and make it executable with chmod +x deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
NEW_TAG="${1:?usage: deploy.sh <image-tag>}"
ACTIVE=$(grep -oP 'server app-\K(blue|green)' nginx/upstream.conf | head -n1)
if [ "$ACTIVE" = "blue" ]; then IDLE=green; else IDLE=blue; fi
echo "active=$ACTIVE idle=$IDLE tag=$NEW_TAG"
export APP_TAG="$NEW_TAG"
docker compose pull "app-$IDLE"
docker compose up -d --no-deps "app-$IDLE"
CID=$(docker compose ps -q "app-$IDLE")
for i in $(seq 1 30); do
STATUS=$(docker inspect -f '{{.State.Health.Status}}' "$CID")
[ "$STATUS" = "healthy" ] && break
sleep 2
done
if [ "$STATUS" != "healthy" ]; then
echo "new slot never became healthy - leaving traffic on $ACTIVE"
exit 1
fi
sed -i "s/server app-$ACTIVE:3000/server app-$IDLE:3000/" nginx/upstream.conf
docker compose exec -T proxy nginx -t
docker compose exec -T proxy nginx -s reload
echo "traffic switched to $IDLE; draining $ACTIVE for 30s"
sleep 30
docker compose stop "app-$ACTIVE"
echo "deploy complete"Run it with the new tag:
./deploy.sh 1.4.3The --no-deps flag is important: without it, Compose may recreate the proxy container and you lose the reload-based switch.
Warning: Do not stop the old slot before the drain period. Requests already accepted by Nginx are still being proxied to it, and stopping it early produces 502s for real users.
Check it works
Open a second terminal and hammer the site while you deploy:
while true; do
curl -s -o /dev/null -w "%{http_code} " http://203.0.113.10/
sleep 0.2
doneEvery response should be 200. Run ./deploy.sh 1.4.3 in the first terminal and watch for a single non-200 code. Then confirm the switch:
grep server nginx/upstream.conf
docker compose psThe upstream should name the slot you just deployed, and the old slot should be Exited.
Troubleshooting
- 502 immediately after the reload — the new slot is not actually serving. Check
docker compose logs app-greenand test from inside the network withdocker compose exec -T proxy wget -qO- http://app-green:3000/healthz. nginx: [emerg] host not found in upstream "app-green"— the idle container is not running or not on theedgenetwork. Start it before reloading, and confirm both services list the same network.- Health check stays
startingforever — the image has nocurlorwget, or the endpoint path is wrong. Switch toCMD-SHELLand verify the path manually. - Users get logged out on every deploy — sessions live in container memory. Move them to Redis or a shared store so both slots see the same session data.
- Compose recreates the proxy during deploy — you omitted
--no-deps, or you edited the Compose file. Deploy app services only, and keep proxy changes in a separate maintenance window.
Next steps
- Add automatic HTTPS and label-based routing with Traefik in Docker: automatic SSL and routing by container labels.
- Harden the proxy layer with Set up Nginx as a reverse proxy for any backend app.
- Keep production and dev configs apart using Docker Compose override files.
- Move secrets out of the Compose file with Manage secrets and environment variables in Docker Compose.
