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


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:
Environment Variables
- Ensure values inside your
.env
files are not wrapped in quotes (" "
) unless explicitly required.
Example:PORT=3000 NODE_ENV=production
- Ensure values inside your
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
- Each service should define a HEALTHCHECK so the rolling update script can detect if a new container is healthy.
If Using Node Alpine Image
- Install
curl
for health checks to work:RUN apk add --no-cache curl
- Install
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).
- One folder for each service (
- On your EC2 server, create a root project folder (e.g.,
⚙️ 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:
Renames the current running container (
service → service-old
).Starts a new container with the updated image.
Checks health status (
healthy
via Docker healthcheck).If healthy → remove old container.
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:
Build & push new Docker images to GHCR.
Deploy them to your EC2 instance.
Perform rolling updates with health checks.
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!
Subscribe to my newsletter
Read articles from Vinay Singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
