DevOps / CI/CD / GitHub Actions
Designing CI/CD Pipelines for Multi-Environment Deployments
Practical patterns for building GitHub Actions pipelines that handle staging, production, and rollback across AWS and Azure — based on real SaaS deployment workflows.
|8 min read
Introduction
Most CI/CD tutorials show a single pipeline that pushes to one environment. Real production systems don't work that way. You need staging gates, environment-specific secrets, database migrations that run before code deploys, and a rollback plan for when things go wrong.
This post walks through the pipeline patterns I've used to deploy SaaS applications across AWS and Azure, serving clients in multiple countries with zero-downtime requirements.
The Problem with Simple Pipelines
A basic push to main → deploy pipeline works fine until you hit any of these:
- A migration breaks a table that the running code depends on
- Your staging and production environments have different infrastructure (e.g., AWS for one client, Azure for another)
- A hotfix needs to skip the normal QA cycle but still go through smoke tests
- You need to roll back code but keep the database migration
Each of these forces you to rethink the single-pipeline model.
Pipeline Architecture
Here's the structure that has worked well across multiple projects:
1
# .github/workflows/deploy.yml
2
name: Deploy Pipeline
3
4
on:
5
push:
6
branches: [main, staging]
7
workflow_dispatch:
8
inputs:
9
environment:
10
type: choice
11
options: [staging, production]
12
13
jobs:
14
test:
15
runs-on: ubuntu-latest
16
steps:
17
- uses: actions/checkout@v4
18
- run: npm ci
19
- run: npm test
20
- run: npm run lint
21
22
build:
23
needs: test
24
runs-on: ubuntu-latest
25
steps:
26
- uses: actions/checkout@v4
27
- run: npm ci
28
- run: npm run build
29
- uses: actions/upload-artifact@v4
30
with:
31
name: build-output
32
path: dist/
33
34
deploy-staging:
35
needs: build
36
if: github.ref == 'refs/heads/staging'
37
environment: staging
38
runs-on: ubuntu-latest
39
steps:
40
- uses: actions/download-artifact@v4
41
- run: ./scripts/deploy.sh staging
42
43
deploy-production:
44
needs: build
45
if: github.ref == 'refs/heads/main'
46
environment: production
47
runs-on: ubuntu-latest
48
steps:
49
- uses: actions/download-artifact@v4
50
- run: ./scripts/migrate.sh production
51
- run: ./scripts/deploy.sh production
52
- run: ./scripts/smoke-test.sh production
The key insight is separating build from deploy. The same build artifact goes to staging and production — you never rebuild for production.
Handling Database Migrations Safely
The most dangerous part of any deployment is the database migration. Here's the pattern that avoids downtime:
-
Forward-only migrations: Never write a migration that drops a column the running code uses. Instead, deploy in two phases — first the code that stops using the column, then the migration that removes it.
-
Migration as a separate step: Run migrations before the code deploy. If the migration fails, the old code is still running and unaffected.
-
Migration locking: Use advisory locks to prevent two pipelines from running migrations simultaneously.
#!/bin/bash
# scripts/migrate.sh
set -euo pipefail
ENVIRONMENT=$1
echo "Running migrations for $ENVIRONMENT"
# Acquire advisory lock
npm run db:migrate -- --env "$ENVIRONMENT" --lock
# Verify migration state
npm run db:verify -- --env "$ENVIRONMENT"
Environment-Specific Secrets
GitHub Actions environments let you scope secrets per deployment target:
stagingenvironment getsAWS_ACCESS_KEY_IDfor the staging accountproductionenvironment gets different credentials and requires manual approval- Azure deployments use
AZURE_CREDENTIALSscoped to the specific subscription
The environment key in your workflow job handles this automatically — you don't need conditional logic to swap secrets.
Rollback Strategy
Rollbacks should be boring. Here's what works:
- Code rollback: Revert the merge commit on main. The pipeline redeploys the previous version automatically.
- Migration rollback: Keep a
downmigration for everyup, but test it in staging first. In practice, I've found it safer to write a new forward migration that undoes the change. - Emergency rollback: A
workflow_dispatchtrigger that deploys a specific tagged release without running the full test suite.
Smoke Tests as a Gate
After every production deploy, run a minimal smoke test that verifies:
- The health endpoint returns 200
- Authentication works (login with a test account)
- One critical API endpoint returns expected data
If the smoke test fails, the pipeline triggers an automatic rollback. This has saved us from several incidents where code passed all tests but failed against real infrastructure.
Conclusion
The pipeline patterns here aren't complex individually. The value comes from combining them into a workflow where deployments are predictable, migrations are safe, and rollbacks are one click away. Start with the basics — separate build from deploy, run migrations first, add smoke tests — and layer on complexity only when you need it.
Written by Erik Yuntantyo·Software Engineer·About me