Docker & DevOps

Zero-downtime deploys with Docker Compose and a reverse proxy

Run blue-green container swaps behind Nginx so every deploy keeps serving traffic. Health checks gate the switch, and a reload moves traffic without dropping connections.

AdvancedLinux server6 min read

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 returns 200 when it is ready to serve.
  • A working directory, for example /opt/app, owned by a non-root user such as deploy.
  • 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:

YAML
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:

Nginx
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:

Nginx
upstream app_backend {
    server app-blue:3000 max_fails=2 fail_timeout=5s;
    keepalive 16;
}

Start the stack:

Bash
cd /opt/app
docker compose up -d
docker compose ps

Both 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:

Bash
#!/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:

Bash
./deploy.sh 1.4.3

The --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:

Bash
while true; do
  curl -s -o /dev/null -w "%{http_code} " http://203.0.113.10/
  sleep 0.2
done

Every 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:

Bash
grep server nginx/upstream.conf
docker compose ps

The 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-green and test from inside the network with docker 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 the edge network. Start it before reloading, and confirm both services list the same network.
  • Health check stays starting forever — the image has no curl or wget, or the endpoint path is wrong. Switch to CMD-SHELL and 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

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.