Courses / CI/CD & GitHub Actions
Lesson 15 of 16
CI/CD Environment Promotion: Dev to Staging to Production
Implement robust environment promotion patterns with approval gates, environment protection rules, and automated validation at every stage.
What you'll learn
- ✓How to structure dev, staging, and production environments in CI/CD
- ✓Implementing approval gates and manual promotion steps
- ✓Using GitHub environment protection rules for production safety
- ✓Automating validation checks between environment promotions
- ✓Patterns for database migrations and feature flags across environments
Prerequisites
- •Basic CI/CD pipeline experience
- •Understanding of deployment workflows
Shipping code directly from a developer’s branch to production is a recipe for outages. Environment promotion creates a structured path where every change passes through increasingly production-like environments, with validation and approval at each stage. This pattern catches configuration issues, integration bugs, and performance problems before they affect real users.
The Promotion Model
A typical promotion pipeline moves artifacts through three or more environments:
- Development (dev) - Automatic deployment on every push. Used for rapid iteration and integration testing.
- Staging - Deployed after dev validation passes. Mirrors production configuration. Used for QA, performance testing, and stakeholder review.
- Production - Deployed after staging approval. Serves real users. Protected by approval gates and monitoring.
The critical principle: you promote the same artifact through every environment. You do not rebuild the application for each environment. Build once, deploy the same binary everywhere, and use environment-specific configuration to change behavior.
GitHub Actions Environment Protection
GitHub environments provide built-in protection rules that enforce promotion policies:
Setting Up Environments
Configure environments in your repository settings (Settings > Environments):
- development: No protection rules, auto-deploys on push.
- staging: Required reviewers (optional), wait timer (optional).
- production: Required reviewers (mandatory), deployment branch restriction, wait timer.
Environment-Scoped Secrets
Each environment can have its own secrets. Production database credentials are only available to jobs targeting the production environment:
jobs:
deploy-prod:
environment: production
runs-on: ubuntu-latest
steps:
- run: echo "DB_HOST is ${{ secrets.DB_HOST }}"
# This DB_HOST comes from the production environment secrets,
# not the repository-level secrets
Full Promotion Pipeline
Here is a complete pipeline that builds once and promotes through three environments:
name: Build and Promote
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ──────────────────────────────────────────────
# Stage 1: Build and test (runs on every push)
# ──────────────────────────────────────────────
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
image-digest: ${{ steps.build.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: type=sha,prefix=
- id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm test
# ──────────────────────────────────────────────
# Stage 2: Deploy to development (automatic)
# ──────────────────────────────────────────────
deploy-dev:
needs: [build, unit-tests]
runs-on: ubuntu-latest
environment:
name: development
url: https://dev.myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to dev cluster
run: |
kubectl set image deployment/myapp \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.image-digest }} \
--namespace=dev
kubectl rollout status deployment/myapp --namespace=dev --timeout=300s
- name: Run smoke tests
run: |
curl --fail --retry 10 --retry-delay 5 https://dev.myapp.example.com/health
npm run test:smoke -- --base-url=https://dev.myapp.example.com
# ──────────────────────────────────────────────
# Stage 3: Deploy to staging (automatic after dev)
# ──────────────────────────────────────────────
deploy-staging:
needs: [build, deploy-dev]
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to staging cluster
run: |
kubectl set image deployment/myapp \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.image-digest }} \
--namespace=staging
kubectl rollout status deployment/myapp --namespace=staging --timeout=300s
- name: Run integration tests
run: |
npm run test:integration -- --base-url=https://staging.myapp.example.com
- name: Run performance baseline
run: |
npx k6 run tests/performance/baseline.js \
--env BASE_URL=https://staging.myapp.example.com \
--out json=perf-results.json
- uses: actions/upload-artifact@v4
with:
name: performance-results
path: perf-results.json
# ──────────────────────────────────────────────
# Stage 4: Deploy to production (requires approval)
# ──────────────────────────────────────────────
deploy-production:
needs: [build, deploy-staging]
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production cluster
run: |
kubectl set image deployment/myapp \
myapp=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.image-digest }} \
--namespace=production
kubectl rollout status deployment/myapp --namespace=production --timeout=600s
- name: Verify production health
run: |
for i in {1..12}; do
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://myapp.example.com/health)
if [ "$HTTP_CODE" != "200" ]; then
echo "Health check failed with $HTTP_CODE, attempt $i/12"
sleep 10
else
echo "Production is healthy"
exit 0
fi
done
echo "Production health check failed after 2 minutes"
exit 1
- name: Rollback on failure
if: failure()
run: kubectl rollout undo deployment/myapp --namespace=production
Notice that every deployment uses the same image-digest from the build job. The image is built once and promoted through each environment by reference.
Approval Gates
GitHub Environment Required Reviewers
The simplest approval gate is GitHub’s built-in “Required reviewers” on an environment. When the pipeline reaches a job targeting that environment, it pauses and notifies the specified reviewers. They approve or reject through the GitHub UI.
Configure this in Settings > Environments > production > Protection rules:
- Add 1-6 required reviewers
- Optionally set a wait timer (e.g., 5 minutes) to allow for last-minute objections
- Restrict deployment branches to
mainonly
Slack-Based Approval
For teams that live in Slack, you can build custom approval workflows:
request-approval:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- name: Request production approval via Slack
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"channel": "C0123456789",
"text": "Production deployment pending for ${{ github.repository }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Production Deployment Request*\nRepo: `${{ github.repository }}`\nCommit: `${{ github.sha }}`\nAuthor: ${{ github.actor }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|Approve in GitHub>"
}
}
]
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
Database Migrations Across Environments
Database schema changes are the trickiest part of environment promotion. The migration must run against each environment’s database before the new application code deploys.
Forward-Only Migrations
migrate-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Run migrations
run: |
npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Verify migration
run: |
npx prisma migrate status
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Backward-Compatible Migration Strategy
To safely promote through environments, every migration should be backward-compatible:
- Expand: Add new columns or tables (compatible with old code).
- Migrate: Deploy new code that uses both old and new schema.
- Contract: Remove old columns or tables in a subsequent release.
-- Migration 001: Expand (add new column, keep old one)
ALTER TABLE users ADD COLUMN full_name TEXT;
UPDATE users SET full_name = first_name || ' ' || last_name;
-- Migration 002: Contract (remove old columns, in a later release)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
Never run the contract migration until the expand migration has been deployed to all environments and the old columns are no longer read by any running code.
Feature Flags for Gradual Rollout
Feature flags decouple deployment from release. Code can be deployed to production but only activated for specific users or percentages:
// Using a feature flag service
const flags = require('./feature-flags');
app.get('/dashboard', async (req, res) => {
const showNewDashboard = await flags.isEnabled('new-dashboard', {
userId: req.user.id,
environment: process.env.NODE_ENV,
});
if (showNewDashboard) {
return res.render('dashboard-v2');
}
return res.render('dashboard-v1');
});
Environment-specific flag configuration:
{
"new-dashboard": {
"development": { "enabled": true, "percentage": 100 },
"staging": { "enabled": true, "percentage": 100 },
"production": { "enabled": true, "percentage": 5 }
}
}
The feature is fully enabled in dev and staging for testing, but only reaches 5% of production users initially.
Environment Configuration Management
Using ConfigMaps per Environment
# k8s/overlays/staging/config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
namespace: staging
data:
LOG_LEVEL: "debug"
CACHE_TTL: "60"
FEATURE_FLAGS_URL: "https://flags.internal/staging"
---
# k8s/overlays/production/config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
namespace: production
data:
LOG_LEVEL: "warn"
CACHE_TTL: "3600"
FEATURE_FLAGS_URL: "https://flags.internal/production"
Use Kustomize overlays to manage per-environment differences:
# k8s/overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: staging
resources:
- ../../base
patchesStrategicMerge:
- config.yaml
- replicas.yaml
Promotion Anti-Patterns
Avoid these common mistakes:
-
Rebuilding per environment. If you run
npm buildseparately for staging and production, you are not testing what you will deploy. Build once and promote the artifact. -
Skipping environments. “It worked on my machine, let’s push to prod” bypasses the safety net. Enforce the promotion path with branch protection and environment restrictions.
-
Environment drift. If staging uses a different database version, OS, or resource allocation than production, it cannot catch production-specific issues. Keep environments as similar as possible.
-
No rollback plan. Every promotion step should have an automated rollback. If production health checks fail, roll back automatically rather than paging someone at 3 AM.
-
Long-lived staging branches. Do not maintain separate
stagingandproductionbranches. Use a singlemainbranch and promote the same commit through environments.
Summary
Environment promotion is the backbone of reliable software delivery. Build your artifact once, deploy it to development automatically, validate it in staging with integration and performance tests, and promote to production through an approval gate. Use GitHub environment protection rules for built-in approval workflows, backward-compatible database migrations for safe schema evolution, and feature flags to decouple deployment from release. The same artifact, the same configuration structure, and increasing levels of validation at each stage ensure that what reaches your users has been thoroughly verified.
Progress is saved locally to your browser.