Stop Dropping Connections: The Engineer's Guide to Zero-Downtime Deployments with Docker Compose
How to Deploy Docker Compose Applications Without Interruptions Using Rolling Updates, Reverse Proxies, Health Checks, and Smart Container Strategies
Senior Developer

Most developers hit this wall at some point. You push a fix, your CI runs, and for 15 awkward seconds your app returns a 502. Your users see an error. You refresh obsessively until the container is back up. You tell yourself you'll fix it "next sprint."
This is the guide for next sprint.
You do not need Kubernetes. You do not need a load balancer service costing $50/month. You need a blue-green deployment strategy, a clean Nginx config, and a GitHub Actions workflow. This entire setup runs comfortably on a $5/month Hetzner or DigitalOcean VPS.
How Blue-Green Deployments Work
The idea is simple. You run two environments โ blue and green โ but only one serves live traffic at any time. When you deploy:
The new version starts in the idle environment (say, green)
You health-check green until it's ready
You flip Nginx to point at green
You shut down blue
No gap. No 502. Users never know anything changed. If green fails its health check, Nginx never switches and your old version keeps running.
Project Structure
/project
โโโ app/ # your application code
โโโ nginx/
โ โโโ nginx.conf # base config
โ โโโ blue.conf # upstream pointing to blue container
โ โโโ green.conf # upstream pointing to green container
โโโ docker-compose.blue.yml
โโโ docker-compose.green.yml
โโโ .github/
โโโ workflows/
โโโ deploy.yml
Step 1 โ Docker Compose Files
You maintain two almost-identical Compose files. The only difference is the container name and port.
docker-compose.blue.yml
version: "3.8"
services:
app:
image: ghcr.io/your-org/your-app:${IMAGE_TAG}
container_name: app_blue
ports:
- "3001:3000"
env_file:
- .env.production
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
docker-compose.green.yml
version: "3.8"
services:
app:
image: ghcr.io/your-org/your-app:${IMAGE_TAG}
container_name: app_green
ports:
- "3002:3000"
env_file:
- .env.production
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
The key: blue listens on host port 3001, green on 3002. Nginx decides which one gets traffic.
Step 2 โ Nginx Configuration
nginx/nginx.conf (base config, loaded by systemd or as a container)
worker_processes auto;
events { worker_connections 1024; }
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
# Active upstream โ swap this file to switch environments
include /etc/nginx/active-upstream.conf;
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location / {
proxy_pass http://app_upstream;
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 5s;
proxy_read_timeout 60s;
}
location /health {
access_log off;
proxy_pass http://app_upstream/health;
}
}
}
nginx/blue.conf
upstream app_upstream {
server 127.0.0.1:3001;
}
nginx/green.conf
upstream app_upstream {
server 127.0.0.1:3002;
}
On your VPS, /etc/nginx/active-upstream.conf is a symlink. Switching environments means updating the symlink and reloading Nginx โ no downtime, no restart.
Initial setup on the server:
# Start with blue as active
ln -s /etc/nginx/blue.conf /etc/nginx/active-upstream.conf
nginx -t && systemctl reload nginx
Step 3 โ The Deploy Script
Create this on your VPS at /opt/deploy/swap.sh. This is the heart of the whole thing.
#!/bin/bash
set -e
IMAGE_TAG=$1
CURRENT=$(readlink /etc/nginx/active-upstream.conf | grep -o 'blue\|green')
if [ "$CURRENT" = "blue" ]; then
NEXT="green"
NEXT_PORT=3002
COMPOSE_FILE="docker-compose.green.yml"
else
NEXT="blue"
NEXT_PORT=3001
COMPOSE_FILE="docker-compose.blue.yml"
fi
echo "Current: $CURRENT โ Deploying to: $NEXT (port $NEXT_PORT)"
# Pull new image
IMAGE_TAG=$IMAGE_TAG docker-compose -f $COMPOSE_FILE pull
# Start new environment
IMAGE_TAG=$IMAGE_TAG docker-compose -f $COMPOSE_FILE up -d
# Wait for health check to pass
echo "Waiting for $NEXT to become healthy..."
RETRIES=0
until curl -sf http://localhost:$NEXT_PORT/health > /dev/null; do
RETRIES=$((RETRIES + 1))
if [ $RETRIES -ge 12 ]; then
echo "Health check failed after 60s. Aborting. $CURRENT is still live."
IMAGE_TAG=$IMAGE_TAG docker-compose -f $COMPOSE_FILE down
exit 1
fi
echo "Not ready yet. Retrying in 5s... ($RETRIES/12)"
sleep 5
done
echo "$NEXT is healthy. Switching Nginx..."
# Swap the symlink
ln -sf /etc/nginx/$NEXT.conf /etc/nginx/active-upstream.conf
nginx -t && systemctl reload nginx
echo "Nginx now pointing to $NEXT. Shutting down $CURRENT..."
# Stop old environment
if [ "$CURRENT" = "blue" ]; then
IMAGE_TAG=$IMAGE_TAG docker-compose -f docker-compose.blue.yml down
else
IMAGE_TAG=$IMAGE_TAG docker-compose -f docker-compose.green.yml down
fi
echo "Deploy complete. Active: $NEXT"
Make it executable:
chmod +x /opt/deploy/swap.shStep 4 โ GitHub Actions Workflow
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /opt/deploy
/opt/deploy/swap.sh ${{ github.sha }}
Step 5 โ Secrets to Add in GitHub
Go to your repo โ Settings โ Secrets and Variables โ Actions, and add:
Secret | Value |
|---|---|
| Your server IP or domain |
| The deploy user (e.g. |
| Private SSH key for that user |
Create a dedicated deploy user on your VPS with limited permissions โ do not use root:
# On your VPS
adduser deploy
usermod -aG docker deploy
mkdir -p /home/deploy/.ssh
# Paste your public key into /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keysGive the deploy user passwordless sudo for only the nginx reload:
# /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx, /bin/lnA Note on Your /health Endpoint
Your app must expose a /health endpoint. It should return a 200 OK only when the app is genuinely ready โ database connected, caches warm, whatever "ready" means for your service. A 200 from an app that cannot reach its database is worse than no health check at all.
A minimal Node.js example:
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1'); // confirm DB is reachable
res.status(200).json({ status: 'ok' });
} catch (err) {
res.status(503).json({ status: 'error', message: err.message });
}
});What This Gets You
Zero-downtime deployments from a
git pushAutomatic rollback if the new version fails its health check
The entire thing runs on a $5 VPS with no external services
One less excuse for that 2 AM 502 error
The tradeoff is that you are running two containers briefly during each deploy. On a $5 VPS with modest RAM, this is fine for most apps. If memory is very tight, you can use rolling restart instead of blue-green โ but you lose the safety net of the health check gate.
Comments (0)
Login to post a comment.