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:
- Verifies that the environment's protection rules are met
- If they aren't met, it pauses the workflow
- Once they're met, it runs the job with the environment's secrets
- 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:
- Shows "Waiting for review" in the Actions UI
- Sends a notification to the reviewers
- The reviewers see a "Review deployments" button
- A reviewer approves (or rejects)
- If they approve → the job runs
- 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
| Aspect | Without environments | With environments |
|---|---|---|
| Secrets | One global set | Separated per environment |
| Protection | None | Required reviewers, wait timer |
| Tracking | No history | A history of deployments |
| Branch control | No restriction | Deployment branches |
| Feedback | Only logs | Status on PRs, deployment badges |
Staging without protection vs staging with deployment branches
| Aspect | No restriction | With deployment branches |
|---|---|---|
| Who can deploy | Any workflow | Only from main |
| Risk | A feature branch deploys to staging | Only merged code |
| Recommendation | Don't use | Always restrict to main |
Wait timer vs no wait timer
| Aspect | No wait timer | With a wait timer (5 min) |
|---|---|---|
| Speed | An immediate deploy post-approval | 5 min of waiting |
| Safety | No observation buffer | Time to detect late problems |
| Ideal use | Staging (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 secretsproduction: 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 branchrelease/v*— release brancheshotfix/*— 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_OUTPUTat the step level writes the output's value${{ needs.build.outputs.image_tag }}reads the output from another jobdeploy-productiondeclaresneeds: [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
- GitHub Environments — Protection rules — Official documentation on protection rules
- GitHub Actions — Using environments for deployment — The complete guide to environments
- GitHub Actions — Defining outputs for jobs — How to pass data between jobs
- GitHub Deployment API — The REST API for creating deployments programmatically
- GitHub Actions — Required reviewers — How deployment review works
- GitHub CLI — Environment secrets — Configuring secrets from the CLI