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 Service that 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 }}"
Cutover Order, and the Step Everyone Skips The workflow determines which colour is idle, deploys to it, runs smoke tests against it directly, switches the router, and only then — after a delay — scales down the old colour. Scaling down immediately removes the rollback path at the exact moment it is most likely to be needed. find idle colour deploy to it smoke test directly switch router wait, then scale down old Smoke testing the idle colour by its internal address is what makes the switch safe — testing after the switch is too late. The final wait is the rollback window. Ten to thirty minutes costs little and is the whole reason for the pattern.

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.

In-Flight Requests Must Drain, Not Be Cut At the moment of the switch, new connections are routed to the new colour immediately, while requests already in flight on the old colour are allowed to complete. Without a drain period those requests are terminated mid-response, which users experience as errors during an otherwise successful deploy. switch old colour serving all requests draining in-flight — 30s new colour taking every new connection Without the drain window, every request in flight at the switch instant becomes a user-visible error. Set the drain slightly above your longest expected request, not to an arbitrary round number.

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.readyReplicas

Expected: 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.

Where "Which Colour Is Live" Should Be Stored A workflow variable drifts as soon as anyone switches manually. A label on the deployment is better but can disagree with reality. The router's own configuration is the only source that cannot be wrong, because it is what actually decides where traffic goes. a workflow variable drifts the first time someone switches by hand a label on the service better, but can still disagree with the router the router configuration cannot be wrong — it is what decides where traffic goes Read the live colour from the router at the start of every deploy. Deriving it from anything else eventually deploys onto the live environment.

Common pitfalls

  • Testing through the live router. If the smoke test hits app-router instead of the idle internal service, it validates the old version and the flip ships an untested build. Always target app-<idle>-internal.
  • Retiring the old color in this job. Do not scale the previous color to zero here — the rollback job 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.

← Back to Blue-Green Deployments for Full-Stack Apps