DevOps

Zero-Downtime Docker Compose Rolling Updates with GitHub Actions

Tech Setup5 min read
TS

Tech Setup

Published October 14, 2026 · Editorial policy

Zero-Downtime Docker Compose Rolling Updates with GitHub Actions

Every time you run docker compose up -d to push a production update, your users experience a brief connection drop while the old container stops and the new one starts. For a multi-container web application running on a single Virtual Private Server (VPS), this hard restart hurts availability and breaks active API requests. You are a solo developer or part of a small team managing a Dockerized app on an AWS EC2 instance or DigitalOcean Droplet via GitHub Actions, and you want true zero-downtime rolling updates without migrating to a complex Kubernetes cluster. By the end of this guide, you will have a fully automated CI/CD pipeline that updates your containers one by one with zero dropped requests.

The Zero-Downtime Strategy At a Glance

Before writing any configuration files, you need to understand the architectural shift required for rolling updates in Docker Compose. Standard Docker networks use container names for internal routing, but when you scale services, you need a load balancer to handle traffic routing gracefully during a transition.

Here is the exact component stack you will use:

  1. Nginx: Acts as the reverse proxy and load balancer, intercepting incoming HTTP traffic and routing it to your application containers.
  2. Docker Compose scale and --no-deps flags: Allows you to spin up a new container instance alongside the old one on the same internal network.
  3. GitHub Actions: Triggers the deployment sequence over SSH when you push to your main branch.

The deployment sequence follows this exact order:

  1. Pull the new Docker image from your registry.
  2. Start a second instance of your application container on a different internal port or alias.
  3. Wait for the new container's health check to pass.
  4. Reload Nginx to route traffic to the new container.
  5. Stop and remove the old container.

Project Structure and Configuration

To make rolling updates work smoothly, your repository needs a predictable layout. Create a project root with the following file structure:

my-app/
├── .github/
│   └── workflows/
│       └── deploy.yml
├── nginx/
│   └── conf.d/
│       └── default.conf
├── docker-compose.yml
├── Dockerfile
└── package.json

The Nginx Upstream Configuration

Nginx requires an upstream block so it can dynamically manage backend targets. If a backend goes down or a new one spins up, Nginx handles the proxying cleanly.

Create nginx/conf.d/default.conf with this configuration:

upstream app_cluster {
    # Keepalive connections speed up proxying to local containers
    server web-app-1:3000 max_fails=3 fail_timeout=5s;
    server web-app-2:3000 max_fails=3 fail_timeout=5s;
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://app_cluster;
        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;
        
        # Timeout configurations for graceful handling
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

The Production Docker Compose File

Your docker-compose.yml file must define two application slots (web-app-1 and web-app-2) connected to the Nginx proxy. This explicit naming convention allows you to alternate updates between the two slots.

version: '3.8'

networks:
  app-net:
    driver: bridge

services:
  nginx:
    image: nginx:alpine
    container_name: nginx-lb
    restart: always
    ports:
      - "80:80"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
    networks:
      - app-net
    depends_on:
      - web-app-1

  web-app-1:
    image: ghcr.io/your-username/my-app:latest
    container_name: web-app-1
    restart: always
    environment:
      - NODE_ENV=production
      - PORT=3000
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 10s

  web-app-2:
    image: ghcr.io/your-username/my-app:latest
    container_name: web-app-2
    restart: always
    environment:
      - NODE_ENV=production
      - PORT=3000
    networks:
      - app-net
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
      interval: 5s
      timeout: 3s
      retries: 3
      start_period: 10s

Note: In your application code, make sure you expose a /health endpoint that returns a 200 OK status code as soon as your server is ready to accept HTTP requests.

The GitHub Actions Deployment Workflow

You need an automated pipeline that connects to your production server via SSH, pulls the latest images, and cycles the containers without downtime.

Create .github/workflows/deploy.yml:

name: Production Zero-Downtime Deploy

on:
  push:
    branches: [ "main" ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest

      - name: Executing Remote Rolling Update via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /opt/my-app
            
            # Pull the absolute latest image
            docker compose pull
            
            # 1. Update web-app-1 first
            echo "Updating web-app-1..."
            docker compose stop web-app-1
            docker compose rm -f web-app-1
            docker compose up -d --no-deps web-app-1
            
            # Wait for container health check to report healthy
            echo "Waiting for web-app-1 to pass health check..."
            while [ "$(docker inspect -f '{{.State.Health.Status}}' web-app-1)" != "healthy" ]; do
              sleep 2
            done
            
            # 2. Update web-app-2 second
            echo "Updating web-app-2..."
            docker compose stop web-app-2
            docker compose rm -f web-app-2
            docker compose up -d --no-deps web-app-2
            
            # Wait for container health check to report healthy
            echo "Waiting for web-app-2 to pass health check..."
            while [ "$(docker inspect -f '{{.State.Health.Status}}' web-app-1)" != "healthy" ]; do
              sleep 2
            done
            
            # 3. Reload Nginx to ensure optimal upstream keepalive pooling
            docker exec nginx-lb nginx -s reload
            
            # Prune old unused images to save disk space
            docker image prune -f

Understanding the Deployment Script Loop

The inline script inside the GitHub Actions workflow executes three critical safety guarantees:

  • --no-deps: Tells Docker Compose to recreate only the target container without restarting linked dependencies like databases or caches.
  • docker inspect Health Polling: The while loop halts the deployment script until the container's internal health check explicitly transitions from starting to healthy. If a build fails or crashes on startup, the script aborts before touching the second container.
  • Nginx Reload: nginx -s reload refreshes the configuration and clears idle upstream keepalive sockets without dropping active user connections.

Troubleshooting Common Failures

Deploying via scripts over SSH can occasionally fail due to environment variances. Check these common failure points if your deployment stalls.

Health Check Status Stuck at "starting"

If your GitHub Actions pipeline hangs indefinitely during the health check loop, your container is likely failing its internal validation.

  • The fix: Log into your server and run docker inspect --format='{{json .State.Health}}' web-app-1 to view the exact exit codes and failure logs of the health check command. Ensure curl is installed inside your base Docker image if your health check relies on it.

502 Bad Gateway During Deployment

If users experience brief 502 errors when an update triggers, Nginx is attempting to proxy traffic to a container that has stopped responding, but hasn't fully deregistered from the upstream pool.

  • The fix: Verify that your nginx/conf.d/default.conf includes the max_fails=3 fail_timeout=5s directives on your upstream servers. This tells Nginx to immediately drop unresponsive upstream nodes and route traffic to the healthy instance.

Permission Denied on nginx -s reload

If the SSH deployment fails when trying to execute the Nginx reload command, your SSH user lacks container execution privileges.

  • The fix: Ensure your deployment SSH user is added to the local docker group on the host machine by running sudo usermod -aG docker $USER.

Alternatives and Maintenance

While this Docker Compose rolling deployment pattern provides robust zero-downtime updates for single-server architectures, it does require maintaining explicit container slots (web-app-1 and web-app-2).

If your application scales beyond a single VPS, consider migrating this exact deployment pattern to Docker Swarm. Swarm natively supports rolling updates via update_config parameters inside your compose files without requiring custom shell scripts or manual container slot management:

deploy:
  update_config:
    parallelism: 1
    delay: 10s
    order: start-first

For most single-server production setups, however, the explicit two-slot Compose method gives you total transparency, easy debugging, and zero dropped requests without the heavy operational overhead of a clustered orchestrator.