Skip to content
Codeloom
CI/CD

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.

·8 min read · By Codeloom
Intermediate 12 min read

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:

  1. Development (dev) - Automatic deployment on every push. Used for rapid iteration and integration testing.
  2. Staging - Deployed after dev validation passes. Mirrors production configuration. Used for QA, performance testing, and stakeholder review.
  3. 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 main only

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:

  1. Expand: Add new columns or tables (compatible with old code).
  2. Migrate: Deploy new code that uses both old and new schema.
  3. 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:

  1. Rebuilding per environment. If you run npm build separately for staging and production, you are not testing what you will deploy. Build once and promote the artifact.

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

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

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

  5. Long-lived staging branches. Do not maintain separate staging and production branches. Use a single main branch 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.