Back to Blog

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:

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

  2. Migration as a separate step: Run migrations before the code deploy. If the migration fails, the old code is still running and unaffected.

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

  • staging environment gets AWS_ACCESS_KEY_ID for the staging account
  • production environment gets different credentials and requires manual approval
  • Azure deployments use AZURE_CREDENTIALS scoped 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 down migration for every up, 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_dispatch trigger 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