ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

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

ยฉ 2026 ZyVOP. Crafted with care for the developer community.

Made with โค๏ธ by the ZyVOP team
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

#Docker Compose#Zero Downtime Deployments#Container Orchestration#Reverse Proxy#High Availability#Linux Servers#CI/CD
Z
ZyVOP

Senior Developer

May 22, 2026
6 min read
24 views
Stop Dropping Connections: The Engineer's Guide to Zero-Downtime Deployments with Docker Compose

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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

Docker for Developers: Stop "It Works on My Machine" Forever

Docker eliminates the โ€œworks on my machineโ€ problem by packaging your app, dependencies, and runtime into portable containers. This guide covers production-grade Dockerfiles, layer caching, multi-stage builds, Docker Compose, volumes, networking, and practical workflows for real applications.

Read article

Automate Your Code Quality with Git Hooks (And Never Argue in Code Review Again)

Most code review comments should never require a reviewer. This guide shows how to automate formatting, linting, staged-file checks, and commit message validation using Git hooks, Husky, lint-staged, and commitlint before bad code ever reaches your repository.

Read article

Ditch Vercel: A Complete Guide to Auto-Deploying Next.js to a VPS via GitHub Actions

Read article

High Availability: Why Modern Systems Must Stay Online Even During Failures

Read article

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design