Zero-Downtime Deployment Strategies
Zero-downtime deployment means releasing a new version of your application so that no in-flight request is dropped, no user sees an error page, and no one notices a release happened at all. It is the difference between a deploy that quietly runs in the background and one where a two-minute restart window turns into a stream of support tickets. Getting there is not a single setting you flip on. It comes from how you route traffic, how you sequence container startup and shutdown, and how your GitHub Actions workflow gates every step behind a real health check before it commits to the next one.
Overview / How it works
There are three deployment strategies you will meet in production pipelines, and each solves the zero-downtime problem differently. A rolling update replaces running instances a few at a time behind a load balancer or orchestrator, so some capacity is always serving traffic while the rest updates. A blue-green deployment keeps two full environments: one live (“blue”) and one idle (“green”). The new version deploys entirely to the idle environment, gets verified there, and only then does a single switch (a load balancer target, a reverse proxy config, or a DNS-like pointer) send all traffic to it at once. The old environment stays warm as an instant rollback target. A canary deployment sends a small percentage of real traffic to the new version first, watches error rate and latency, and only increases that percentage after the metrics look healthy, aborting and rolling back the moment they do not.
All three strategies depend on the same underlying signal: a health check that tells your workflow the new version is actually ready to serve, not just that the container process started. A container can start in milliseconds and still take several seconds to warm a cache, open a database pool, or finish loading configuration. Treating “container running” as “ready for traffic” is one of the most common causes of a deploy that looks successful in the Actions log but breaks users in production.
Two more constraints shape a safe pipeline. First, connection draining: when you take an old slot out of rotation, in-flight requests must be allowed to finish instead of being killed mid-response, so the switch needs a timeout, not an instant kill. Second, database migrations: during a rolling or canary release, old and new application code run against the same database at the same time, so migrations must be backward compatible. Add columns before you use them, and only drop old columns in a later, separate migration once the old code path is fully retired.
Syntax or workflow structure
GitHub Actions gives you a few building blocks for this. An environment: key on a job (with an optional url) lets you attach protection rules in repository settings, such as required reviewers or a wait timer, so a promotion to production needs a human approval or a cooling-off period. A concurrency: group on the deploy job prevents two releases from racing to update the same idle slot at once. A workflow_dispatch trigger with typed inputs gives you a manual rollback entry point that takes an exact image reference rather than relying on whatever the branch happens to build next. The permissions: block should explicitly grant deployments: write so the workflow can post deployment status back to GitHub’s Deployments view, since the default token scope is not guaranteed to include it.
One distinction matters more here than almost anywhere else in CI/CD: a tag versus a digest. A tag such as :latest or :main is a mutable pointer that can be overwritten by the next build, including a broken one. A digest, written as sha256:..., is a content hash that always resolves to the exact same immutable bytes. Deploy and rollback steps should reference the digest produced by your build job, not a tag, because a rollback is only trustworthy if it is guaranteed to redeploy precisely what worked before.
Examples
Example 1: Blue-green deployment with a health-checked cutover
This workflow builds an image, deploys it to the idle slot, waits for a passing health check, and only then switches the load balancer. The old slot keeps running until connections drain.
name: Blue-Green Deploy
on:
push:
branches: [main]
permissions:
contents: read
deployments: write
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./ci/run-tests.sh
build:
needs: test
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Deploy to idle slot
run: ./deploy/deploy-slot.sh --slot idle --image ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
- name: Health check idle slot
run: ./deploy/health-check.sh --slot idle --retries 10 --interval 5
- name: Switch load balancer to idle slot
run: ./deploy/switch-traffic.sh --to idle
- name: Drain old slot connections
run: ./deploy/drain-slot.sh --slot active --timeout 30
Expected behavior: the test and build jobs run first, and the deploy job only starts if both succeed. The new image goes live on the idle slot while the active slot keeps serving all real traffic, so a slow health check or a crash on startup never reaches a single end user. Only after health-check.sh exits successfully does traffic move, and the old slot is given 30 seconds to finish in-flight requests before it is considered fully retired.
Example 2: Canary rollout with staged traffic and metric gates
Building on the same image and digest from example 1, this workflow shifts traffic gradually instead of all at once, so a bad release affects a fraction of users instead of everyone.
name: Canary Deploy
on:
workflow_dispatch:
inputs:
image_digest:
description: "Image digest to release (sha256:...)"
required: true
permissions:
contents: read
deployments: write
jobs:
canary-10:
runs-on: ubuntu-latest
environment: production-canary
steps:
- name: Shift 10% traffic to canary
run: ./deploy/set-traffic-weight.sh --version ${{ github.event.inputs.image_digest }} --weight 10
- name: Watch error rate for 5 minutes
run: ./deploy/watch-metrics.sh --window 5m --max-error-rate 1
canary-50:
needs: canary-10
runs-on: ubuntu-latest
environment: production-canary
steps:
- name: Shift 50% traffic to canary
run: ./deploy/set-traffic-weight.sh --version ${{ github.event.inputs.image_digest }} --weight 50
- name: Watch error rate for 5 minutes
run: ./deploy/watch-metrics.sh --window 5m --max-error-rate 1
promote:
needs: canary-50
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Shift 100% traffic to new version
run: ./deploy/set-traffic-weight.sh --version ${{ github.event.inputs.image_digest }} --weight 100
Expected behavior: 10% of traffic moves to the new version first, and the job blocks for five minutes while watch-metrics.sh polls your monitoring system. If the error rate exceeds the threshold, that step fails, the job fails, and the canary-50 and promote jobs never run because of the needs: chain, so the blast radius is capped at whatever percentage was live when it failed. Because production-canary and production are protected environments, you can also require a reviewer to click approve between stages instead of relying purely on automated metrics.
Example 3: Digest-based rollback
When a release does need to be reversed, the safest path is to reuse the exact blue-green mechanics from example 1, pointed at the last known-good digest rather than building anything new.
name: Rollback Production
on:
workflow_dispatch:
inputs:
rollback_digest:
description: "Previous known-good image digest (sha256:...)"
required: true
permissions:
contents: read
deployments: write
jobs:
rollback:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- name: Deploy previous image to idle slot
run: ./deploy/deploy-slot.sh --slot idle --image ghcr.io/${{ github.repository }}@${{ github.event.inputs.rollback_digest }}
- name: Health check idle slot
run: ./deploy/health-check.sh --slot idle --retries 10 --interval 5
- name: Switch load balancer back to previous version
run: ./deploy/switch-traffic.sh --to idle
Expected behavior: an operator triggers this manually from the Actions tab and supplies the digest recorded from the last successful production deploy (visible in that run’s logs or in the GitHub Deployments history). The previous image is deployed to the idle slot, health-checked exactly like a forward deploy, and only then does traffic switch back. Because rollback follows the identical path as a normal release, it is exercised and tested every time you deploy, instead of being a rarely-used code path that might itself be broken when you need it most.
Step by step
- CI runs the test suite; the deploy job only starts on green.
- Build the image once, push it to the registry, and capture its digest as a job output.
- Deploy that exact digest to the idle slot only; the active slot is untouched and keeps serving 100% of traffic.
- Poll a dedicated health endpoint on the idle slot with retries and a bounded timeout, failing the workflow if it never turns healthy.
- Switch the load balancer or router so new connections go to the slot that just passed its health check.
- Drain the now-old slot: stop sending it new connections but let requests already in flight finish within a timeout.
- Keep the old slot warm for a defined soak period so a fast rollback is just another traffic switch, not a redeploy.
- Record the deployed digest somewhere durable (deployment status, release notes, or an artifact) so a future rollback has an exact target.
Common Mistakes
Mistake 1: switching traffic before the health check passes
It is tempting to deploy and flip traffic in the same step, especially once the deploy script itself returns successfully. But a script returning exit code 0 only means the container was scheduled or started, not that the application inside finished booting, connected to its database, or is actually able to serve a request.
- name: Deploy and switch immediately
run: |
./deploy/deploy-slot.sh --slot idle --image ghcr.io/org/app:latest
./deploy/switch-traffic.sh --to idle
If the new container crashes on startup or is still warming up, every user gets routed straight to a dead or unready backend the instant traffic switches. The fix is to make the health check its own step and treat traffic switching as strictly dependent on it succeeding:
- name: Deploy to idle slot
run: ./deploy/deploy-slot.sh --slot idle --image ghcr.io/org/app@sha256:8f3e9c2b1a...
- name: Health check idle slot
run: ./deploy/health-check.sh --slot idle --retries 10 --interval 5
- name: Switch traffic only after health check passes
run: ./deploy/switch-traffic.sh --to idle
Now a failed health check fails the workflow before the switch step ever runs, so the active slot keeps serving traffic unchanged and no user notices anything happened.
Mistake 2: rolling back to a floating tag instead of a digest
After a bad release, it is common to reach for whatever tag used to represent “the good version,” such as :latest or a branch tag. The problem is that these tags are mutable pointers: if the very build you are trying to escape already overwrote that tag, “rolling back to latest” redeploys the broken version you started with.
- name: Rollback to previous version
run: ./deploy/deploy-slot.sh --slot idle --image ghcr.io/org/app:latest
The fix is to always roll back to the immutable digest recorded from the last successful deployment, never a tag that could have moved since then:
- name: Rollback to a known-good digest
run: ./deploy/deploy-slot.sh --slot idle --image ghcr.io/org/app@sha256:2c9a7e10d4...
Storing that digest as part of your deployment record (or as a workflow artifact) at release time is what makes this rollback path reliable months later.
Best Practices
- Always gate a traffic switch behind an explicit readiness check, never behind “the deploy command exited successfully.”
- Keep the previous slot or version warm for a defined soak period after cutover so rollback is a traffic switch, not a rebuild.
- Deploy and roll back using image digests, not mutable tags, so you always know exactly what bytes are running.
- Split database migrations into additive (“expand”) changes that ship before the code that needs them, and destructive (“contract”) changes that ship only after the old code path is fully retired.
- Protect the production environment with required reviewers and, for canary promotions, a minimum wait timer between stages.
- Grant only
contents: readanddeployments: write(or whatever the deploy step truly needs) instead of leaving default broad permissions in place. - Set a
concurrencygroup on deploy workflows so two releases can never race for the same idle slot. - Automate the canary abort decision from real metrics (error rate, latency) instead of relying on someone watching a dashboard in real time.
Practice Exercises
- Take the blue-green workflow in Example 1 and add a manual approval gate before the
Switch load balancerstep by moving that step into its own job with a protectedenvironment:. - Modify the canary workflow in Example 2 to add a fourth stage at 25% traffic between the existing 10% and 50% stages, keeping the
needs:chain intact so a failed metrics check still blocks promotion. - Rewrite
health-check.shfrom Example 1 so that it also checks a second, database-connectivity endpoint before reporting success, and explain why both checks matter. - Identify a mutable tag reference anywhere in this lesson’s rollback workflow and replace it with a digest sourced from a prior job’s output, mirroring how the blue-green workflow passes its digest between jobs.
Summary
Zero-downtime deployment is the deployment stage’s job, sitting downstream of a passing CI run: rolling updates, blue-green, and canary releases are three different ways to move traffic to new code without a gap in service, and all three depend on real readiness checks, connection draining, and backward-compatible database migrations rather than on the deploy command simply exiting successfully. In GitHub Actions, protected environments, a concurrency group, minimal explicit permissions, and digest-based image references turn those ideas into a workflow that is safe to run automatically and just as safe to reverse when a release goes wrong.
