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.
The two details that decide whether it is really zero-downtime
A blue-green workflow that looks correct can still drop requests, and the causes are almost always one of two things.
Connection draining. At the instant the router switches, requests are already in flight against the old colour. If that colour is scaled down immediately, those requests are terminated mid-response and the users involved see errors during what the dashboard records as a successful deploy. The fix is a drain period slightly longer than the longest request the service handles — not a round number, but a number derived from the actual latency distribution’s tail. Thirty seconds covers most web workloads; a service with long-poll or streaming endpoints needs considerably more.
Readiness before the switch. The idle colour must be genuinely ready, not merely started. A container that has begun listening but has not warmed its connection pool, loaded its configuration, or completed its first compile will accept the switched traffic and serve it badly for the first several seconds. Smoke testing the idle colour by its internal address — before the switch, using the same code path real requests take — is what turns “it started” into “it works”.
Determining the live colour reliably
The workflow needs to know which colour is currently serving, and where that fact is stored determines how often the deploy goes wrong. A variable in the workflow or a value in a repository setting drifts the first time anyone switches manually during an incident. A label on the deployment is better, but can still disagree with the router if a change was made at the router level.
The only source that cannot be wrong is the router’s own configuration, because it is what actually decides where traffic goes. Reading it at the start of every deploy costs one API call and removes the failure mode where a deploy targets the environment currently serving production traffic — which is the worst outcome this pattern can produce, and one that happens to most teams exactly once.
Keeping the old colour available
The final step, scaling down the old colour, should be delayed rather than immediate. Its whole value is that it remains a warm rollback target, and a rollback is most likely in the first fifteen minutes. Scaling it down as part of the same job converts an instant router flip back into a redeploy, at exactly the moment speed matters most. A separate delayed job, or simply leaving it running until the next deploy needs the capacity, preserves the property the pattern exists for.
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.