Zero-Downtime Blue-Green Deploys with GitHub Actions
You want every push to main to reach production with no dropped requests and an instant escape hatch if the new version misbehaves — this workflow does exactly that using blue-green deployments on Kubernetes.
When to use this pattern
- You run a containerized service on Kubernetes with a
Servicethat routes by a mutable label. - You can briefly run two full-size environments and want cutover measured in seconds with instant rollback.
- Your database migrations are backward compatible (expand-then-contract), so both colors can share one database.
Prerequisites
Complete working example
# .github/workflows/blue-green.yml
name: Blue-Green Deploy
on:
push:
branches: [main]
concurrency:
group: blue-green-prod # serialize deploys — never flip two at once
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
idle: ${{ steps.color.outputs.idle }}
live: ${{ steps.color.outputs.live }}
steps:
- uses: actions/checkout@v4
- name: Configure kubectl
run: |
mkdir -p "$HOME/.kube"
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config"
- name: Build and push image
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
- name: Detect idle color
id: color
run: |
LIVE=$(kubectl get service app-router -o jsonpath='{.spec.selector.color}')
IDLE=$([ "$LIVE" = "blue" ] && echo green || echo blue)
echo "live=$LIVE" >> "$GITHUB_OUTPUT"
echo "idle=$IDLE" >> "$GITHUB_OUTPUT"
echo "Deploying to idle color: $IDLE (live is $LIVE)"
- name: Deploy to idle color
run: |
kubectl set image deploy/app-${{ steps.color.outputs.idle }} \
app=ghcr.io/${{ github.repository }}:${{ github.sha }}
# Block until every replica of the idle color is Ready
kubectl rollout status deploy/app-${{ steps.color.outputs.idle }} --timeout=180s
- name: Smoke test idle color (bypass live router)
run: |
# Port-forward the idle color's internal service and test the NEW version directly
kubectl port-forward svc/app-${{ steps.color.outputs.idle }}-internal 8080:80 &
PF_PID=$!; sleep 3
trap 'kill $PF_PID' EXIT
curl -sf http://localhost:8080/ready | jq -e '.status == "ok"'
curl -sf http://localhost:8080/api/version | jq -e --arg s "${{ github.sha }}" '.sha == $s'
- name: Flip router (atomic cutover)
run: |
kubectl patch service app-router -p \
'{"spec":{"selector":{"color":"${{ steps.color.outputs.idle }}"}}}'
echo "Live traffic now on ${{ steps.color.outputs.idle }}"
rollback:
needs: deploy
if: failure() # only runs if the deploy job failed
runs-on: ubuntu-latest
steps:
- name: Configure kubectl
run: |
mkdir -p "$HOME/.kube"
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config"
- name: Flip back to previous color
run: |
kubectl patch service app-router -p \
'{"spec":{"selector":{"color":"${{ needs.deploy.outputs.live }}"}}}'
echo "Rolled back to ${{ needs.deploy.outputs.live }}"Step-by-step walkthrough
concurrency block. Serializes deploys with cancel-in-progress: false. Two blue-green flips racing would corrupt the “which color is live” invariant, so deploys queue instead of overlapping.
Build and push. Tags the image by github.sha — immutable and traceable. The registry build cache keeps rebuilds fast without weakening reproducibility.
Detect idle color. Reads the router’s current selector.color and computes its opposite. The values are exported as job outputs so the rollback job knows which color to return to.
Deploy to idle color. kubectl set image updates only the idle deployment; no user traffic touches it because the router still points at the live color. rollout status --timeout=180s is the gate — it fails the job if the new version never becomes ready, before any flip happens.
Smoke test. Port-forwards the idle color’s internal service and checks /ready and /api/version. Testing the version endpoint against github.sha proves you are validating the new build, not accidentally the old one behind the live router.
Flip router. A single kubectl patch moves the selector. Kubernetes reprograms endpoints atomically, so traffic shifts with no half-state.
Rollback job. Guarded by if: failure(), it runs only when the deploy job fails. Because the previous color is still warm, it patches the selector back in seconds — no rebuild required.
Verification
# Through the live router, confirm the new SHA is serving
curl -sf https://app.example.com/api/version | jq -r '.sha'
# → should print the commit SHA you just pushed
# Confirm the previous color is still running (warm standby for rollback)
kubectl get deploy app-blue app-green -o \
custom-columns=NAME:.metadata.name,READY:.status.readyReplicasExpected: the live router returns the new SHA, and both deployments still show ready replicas until the retirement job scales the old one down after the analysis window.
Common pitfalls
- Testing through the live router. If the smoke test hits
app-routerinstead of the idle internal service, it validates the old version and the flip ships an untested build. Always targetapp-<idle>-internal. - Retiring the old color in this job. Do not scale the previous color to zero here — the
rollbackjob needs it warm. Retire it in a separate, delayed job after the analysis window closes cleanly. - Destructive migration in the same deploy. A dropped column breaks the still-live old color mid-cutover. Keep migrations backward compatible, as covered in the blue-green guide.
Related
- Blue-Green Deployments for Full-Stack Apps — the parent guide with the full topology and database-compatibility rules.
- Deployment Strategies & Progressive Delivery — how blue-green compares to canary and rolling.
- Atomic Symlink Deploys for Static Frontends — the same cutover idea for static sites.
- Triggering Automatic Rollback on Failed Health Checks — extend this workflow with metric-driven rollback.