ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeStop Dropping Connections: The Engineer's Guide to Zero-Downtime Deployments with Docker Compose

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

ZyVOP
ZyVOP
Senior Developer
May 22, 2026
5 min read
Stop Dropping Connections: The Engineer's Guide to Zero-Downtime Deployments with Docker Compose
#Zero Downtime Deployments#Linux Servers#Container Orchestration#High Availability#CI/CD#Docker Compose#Reverse Proxy

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:

  1. The new version starts in the idle environment (say, green)

  2. You health-check green until it's ready

  3. You flip Nginx to point at green

  4. 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.sh

Step 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

VPS_HOST

Your server IP or domain

VPS_USER

The deploy user (e.g. deploy)

VPS_SSH_KEY

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_keys

Give the deploy user passwordless sudo for only the nginx reload:

# /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx, /bin/ln

A 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 push

  • Automatic 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.

ZyVOP
ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub โ€” and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropicโ€™s Pricing Shock, Granite 4.2 Openโ€‘Source Leap, and AIโ€‘Powered Security & Policy Shifts

From Anthropicโ€™s flagship model losing steam to IBMโ€™s 512โ€ฏKโ€‘token Graniteโ€ฏ4.2, plus a new wave of AIโ€‘driven security exploits and policy alarms, this weekโ€™s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1