🚀 Zero-Downtime Rolling Deployments with GitHub Actions, Docker & EC2

Vinay SinghVinay Singh
5 min read

Deploying microservices can get tricky when you want zero downtime and smooth rollouts. In this post, we’ll walk through how to build a CI/CD pipeline using GitHub Actions, Docker, and EC2 to achieve rolling deployments for microservices.


🔑 Key Goals

  • Automated builds for each microservice (api, notification, mail)
  • Push images to GitHub Container Registry (GHCR)
  • Rolling deployments on an EC2 server with health checks
  • Zero-downtime updates using Docker container swapping
  • Caddy reverse proxy for routing traffic

✅ First Check (Before Running the Workflow)

Before you run this GitHub Actions pipeline for the first time, make sure you’ve covered these important checks:

  1. Environment Variables

    • Ensure values inside your .env files are not wrapped in quotes (" ") unless explicitly required.
      Example:
      PORT=3000
      NODE_ENV=production
      
  2. Healthcheck in Dockerfiles

    • Each service should define a HEALTHCHECK so the rolling update script can detect if a new container is healthy.
      Example for a Node.js service:
      HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 CMD curl -f <http://localhost:3000/health> || exit 1
      
  3. If Using Node Alpine Image

    • Install curl for health checks to work:
      RUN apk add --no-cache curl
      
  4. Initial Server Setup

    • On your EC2 server, create a root project folder (e.g., ~/microservices) and inside it:
      • One folder for each service (api, notification, mail) containing its .env file.
      • A docker-compose.yml in the root folder (if you are using Caddy for reverse proxy).

⚙️ GitHub Actions Workflow

We defined our pipeline in .github/workflows/rolling-deploy.yml.

1. Trigger

The workflow runs on every push to main:

on:
  push:
    branches:
      - main
`

2. Build & Push Docker Images

Each service (api, notification, mail) is built and pushed to GHCR with a version tag based on the short commit SHA.

- name: Build and Push API
  uses: docker/build-push-action@v4
  with:
    context: ./api
    push: true
    tags: ghcr.io/${{ github.repository }}/api:${{ steps.vars.outputs.sha }}

This step is repeated for notification and mail services.


3. Deploy to EC2

Once the images are built and pushed, we connect to the EC2 instance via SSH using appleboy/ssh-action.

- name: Deploy on EC2
  uses: appleboy/ssh-action@v0.1.6
  with:
    host: ${{ secrets.EC2_HOST }}
    username: ${{ secrets.EC2_USER }}
    key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}

🔄 Rolling Update Script

The heart of the deployment is the rolling_update function.

rolling_update() {
  SERVICE=$1
  IMAGE=$2

  echo "Updating $SERVICE..."

  # Keep old container temporarily
  if [ "$(docker ps -a -q -f name=$SERVICE)" ]; then
    docker rename $SERVICE $SERVICE-old
  fi

  # Start new container with latest image
  docker run -d --name $SERVICE \\
    --env-file ./$SERVICE/.env \\
    --network microservices-net \\
    $IMAGE

  # Wait for health check (max 30 retries)
  for i in {1..30}; do
    STATUS=$(docker inspect --format='{{.State.Health.Status}}' $SERVICE 2>/dev/null || echo "starting")
    if [ "$STATUS" == "healthy" ]; then
      echo "$SERVICE is healthy!"
      docker rm -f $SERVICE-old || true
      return
    fi
    echo "Waiting for $SERVICE health check..."
    sleep 5
  done

  # Rollback if unhealthy
  echo "$SERVICE failed health check. Rolling back..."
  docker rm -f $SERVICE || true
  if [ "$(docker ps -a -q -f name=$SERVICE-old)" ]; then
    docker rename $SERVICE-old $SERVICE
  fi
}

What it does:

  1. Renames the current running container (service → service-old).

  2. Starts a new container with the updated image.

  3. Checks health status (healthy via Docker healthcheck).

  4. If healthy → remove old container.

  5. If unhealthy → rollback to the old container.


🌐 Networking & Proxy

  • All services share a Docker network:

      docker network create microservices-net || true
    
  • Caddy acts as the reverse proxy and is reconnected after each deployment:

      docker-compose up -d caddy
      docker network connect microservices-net caddy || true
    

🧹 Cleanup

To avoid bloating the server with old images:

docker image prune -f

rolling-deploy.yml


name: Rolling Deploy Microservices

on:
  push:
    branches:
      - main

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    outputs:
      SHORT_SHA: ${{ steps.vars.outputs.sha }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set short SHA
        id: vars
        run: echo "sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT

      - name: Login to GHCR
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GA_TOKEN }}

      - name: Build and Push API
        uses: docker/build-push-action@v4
        with:
          context: ./api
          push: true
          tags: ghcr.io/${{ github.repository }}/api:${{ steps.vars.outputs.sha }}

      - name: Build and Push Notification
        uses: docker/build-push-action@v4
        with:
          context: ./notification
          push: true
          tags: ghcr.io/${{ github.repository }}/notification:${{ steps.vars.outputs.sha }}

      - name: Build and Push Mail
        uses: docker/build-push-action@v4
        with:
          context: ./mail
          push: true
          tags: ghcr.io/${{ github.repository }}/mail:${{ steps.vars.outputs.sha }}

  deploy:
    runs-on: ubuntu-latest
    needs: build-and-push
    steps:
      - name: Deploy on EC2
        uses: appleboy/ssh-action@v0.1.6
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ${{ secrets.EC2_USER }}
          key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
          script: |
            cd ~/microservices
            VERSION=${{ needs.build-and-push.outputs.SHORT_SHA }}
            echo "Deploying VERSION=$VERSION"

            # Create shared network if not exists
            docker network create microservices-net || true

            rolling_update() {
              SERVICE=$1
              IMAGE=$2

              echo "Updating $SERVICE..."

              if [ "$(docker ps -a -q -f name=$SERVICE)" ]; then
                docker rename $SERVICE $SERVICE-old
              fi

              docker run -d --name $SERVICE --env-file ./$SERVICE/.env --network microservices-net $IMAGE

              for i in {1..30}; do
                STATUS=$(docker inspect --format='{{.State.Health.Status}}' $SERVICE 2>/dev/null || echo "starting")
                if [ "$STATUS" == "healthy" ]; then
                  echo "$SERVICE is healthy!"
                  docker rm -f $SERVICE-old || true
                  return
                fi
                echo "Waiting for $SERVICE health check..."
                sleep 5
              done

              echo "$SERVICE failed health check. Rolling back..."
              docker rm -f $SERVICE || true
              if [ "$(docker ps -a -q -f name=$SERVICE-old)" ]; then
                docker rename $SERVICE-old $SERVICE
              fi
            }

            docker pull ghcr.io/${{ github.repository }}/api:$VERSION
            docker pull ghcr.io/${{ github.repository }}/notification:$VERSION
            docker pull ghcr.io/${{ github.repository }}/mail:$VERSION

            rolling_update api ghcr.io/${{ github.repository }}/api:$VERSION
            rolling_update notification ghcr.io/${{ github.repository }}/notification:$VERSION
            rolling_update mail ghcr.io/${{ github.repository }}/mail:$VERSION

            docker-compose up -d caddy
            docker network connect microservices-net caddy || true
            docker image prune -f

Caddyfile

example.com {
    reverse_proxy api:port
    encode gzip
}

docker-compose.yml

services:
  caddy:
    image: caddy:latest
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    restart: always
    networks:
      - microservices-net

volumes:
  caddy_data:
  caddy_config:

networks:
  microservices-net:
    external: true

✅ Final Workflow Benefits

  • Zero downtime deployments

  • Automatic rollback on failure

  • Simple scaling by adding more microservices

  • Seamless integration with GitHub & Docker


🚀 Conclusion

With this setup, every push to main will:

  1. Build & push new Docker images to GHCR.

  2. Deploy them to your EC2 instance.

  3. Perform rolling updates with health checks.

  4. Keep your services running without downtime.

This approach is lightweight, reliable, and avoids complex orchestrators like Kubernetes—perfect for small to medium-scale microservice projects.


💡 Next Steps:

  • Add monitoring/logging (e.g., Prometheus + Grafana)

  • Automate scaling using Docker Swarm or Kubernetes (for larger systems)

  • Improve secrets management with AWS SSM or Vault


🔥 Happy Deploying!

0
Subscribe to my newsletter

Read articles from Vinay Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vinay Singh
Vinay Singh