Module 4: Secrets and Environment Management
4. Per-Environment Secrets
Overview
So far, every secret in your repository is global — a single OPENAI_API_KEY that uses the same value both for tests in CI and for production. That works for a personal project, but it has a serious problem: if your staging pipeline generates unexpected costs (a bug that makes 10,000 requests), the costs get billed to the same account as production. If someone compromises the CI key, they also compromise production.
GitHub Environments solves this. You can create separate environments — staging and production — each with its own secrets. Staging uses an API key with a limited budget of $10/month. Production uses the real key with no limit. If staging has a problem, production isn't affected.
But environments do more than separate secrets. You can configure protection rules: requiring manual approval before a workflow uses production's secrets. This means nobody can deploy to production without a reviewer approving it — even if the tests pass.
What you're going to learn:
- Creating GitHub Environments with independent secrets
- Using environments in workflows with the
environmentkey - Configuring protection rules (required reviewers, wait timer)
- Designing a staging → approval → production flow
- Understanding the precedence between environment secrets and repository secrets
What a GitHub Environment is
A GitHub Environment is a deployment context that groups:
- ✅ Secrets specific to the environment (they override repo-level secrets with the same name)
- ✅ Variables specific to the environment
- ✅ Protection rules: manual approval, wait timers, branch restrictions
- ✅ A deployment URL (optional, for tracking)
Creating an environment
GitHub → Settings → Environments → New environment
Name: staging
→ Configure environment
GitHub → Settings → Environments → New environment
Name: production
→ Configure environment
Adding secrets to the environment
Environment: staging
→ Environment secrets → Add secret
Name: OPENAI_API_KEY
Value: sk-staging-abc123... (a key with a limited budget)
Environment: production
→ Environment secrets → Add secret
Name: OPENAI_API_KEY
Value: sk-prod-xyz789... (the production key)
Both secrets are called OPENAI_API_KEY, but they have different values. The workflow sees one or the other depending on the environment it declares.
From the CLI
# Create secrets per environment
gh secret set OPENAI_API_KEY --env staging --body "sk-staging-abc123..."
gh secret set OPENAI_API_KEY --env production --body "sk-prod-xyz789..."
# Verify
gh secret list --env staging
# Output:
# NAME UPDATED
# OPENAI_API_KEY 2026-03-08
gh secret list --env production
# Output:
# NAME UPDATED
# OPENAI_API_KEY 2026-03-08
Using environments in workflows
Basic syntax
name: AI Pipeline with Environments
on:
push:
branches: [main]
jobs:
staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Run AI checks (staging)
run: |
echo "Running in staging environment"
python scripts/evaluate_prompts.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
# Uses sk-staging-abc123...
By declaring environment: staging, the job gets access to the "staging" environment's secrets. If OPENAI_API_KEY exists both as a repository secret and as an environment secret, the environment secret takes priority.
The complete pipeline: staging → production
name: Staged AI Pipeline
on:
push:
branches: [main]
jobs:
lint-and-test:
name: Lint & Test
runs-on: ubuntu-latest
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: ruff check src/ tests/ scripts/
- run: pytest tests/ -v --tb=short
staging-checks:
name: AI Checks (Staging)
runs-on: ubuntu-latest
needs: [lint-and-test]
environment: staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -r requirements.txt
- name: Verify staging secrets
run: |
if [ -z "$OPENAI_API_KEY" ]; then
echo "ERROR: OPENAI_API_KEY not set for staging"
exit 1
fi
echo "Staging API key configured (${#OPENAI_API_KEY} chars)"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run prompt evaluation (staging)
run: python scripts/evaluate_prompts.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: staging
- name: Run cost estimation
run: python scripts/estimate_cost.py --threshold 5.00
production-checks:
name: AI Checks (Production)
runs-on: ubuntu-latest
needs: [staging-checks]
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- run: pip install -r requirements.txt
- name: Verify production secrets
run: |
if [ -z "$OPENAI_API_KEY" ]; then
echo "ERROR: OPENAI_API_KEY not set for production"
exit 1
fi
echo "Production API key configured (${#OPENAI_API_KEY} chars)"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run prompt evaluation (production)
run: python scripts/evaluate_prompts.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: production
The visual flow
Push to main
│
├── lint-and-test (no secrets)
│ ├── ruff check
│ └── pytest
│
├── staging-checks (environment: staging)
│ ├── OPENAI_API_KEY = sk-staging-abc123...
│ ├── Prompt evaluation
│ └── Cost estimation
│
└── production-checks (environment: production)
├── Requires manual approval ← protection rule
├── OPENAI_API_KEY = sk-prod-xyz789...
├── Prompt evaluation
└── Cost estimation
Protection rules
Protection rules are the second big advantage of environments. They let you add gates before a job can run.
Required reviewers
Environment: production
→ Protection rules
☑ Required reviewers
Reviewers: @your-username, @lead-developer
→ Save protection rules
When a workflow reaches the job with environment: production, it pauses and waits for approval. You'll see a "Review deployments" button in GitHub Actions' UI. Only the designated reviewers can approve.
Wait timer
Environment: production
→ Protection rules
☑ Wait timer
Minutes: 5
→ Save protection rules
Even after approval, the job waits N minutes before running. This gives you time to cancel if you spot a problem.
Branch restrictions
Environment: production
→ Deployment branches
→ Selected branches
Add: main
→ Save
Only workflows triggered by the main branch can use the production environment. A push to feature/xyz can't access production's secrets.
Example of a workflow with an approval gate
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [staging-checks]
environment:
name: production
url: https://my-app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy
run: echo "Deploying to production..."
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
The url property is optional — GitHub shows it in the UI so reviewers know where the deploy is going.
What the reviewer sees
GitHub → Actions → Workflow Run
┌────────────────────────────────────────────┐
│ deploy-production │
│ Waiting for review │
│ │
│ Environment: production │
│ URL: https://my-app.example.com │
│ │
│ [Review deployments] [Reject] │
└────────────────────────────────────────────┘
The reviewer sees which environment, which URL, and can approve or reject.
Precedence: environment vs repository secrets
The same name "OPENAI_API_KEY":
Repository secret: sk-repo-default...
Environment "staging": sk-staging-abc123...
Environment "production": sk-prod-xyz789...
A job without an environment:
→ Uses sk-repo-default... (the repository secret)
A job with environment: staging
→ Uses sk-staging-abc123... (environment overrides repo)
A job with environment: production
→ Uses sk-prod-xyz789... (environment overrides repo)
This lets you have a repo-level "fallback" that gets used when a job doesn't declare an environment.
Pattern: API keys with different budgets
Configuring budgets in OpenAI
OpenAI lets you create multiple API keys with different configurations:
OpenAI Dashboard → API Keys
Key 1: "staging-ci" → Budget: $10/month → sk-staging-abc123...
Key 2: "production" → Budget: $500/month → sk-prod-xyz789...
Mapping them to GitHub Environments
GitHub Environment: staging
OPENAI_API_KEY = sk-staging-abc123... (budget: $10/month)
GitHub Environment: production
OPENAI_API_KEY = sk-prod-xyz789... (budget: $500/month)
The benefits
| Scenario | Without environments | With environments |
|---|---|---|
| A bug in CI generates 100K requests | $2,000 on the production account | $10 maximum (staging's budget) |
| The staging key is compromised | The same key as production | Only staging is affected |
| A developer needs to test | They use the production key | They use the staging key with a low budget |
A script for detecting the active environment
#!/usr/bin/env python3
"""
Detect and validate the active environment based on environment variables.
"""
import os
import sys
def detect_environment() -> dict:
app_env = os.environ.get("APP_ENV", "unknown")
api_key = os.environ.get("OPENAI_API_KEY", "")
github_env = os.environ.get("GITHUB_ENVIRONMENT", "none")
key_prefix = api_key[:10] + "..." if len(api_key) > 10 else "not set"
return {
"app_env": app_env,
"github_environment": github_env,
"api_key_set": bool(api_key),
"api_key_length": len(api_key),
"api_key_prefix": key_prefix if api_key else "N/A",
}
def validate_environment(env_info: dict) -> bool:
if not env_info["api_key_set"]:
print(f"FAIL: No API key set for environment '{env_info['app_env']}'")
return False
if env_info["api_key_length"] < 20:
print(f"FAIL: API key too short for environment '{env_info['app_env']}'")
return False
print(f"OK: Environment '{env_info['app_env']}' configured correctly")
print(f" GitHub Environment: {env_info['github_environment']}")
print(f" API key length: {env_info['api_key_length']} chars")
return True
if __name__ == "__main__":
env_info = detect_environment()
success = validate_environment(env_info)
sys.exit(0 if success else 1)
Expected output (staging)
OK: Environment 'staging' configured correctly
GitHub Environment: staging
API key length: 51 chars
In the workflow
- name: Validate environment
run: python scripts/detect_environment.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: staging
A complete example: three environments
For bigger projects, you can add a development environment for PRs:
name: Full Environment Pipeline
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements-dev.txt
- run: pytest tests/ -v
development:
name: AI Checks (Development)
runs-on: ubuntu-latest
needs: [test]
if: github.event_name == 'pull_request'
environment: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Quick evaluation
run: python scripts/evaluate_prompts.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: development
staging:
name: AI Checks (Staging)
runs-on: ubuntu-latest
needs: [test]
if: github.ref == 'refs/heads/main'
environment: staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Full evaluation
run: python scripts/evaluate_prompts.py --full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: staging
production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: [staging]
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://api.my-app.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Production evaluation
run: python scripts/evaluate_prompts.py --full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
APP_ENV: production
The visual flow
PR opened:
test → development (dev key, $5/month budget)
Push to main:
test → staging (staging key, $10/month budget)
→ production (prod key, requires approval)
Troubleshooting
"The job with an environment doesn't see the secrets"
Cause: The environment's name in the workflow doesn't match the name in GitHub Settings.
Solution: The names are case-sensitive. If you created "Production" (with a capital P) on GitHub, the workflow must use environment: Production, not environment: production.
# ✅ It must match the name in Settings exactly
environment: staging # If in Settings it's called "staging"
"The workflow stays 'Waiting for review' and nobody can approve"
Cause: There are no reviewers configured, or the user trying to approve isn't on the reviewers list.
Solution: Go to Settings → Environments → production → Required reviewers and add at least one user or team. The user who triggers the workflow can also be a reviewer (they self-approve).
"I want to test with production secrets from a feature branch"
Cause: The production environment's branch restrictions only allow main.
Solution: Don't remove the restriction. Instead, temporarily add your branch to the allowed list, or better yet, use the staging environment for testing:
Environment: production → Deployment branches → Selected branches
main ← always
hotfix/** ← for emergencies (temporary)
"The environment secrets don't override the repository ones"
Cause: The job doesn't have the environment key declared.
Solution: Verify that the job declares the environment:
# ❌ It uses repository secrets (no environment)
jobs:
deploy:
runs-on: ubuntu-latest
steps: ...
# ✅ It uses environment secrets
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps: ...
"The approval gate triggers on every push to main"
Cause: The job with the production environment runs on every push to main, and each time it requires approval.
Solution: Add a condition so it only runs on certain triggers:
production:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
Or split the deployment into a different workflow triggered by release tags.
Exercises
Exercise 1: Create two environments with different secrets
Using GitHub's CLI, create "staging" and "production" environments with different API keys. Verify that both exist.
See solution
# Create the secrets for staging
gh secret set OPENAI_API_KEY --env staging
# (enter the staging key when it asks)
# Create the secrets for production
gh secret set OPENAI_API_KEY --env production
# (enter the production key when it asks)
# Verify
gh secret list --env staging
# Output:
# NAME UPDATED
# OPENAI_API_KEY 2026-03-08
gh secret list --env production
# Output:
# NAME UPDATED
# OPENAI_API_KEY 2026-03-08
# Verify the environments exist
gh api repos/{owner}/{repo}/environments --jq '.environments[].name'
# Output:
# staging
# production
The environments get created automatically when you add a secret with --env. You can also create them manually in Settings → Environments.
Exercise 2: A workflow with staging → production
Create a workflow that runs a script in staging first, and only if it passes, runs it in production.
See solution
# .github/workflows/staged-pipeline.yml
name: Staged Pipeline
on:
push:
branches: [main]
jobs:
staging:
name: Run in Staging
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Evaluate in staging
run: |
echo "Environment: staging"
echo "API Key set: $([ -n "$OPENAI_API_KEY" ] && echo yes || echo no)"
python scripts/evaluate_prompts.py \
--baseline baselines/prompt-baseline.json \
--output results/staging-eval.json
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload staging results
uses: actions/upload-artifact@v4
if: always()
with:
name: staging-results
path: results/staging-eval.json
production:
name: Run in Production
runs-on: ubuntu-latest
needs: [staging]
environment:
name: production
url: https://api.example.com
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Evaluate in production
run: |
echo "Environment: production"
echo "API Key set: $([ -n "$OPENAI_API_KEY" ] && echo yes || echo no)"
python scripts/evaluate_prompts.py \
--baseline baselines/prompt-baseline.json \
--output results/production-eval.json
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
The flow:
- Staging runs first with its key
- If staging passes → production activates
- If production has required reviewers → it waits for approval
- Production runs with its own key
Exercise 3: A per-environment validation script
Write a script that validates configuration specific to each environment: staging must use gpt-4o-mini, production can use gpt-4o.
See solution
#!/usr/bin/env python3
"""Validate configuration per environment."""
import os
import sys
ENVIRONMENT_RULES = {
"staging": {
"allowed_models": ["gpt-4o-mini"],
"max_daily_cost": 10.00,
"require_api_key": True,
},
"production": {
"allowed_models": ["gpt-4o-mini", "gpt-4o"],
"max_daily_cost": 500.00,
"require_api_key": True,
},
"development": {
"allowed_models": ["gpt-4o-mini"],
"max_daily_cost": 5.00,
"require_api_key": False,
},
}
def validate_environment() -> bool:
app_env = os.environ.get("APP_ENV", "development")
model = os.environ.get("MODEL", "gpt-4o-mini")
api_key = os.environ.get("OPENAI_API_KEY", "")
if app_env not in ENVIRONMENT_RULES:
print(f"FAIL: Unknown environment '{app_env}'")
return False
rules = ENVIRONMENT_RULES[app_env]
all_ok = True
if model not in rules["allowed_models"]:
print(f"FAIL: Model '{model}' not allowed in {app_env}")
print(f" Allowed: {rules['allowed_models']}")
all_ok = False
else:
print(f"OK: Model '{model}' is allowed in {app_env}")
if rules["require_api_key"] and not api_key:
print(f"FAIL: API key required for {app_env} but not set")
all_ok = False
elif api_key:
print(f"OK: API key is set ({len(api_key)} chars)")
else:
print(f"SKIP: API key not required for {app_env}")
print(f"INFO: Max daily cost for {app_env}: ${rules['max_daily_cost']:.2f}")
return all_ok
if __name__ == "__main__":
success = validate_environment()
sys.exit(0 if success else 1)
Expected output (staging with gpt-4o-mini):
OK: Model 'gpt-4o-mini' is allowed in staging
OK: API key is set (51 chars)
INFO: Max daily cost for staging: $10.00
Expected output (staging with gpt-4o):
FAIL: Model 'gpt-4o' not allowed in staging
Allowed: ['gpt-4o-mini']
OK: API key is set (51 chars)
INFO: Max daily cost for staging: $10.00
Exercise 4: Configure protection rules for production
Document the steps for configuring required reviewers and branch restrictions in the "production" environment. Include the recommended configuration.
See solution
Steps to configure protection rules:
1. GitHub → Settings → Environments → production → Configure environment
2. Required reviewers:
☑ Required reviewers
Reviewers: @lead-developer, @devops-team
→ Up to 6 reviewers
3. Wait timer:
☑ Wait timer
Minutes: 5
→ It gives you time to cancel if you spot a problem
4. Deployment branches:
→ Selected branches
Add rule: main
Add rule: hotfix/*
→ Only main and hotfix branches can deploy to production
5. Save protection rules
The recommended configuration:
Environment: staging
Required reviewers: No
Wait timer: 0 minutes
Deployment branches: All branches
→ No restrictions, to make development easy
Environment: production
Required reviewers: Yes (at least 1 reviewer)
Wait timer: 5 minutes
Deployment branches: main, hotfix/*
→ Maximum restrictions to protect production
Verify it with the CLI:
gh api repos/{owner}/{repo}/environments/production \
--jq '{
protection_rules: .protection_rules,
deployment_branch_policy: .deployment_branch_policy
}'
Summary
- ✅ GitHub Environments group secrets, variables, and protection rules by deployment context
- ✅ Per-environment secrets let you have different API keys for staging and production
- ✅ Precedence: environment secrets override repository secrets with the same name
- ✅ Protection rules: required reviewers, wait timers, and branch restrictions
- ✅ Required reviewers create an approval gate before production's secrets get used
- ✅ Branch restrictions limit which branches can activate an environment
- ✅ Budget separation: staging with a low budget ($10/month), production with the real budget
- ✅ The professional flow: test → staging → approval → production
- ✅ A compromise in staging doesn't affect production — completely separate keys and budgets
Additional resources
- GitHub Environments — Official documentation on environments
- Environment Protection Rules — Required reviewers, wait timers, branch policies
- Reviewing Deployments — How to approve or reject a deployment
- OpenAI API Usage Limits — Configuring budgets and rate limits in OpenAI
- GitHub CLI — Environment Secrets — The
--envflag for per-environment secrets - Deployment Environments Best Practices — Deployment patterns with environments