Building a Zero-Downtime CI/CD Pipeline with GitHub Actions
The pipelines we inherit from clients almost always "work" in the sense that code reaches production. What they rarely do is fail safely. A flaky deploy that occasionally drops connections for thirty seconds gets normalized as "just how deploys are" until a team decides to fix it properly — and it usually takes less effort than expected.
The deployment strategy comes first, the pipeline second
Before touching a YAML file, decide on a deployment strategy, because the pipeline's job is just to execute it reliably:
- Blue-green deployment runs two full environments; traffic cuts over from the old to the new only after the new one passes health checks. Simple to reason about, requires double the infrastructure during deploys.
- Rolling deployment replaces instances gradually behind a load balancer, never removing capacity below a safe threshold. Cheaper on infrastructure, more forgiving on stateful edge cases, slightly more complex to roll back cleanly.
- Canary deployment routes a small percentage of traffic to the new version first, expanding gradually as metrics stay healthy. Best safety profile, most operational complexity — usually reserved for high-traffic services where a bad deploy is expensive.
For most mid-size applications behind a load balancer, we recommend starting with rolling deployment with health-check gating — it delivers most of the safety of canary at a fraction of the setup cost.
The pipeline structure
A production-grade GitHub Actions pipeline for this pattern has four gated stages, each of which must pass before the next begins:
name: deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint && npm run typecheck
- run: npm test -- --coverage
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t $REGISTRY/app:${{ github.sha }} .
- run: docker push $REGISTRY/app:${{ github.sha }}
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Rolling deploy with health gate
run: ./scripts/rolling-deploy.sh ${{ github.sha }}
- name: Verify health checks
run: ./scripts/wait-for-healthy.sh
- name: Rollback on failure
if: failure()
run: ./scripts/rollback.sh
The unglamorous but critical piece is wait-for-healthy.sh — a script that polls the new instances' health endpoint and application-level readiness (not just "process is running," but "process can serve a real request against the database") before the pipeline declares success. Without this, "deployed" and "actually working" are treated as the same event, and they frequently aren't.
The three failure modes that cause downtime anyway
Even with a good pipeline, three issues account for most "zero-downtime deploy that wasn't" incidents we've debugged:
- Database migrations that aren't backward-compatible. If the new code expects a column the migration just added, but the migration runs after old instances are still serving traffic, you get errors during the overlap window. The fix is expand-contract migrations: add new columns/tables in a release that's compatible with both old and new code, deploy the code, then remove old columns in a later release.
- In-flight connection draining. Terminating an instance immediately on deploy drops requests mid-flight. Load balancers need a configured deregistration delay, and application servers need to handle SIGTERM by finishing in-flight requests before shutting down — a few lines of code that get skipped constantly.
- Cache and session state tied to specific instances. Sticky sessions or in-memory caches that aren't shared across instances turn a routine deploy into a bad experience for whoever was pinned to the terminated instance. Externalizing session state to Redis (or equivalent) removes this class of bug entirely.
Rollback needs to be as automated as deployment
A pipeline that can deploy but requires a human to manually diagnose and roll back a bad release will, on a long enough timeline, have an incident that runs far longer than it should. Automated rollback — triggered by failed health checks or an elevated error-rate threshold from your monitoring system in the minutes after deploy — turns "someone notices the dashboard is red and scrambles" into a five-minute, unattended recovery.
Where the real ROI is
Teams that make this investment don't just get to skip maintenance windows — they change their relationship with shipping. When a deploy is boring and safe, teams ship smaller changes more often, which is itself a reliability win: smaller diffs are easier to review, easier to reason about, and easier to roll back cleanly when something does go wrong. The pipeline isn't the goal. The confidence to deploy on a Tuesday afternoon without anyone holding their breath is.