Module 6: Deployment Pipelines

3. GitHub Environments and Protection Rules

Overview

In Module 4 (capsule 04) you created GitHub Environments to separate secrets between staging and production. Now you go further: you configure those environments as formal stages of your deployment pipeline, with protection rules that control who can deploy, when, and from which branch. Environments stop being just containers of secrets and become the structure of your deployment pipeline.

What changes: Before, environment: staging was a way to access specific secrets. Now, it's a declaration of intent: "this job deploys to staging." And for environment: production, GitHub pauses the whole workflow until a reviewer approves — the pipeline can't move forward without human authorization.

This capsule teaches you to configure complete environments with all their protection rules, deployment branches, and the interaction between environments and the workflow's flow.


GitHub Environments: more than secrets

What an environment is in the deployment context

A GitHub Environment groups three things:

GitHub Environment "staging":
  ├── Secrets (API keys, SSH keys, tokens)
  ├── Variables (URLs, config values)
  └── Protection Rules
       ├── Required reviewers (who approves)
       ├── Wait timer (minutes of waiting)
       ├── Deployment branches (from which branches)
       └── Custom rules (GitHub Apps)

When a job declares environment: staging, GitHub:

  1. Verifies that the environment's protection rules are met
  2. If they aren't met, it pauses the workflow
  3. Once they're met, it runs the job with the environment's secrets
  4. It records the deployment in the repo's "Deployments" tab

Creating the environments for deployment

GitHub → Settings → Environments

Environment 1: staging
  → Configure environment
  → No protection rules (an automatic deploy)
  → Secrets: DEPLOY_SSH_KEY, OPENAI_API_KEY (a limited budget)
  → Variables: DEPLOY_HOST=staging.your-app.com, APP_ENV=staging

Environment 2: production
  → Configure environment
  → Protection rules: required reviewers, wait timer
  → Secrets: DEPLOY_SSH_KEY, OPENAI_API_KEY (production)
  → Variables: DEPLOY_HOST=your-app.com, APP_ENV=production

From GitHub's CLI

# Create the environments (it requires the gh CLI)
gh api repos/{owner}/{repo}/environments/staging -X PUT
gh api repos/{owner}/{repo}/environments/production -X PUT

# Add secrets to the environment
gh secret set DEPLOY_SSH_KEY --env staging < ~/.ssh/staging_key
gh secret set DEPLOY_SSH_KEY --env production < ~/.ssh/production_key

gh secret set OPENAI_API_KEY --env staging --body "sk-staging-abc123"
gh secret set OPENAI_API_KEY --env production --body "sk-prod-xyz789"

Protection Rules

1. Required Reviewers

The most important protection rule: it requires one or more users to approve before the job runs.

GitHub → Settings → Environments → production → Protection rules
  ☑ Required reviewers
  Add reviewers: @your-username, @team-leads
  
  A maximum of 6 reviewers per environment
  Any of the listed ones can approve (OR, not AND)

How it works in the workflow:

deploy-production:
  needs: deploy-staging
  runs-on: ubuntu-latest
  environment:
    name: production
    url: https://your-app.com
  steps:
    - name: Deploy to production
      run: echo "Deploying to production..."

When the workflow reaches deploy-production, GitHub:

  1. Shows "Waiting for review" in the Actions UI
  2. Sends a notification to the reviewers
  3. The reviewers see a "Review deployments" button
  4. A reviewer approves (or rejects)
  5. If they approve → the job runs
  6. If they reject → the job gets cancelled

What the reviewer sees:

🔔 Deployment review requested

Workflow: Deploy Pipeline
Run #42: Push abc1234 to main
Environment: production

[Review deployments]

  ☑ production — Deploy to production environment
  
  Comment (optional): "Staging looks good, smoke tests pass"
  
  [Approve and deploy]  [Reject]

2. Wait Timer

A waiting period before the job can run, even if the reviewers already approved.

GitHub → Settings → Environments → production → Protection rules
  ☑ Wait timer
  Minutes: 5

The wait timer is useful for:

  • 📋 An observation buffer: Waiting 5 minutes after staging to see if there are late errors
  • 📋 Deploy windows: If you combine the wait timer with schedules, you avoid deploys at 3 AM
  • 📋 A cooling period: It prevents impulsive deploys after a quick fix
The flow with a 5-minute wait timer:

  deploy-staging completes → smoke tests OK
      ↓
  deploy-production: "Waiting for review"
      ↓
  The reviewer approves
      ↓
  Wait timer: 5 minutes
      ↓
  The job runs

3. Deployment Branches

It restricts which branches can deploy to an environment.

GitHub → Settings → Environments → production → Deployment branches
  
  The options:
  ○ All branches (any branch can deploy)
  ● Selected branches (only specific branches)
  ○ Protected branches (only branches with branch protection)
  
  Selected branches:
  → main
  → release/*

Why it matters: Without deployment branches, someone could create a workflow on a feature branch that declares environment: production and deploy directly, bypassing staging and approval.

# On a feature branch (WITHOUT a deployment branch restriction):
deploy:
  environment: production    # It bypasses staging and approval if there's no restriction
  steps:
    - run: echo "Direct deploy to production from a feature branch"

With deployment branches configured:

❌ Error: Branch 'feature/new-ui' is not allowed to deploy to environment 'production'.
   Allowed branches: main, release/*

4. Custom deployment protection rules (GitHub Apps)

Advanced rules using GitHub Apps. For this guide, you don't need them, but it's good to know they exist:

Examples:
- Requiring an observability service to confirm that staging is stable
- Requiring a security pipeline to have passed
- Integration with tools like Datadog, PagerDuty, ServiceNow

The complete environment configuration

Staging: an automatic deploy

Environment: staging

Protection rules:
  ☐ Required reviewers (NO — an automatic deploy)
  ☐ Wait timer (NO — no waiting)
  Deployment branches: Selected → main

Secrets:
  DEPLOY_SSH_KEY: (the SSH key for the staging server)
  OPENAI_API_KEY: sk-staging-abc123 (budget: $10/month)

Variables:
  DEPLOY_HOST: staging.your-app.com
  APP_ENV: staging
  LOG_LEVEL: debug

Staging has no required reviewers — every push to main deploys automatically. But it does have deployment branches restricted to main, to avoid deploys from feature branches.

Production: a deploy with approval

Environment: production

Protection rules:
  ☑ Required reviewers: @your-username, @lead-dev
  ☑ Wait timer: 5 minutes
  Deployment branches: Selected → main

Secrets:
  DEPLOY_SSH_KEY: (the SSH key for the production server)
  OPENAI_API_KEY: sk-prod-xyz789 (production)

Variables:
  DEPLOY_HOST: your-app.com
  APP_ENV: production
  LOG_LEVEL: warning

Production has required reviewers and a wait timer. Only main can deploy. The secrets are production's (with no limited budget, the real API key).


Environments in the workflow

Using environments in jobs

name: Deploy Pipeline

on:
  push:
    branches: [main]

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.your-app.com
    steps:
      - name: Deploy to staging
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
        run: |
          echo "Deploying to $DEPLOY_HOST"

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://your-app.com
    steps:
      - name: Deploy to production
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
        run: |
          echo "Deploying to $DEPLOY_HOST"

The url field

environment:
  name: production
  url: https://your-app.com

The url appears in GitHub's UI next to the deployment. When a deployment succeeds, GitHub shows a "View deployment" button that leads to that URL. It's informational but valuable — the reviewer can click and see the application.

Accessing secrets and variables

steps:
  - name: Deploy
    env:
      API_KEY: ${{ secrets.OPENAI_API_KEY }}    # The environment's secret
      HOST: ${{ vars.DEPLOY_HOST }}              # The environment's variable
      APP_ENV: ${{ vars.APP_ENV }}               # The environment's variable
    run: |
      echo "Deploying to $HOST with env $APP_ENV"
      echo "API key length: ${#API_KEY}"

secrets.* accesses the environment's secrets. vars.* accesses the variables. The difference: secrets get masked in the logs, variables don't.


Deployment tracking

What GitHub records

Every time a job with environment: runs, GitHub records a deployment:

GitHub → your-repo → Deployments

Environment: staging
  ✅ sha-abc1234 — Active — 2 minutes ago
  ✅ sha-prev123 — Inactive — 1 hour ago
  ❌ sha-broken1 — Failure — 3 hours ago

Environment: production
  ✅ sha-prev123 — Active — 1 hour ago
  ✅ sha-old0001 — Inactive — 1 day ago

This gives you a complete history of which version was deployed, when, and whether it succeeded. Crucial for rollbacks — you see exactly which image is running in production.

Deployment status on PRs

When a deployment succeeds, GitHub shows a badge on the corresponding PR:

PR #42: "Add embeddings endpoint"
  ✅ Tests passed
  ✅ Docker build successful
  🚀 Deployed to staging — View deployment
  ⏸️  Waiting for approval — production

This visual feedback helps the reviewer see the deployment's status without going to the Actions tab.


Secret precedence

Repository secrets vs Environment secrets

If you have a secret with the same name in the repository and in an environment, the environment wins:

Repository secrets:
  OPENAI_API_KEY = sk-repo-default

Environment secrets (staging):
  OPENAI_API_KEY = sk-staging-abc123

Environment secrets (production):
  OPENAI_API_KEY = sk-prod-xyz789
# A job WITHOUT an environment → it uses the repo secret
job-without-env:
  steps:
    - run: echo "${#KEY}"
      env:
        KEY: ${{ secrets.OPENAI_API_KEY }}
        # Value: sk-repo-default

# A job WITH an environment → it uses the environment secret
job-staging:
  environment: staging
  steps:
    - run: echo "${#KEY}"
      env:
        KEY: ${{ secrets.OPENAI_API_KEY }}
        # Value: sk-staging-abc123

The rule is simple: environment secret > repository secret (with the same name). If the environment doesn't have that secret, the repository's one gets used.


The complete workflow with environments

A deployment pipeline with staging and production

name: Deploy Pipeline

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: "pip"
      - run: pip install -r requirements-dev.txt
      - run: pytest tests/ -v --tb=short

  build:
    needs: test
    runs-on: ubuntu-latest
    timeout-minutes: 20
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.version }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/metadata-action@v5
        id: meta
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.your-app.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to staging
        env:
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          IMAGE_TAG: sha-${{ github.sha }}
        run: |
          echo "Deploying $IMAGE_TAG to $DEPLOY_HOST"

      - name: Smoke test
        run: |
          for i in $(seq 1 30); do
            if curl -sf https://staging.your-app.com/health > /dev/null 2>&1; then
              echo "Staging healthy after ${i}s"
              exit 0
            fi
            sleep 2
          done
          echo "Staging health check failed after 60s"
          exit 1

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://your-app.com
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to production
        env:
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          IMAGE_TAG: sha-${{ github.sha }}
        run: |
          echo "Deploying $IMAGE_TAG to $DEPLOY_HOST"

      - name: Smoke test
        run: |
          for i in $(seq 1 30); do
            if curl -sf https://your-app.com/health > /dev/null 2>&1; then
              echo "Production healthy after ${i}s"
              exit 0
            fi
            sleep 2
          done
          echo "Production health check failed after 60s"
          exit 1

Comparisons

Without environments vs with environments

AspectWithout environmentsWith environments
SecretsOne global setSeparated per environment
ProtectionNoneRequired reviewers, wait timer
TrackingNo historyA history of deployments
Branch controlNo restrictionDeployment branches
FeedbackOnly logsStatus on PRs, deployment badges

Staging without protection vs staging with deployment branches

AspectNo restrictionWith deployment branches
Who can deployAny workflowOnly from main
RiskA feature branch deploys to stagingOnly merged code
RecommendationDon't useAlways restrict to main

Wait timer vs no wait timer

AspectNo wait timerWith a wait timer (5 min)
SpeedAn immediate deploy post-approval5 min of waiting
SafetyNo observation bufferTime to detect late problems
Ideal useStaging (a fast deploy)Production (a cooling period)

Troubleshooting

1. "Required status checks not met for environment"

Symptom:

Error: Required status checks have not passed for environment 'production'.

Cause: The environment has required status checks (different from required reviewers) and a previous check failed.

Solution: Verify that every previous job (test, build, deploy-staging) passed. If you configured required status checks on the environment, disable them if you don't need them.

2. The reviewer can't approve — the button doesn't appear

Symptom: The reviewer goes to Actions but doesn't see the "Review deployments" button.

Cause: The reviewer isn't listed as a required reviewer of the environment, or they don't have permissions on the repo.

Solution:

GitHub → Settings → Environments → production → Protection rules
  Required reviewers → Verify that the username is listed
  
  The reviewer needs at least "write" access to the repo

3. The environment's variables are empty in the job

Symptom: ${{ vars.DEPLOY_HOST }} is an empty string.

Cause: The variable isn't configured in the environment, or the name has a typo.

Solution:

GitHub → Settings → Environments → staging → Environment variables
  Add variable:
    Name: DEPLOY_HOST (exactly as in the YAML)
    Value: staging.your-app.com

Variables (unlike secrets) are case-sensitive and don't get masked in the logs.

4. Deployment branches block an expected workflow

Symptom:

Error: Branch 'release/v1.2' is not allowed to deploy to environment 'production'.

Cause: The branch isn't in the list of allowed deployment branches.

Solution:

GitHub → Settings → Environments → production → Deployment branches
  Add branch: release/*
  
  Use glob patterns: main, release/*, hotfix/*

5. The deployment history shows "Inactive" for the correct version

Symptom: The current version appears as "Inactive" in the deployment history.

Cause: A later deployment (even a failed one) can mark the previous one as "Inactive."

Solution: This is cosmetic. The "Active" status gets assigned to the last successful deployment. Verify with the smoke test that the correct version is running:

curl -s https://your-app.com/health | jq .version

Exercises

Exercise 1: Configure two environments from scratch

Describe step by step how you would create these two environments on GitHub for an AI project:

  • staging: an automatic deploy from main, with staging's secrets
  • production: a required reviewer, a 5-min wait timer, only from main
See solution

Step 1: Create staging

GitHub → Settings → Environments → New environment → "staging"
→ Configure environment

Deployment branches:
  ● Selected branches → Add: main

Protection rules:
  ☐ Required reviewers (don't enable — an automatic deploy)
  ☐ Wait timer (don't enable — no waiting)

Environment secrets:
  OPENAI_API_KEY = sk-staging-abc123
  DEPLOY_SSH_KEY = (the contents of ~/.ssh/staging_key)

Environment variables:
  DEPLOY_HOST = staging.your-app.com
  APP_ENV = staging

Step 2: Create production

GitHub → Settings → Environments → New environment → "production"
→ Configure environment

Deployment branches:
  ● Selected branches → Add: main

Protection rules:
  ☑ Required reviewers → Add: @your-username, @lead-dev
  ☑ Wait timer → 5 minutes

Environment secrets:
  OPENAI_API_KEY = sk-prod-xyz789
  DEPLOY_SSH_KEY = (the contents of ~/.ssh/production_key)

Environment variables:
  DEPLOY_HOST = your-app.com
  APP_ENV = production

Step 3: Verify it in a workflow

jobs:
  deploy-staging:
    environment: staging
    # → It runs immediately (no protection rules)

  deploy-production:
    needs: deploy-staging
    environment: production
    # → It pauses until a reviewer approves + a 5 min wait

Exercise 2: Secret precedence

You have these secrets configured:

Repository: OPENAI_API_KEY = sk-repo-111
Staging env: OPENAI_API_KEY = sk-staging-222
Production env: (it has no OPENAI_API_KEY)

What value does each job receive?

jobs:
  test:           # No environment
  deploy-staging: # environment: staging
  deploy-prod:    # environment: production
See solution
test (no environment):
  ${{ secrets.OPENAI_API_KEY }} → sk-repo-111
  The reason: With no environment, the repo secret gets used.

deploy-staging (environment: staging):
  ${{ secrets.OPENAI_API_KEY }} → sk-staging-222
  The reason: The environment secret overrides the repo secret.

deploy-prod (environment: production):
  ${{ secrets.OPENAI_API_KEY }} → sk-repo-111
  The reason: Production doesn't have that secret → it falls back to the repo secret.

The precedence is: environment secret > repository secret. If the environment doesn't define the secret, the repository's one gets used. If neither defines it, the value is an empty string.

Exercise 3: Restrictive deployment branches

Your team uses this branching strategy:

  • main — the main branch
  • release/v* — release branches
  • hotfix/* — urgent fixes

Configure the deployment branches for staging (main + release + hotfix) and production (main + release only).

See solution

Staging:

GitHub → Settings → Environments → staging → Deployment branches
  ● Selected branches
  Add branch: main
  Add branch: release/*
  Add branch: hotfix/*

Staging accepts deploys from main (normal development), release branches (pre-release QA), and hotfixes (urgent fixes that need fast testing).

Production:

GitHub → Settings → Environments → production → Deployment branches
  ● Selected branches
  Add branch: main
  Add branch: release/*

Production does NOT accept hotfix branches directly. A hotfix must first be deployed to staging (from hotfix/*), validated, merged into main, and then deployed to production (from main).

If you need an urgent hotfix in production without going through main, you must add hotfix/* to production temporarily, do the deploy, and then remove it.

Exercise 4: A workflow with outputs between jobs

Create a workflow where the build job generates the image's tag as an output, and the deploy-staging and deploy-production jobs use it as an input.

See solution
name: Deploy Pipeline

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_tag: ${{ steps.tag.outputs.tag }}
    steps:
      - uses: actions/checkout@v4

      - name: Generate image tag
        id: tag
        run: |
          SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
          echo "tag=sha-${SHORT_SHA}" >> $GITHUB_OUTPUT

      - name: Build and push
        run: echo "Building ghcr.io/user/app:${{ steps.tag.outputs.tag }}"

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Deploy to staging
        run: |
          echo "Deploying image: ghcr.io/user/app:${{ needs.build.outputs.image_tag }}"

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy to production
        run: |
          echo "Deploying image: ghcr.io/user/app:${{ needs.build.outputs.image_tag }}"

Key points:

  • outputs: at the job level declares which outputs are available
  • $GITHUB_OUTPUT at the step level writes the output's value
  • ${{ needs.build.outputs.image_tag }} reads the output from another job
  • deploy-production declares needs: [build, deploy-staging] to access the output AND wait for staging

Summary

  • GitHub Environments group secrets, variables, and protection rules per environment
  • Required reviewers pause the workflow until a human approves the deploy
  • A wait timer adds a waiting period after the approval
  • Deployment branches restrict which branches can deploy
  • Staging: no protection rules — an automatic deploy on every push to main
  • Production: with protection rules — required reviewers + a wait timer + a branch restriction
  • Precedence: environment secret > repository secret
  • Deployment tracking: GitHub records a complete history of deployments per environment
  • Job outputs let you pass data (like image tags) between the pipeline's jobs

Additional resources

  1. GitHub Environments — Protection rules — Official documentation on protection rules
  2. GitHub Actions — Using environments for deployment — The complete guide to environments
  3. GitHub Actions — Defining outputs for jobs — How to pass data between jobs
  4. GitHub Deployment API — The REST API for creating deployments programmatically
  5. GitHub Actions — Required reviewers — How deployment review works
  6. GitHub CLI — Environment secrets — Configuring secrets from the CLI