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:

  1. Creating GitHub Environments with independent secrets
  2. Using environments in workflows with the environment key
  3. Configuring protection rules (required reviewers, wait timer)
  4. Designing a staging → approval → production flow
  5. 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

ScenarioWithout environmentsWith environments
A bug in CI generates 100K requests$2,000 on the production account$10 maximum (staging's budget)
The staging key is compromisedThe same key as productionOnly staging is affected
A developer needs to testThey use the production keyThey 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:

  1. Staging runs first with its key
  2. If staging passes → production activates
  3. If production has required reviewers → it waits for approval
  4. 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

  1. GitHub Environments — Official documentation on environments
  2. Environment Protection Rules — Required reviewers, wait timers, branch policies
  3. Reviewing Deployments — How to approve or reject a deployment
  4. OpenAI API Usage Limits — Configuring budgets and rate limits in OpenAI
  5. GitHub CLI — Environment Secrets — The --env flag for per-environment secrets
  6. Deployment Environments Best Practices — Deployment patterns with environments