GitHub Actions in Depth

Secrets, Environments & Approvals

18 min Lesson 7 of 30

Secrets, Environments & Approvals

Shipping code to production at scale means controlling who can deploy, when a deployment is allowed to proceed, and which credentials each stage of your pipeline can see. GitHub Actions addresses all three concerns through three layered primitives: Secrets, Environments, and Protection Rules. This lesson teaches you how to compose them so your pipeline behaves like one built at a Big-Tech company — with mandatory human gates, time windows, branch locks, and least-privilege credential scoping.

Secrets: Scoping and Precedence

Every secret in GitHub Actions has a scope. Scopes form a hierarchy, and a more-specific scope wins when names collide:

  1. Organization secrets — visible to all (or selected) repositories in the org. Rotate a third-party API key once, not across 200 repos.
  2. Repository secrets — scoped to a single repo; suitable for repo-specific tokens such as a Docker Hub service account.
  3. Environment secrets — scoped to a named environment (production, staging). They are injected only when the job targets that environment. A job targeting staging never sees production secrets — even if both environments share a secret name.
Environment secrets override repository secrets of the same name. Use this intentionally: define a low-privilege AWS_ROLE_ARN at the repository level and a high-privilege one at the production environment level.

Reference secrets with the secrets context: ${{ secrets.MY_SECRET }}. GitHub masks the literal value in all log output — but beware of base64-encoded or URL-encoded leakage (a classic mistake). You cannot read secret values back through the API; if you lose a secret, rotate it.

Environments: The Deployment Gate Model

An Environment is a named deployment target that carries its own secrets, variables, and protection rules. Think of it as the enforcement boundary between "code merged" and "code running in production".

You declare an environment at the job level:

jobs: deploy-production: runs-on: ubuntu-24.04 environment: name: production url: https://app.example.com # shown in the deployment timeline UI steps: - name: Deploy run: ./scripts/deploy.sh env: AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }} # environment secret DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

When GitHub Actions reaches this job, it pauses, checks all protection rules on the production environment, waits for approvals if required, and only then starts the runner and injects the environment secrets. No approval — no secret, no deployment.

Protection Rules in Depth

Navigate to Settings → Environments → [environment name] to configure rules. The four most important rules in production:

1. Required Reviewers

Specify up to six individuals or teams who must approve the deployment before it can proceed. A reviewer cannot approve their own run — GitHub enforces this automatically, which prevents a single engineer from bypassing the gate. In practice, configure this as an on-call team or a release-engineering team, not a manager who may be unavailable.

Use a GitHub team (e.g., @my-org/release-engineering) rather than individual users for required reviewers. Teams survive personnel changes, and any member of the team can approve — giving you coverage without creating a single point of failure.

2. Wait Timer

A delay (0–43,200 minutes) inserted before the environment becomes active. Use this as a mandatory bake time: your staging environment can absorb a 10-minute wait while automated smoke tests run after the staging deploy, before production is even permitted to start. This is not a replacement for testing — it is defense-in-depth.

3. Deployment Branches and Tags

Restrict which refs can deploy to this environment. Options are:

  • All branches — no restriction (fine for dev, dangerous for production).
  • Protected branches only — only branches with branch-protection rules can deploy. This implicitly means PRs were reviewed and status checks passed.
  • Selected branches and tags — explicit patterns such as main, release/*, or v[0-9]*.[0-9]*.[0-9]*. This is the Big-Tech standard: production only accepts tags matching a semver pattern, enforcing that every production deploy is a tagged release.

4. Prevent Self-Review

When enabled, the user who triggered the workflow cannot be one of the approvers. Enable this unconditionally on production environments — it eliminates the most common governance bypass.

GitHub Actions deployment pipeline with environment gates Build CI job Test CI job env: staging Wait timer Branch rule Smoke tests APPROVAL GATE Required reviewers No self-review Tag pattern only Prod secrets injected here Production env: production deploy job 👤 Reviewer env: staging secrets
A production pipeline with a staging environment gate followed by a mandatory human approval gate before production secrets are ever injected.

A Complete Workflow with Environments

The following workflow wires together build, staging deploy, and production deploy with proper scoping. Read the needs chain: the staging job runs only after tests pass; the production job runs only after staging succeeds and a reviewer approves.

name: Deploy Pipeline on: push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' # only tagged releases reach production jobs: build-and-test: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: '22' cache: 'npm' - run: npm ci - run: npm test - name: Build artifact run: npm run build - uses: actions/upload-artifact@v4 with: name: dist path: dist/ deploy-staging: needs: build-and-test runs-on: ubuntu-24.04 environment: name: staging url: https://staging.example.com steps: - uses: actions/download-artifact@v4 with: name: dist - name: Deploy to staging run: ./scripts/deploy.sh staging env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # staging env secret deploy-production: needs: deploy-staging runs-on: ubuntu-24.04 environment: name: production url: https://app.example.com steps: - uses: actions/download-artifact@v4 with: name: dist - name: Deploy to production run: ./scripts/deploy.sh production env: DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }} # production env secret (different value) DATADOG_API_KEY: ${{ secrets.DATADOG_API_KEY }}
Never put environment: production on your build or test jobs. If you do, the approval gate fires before tests run — reviewers approve a build that may not even compile, and production secrets are loaded into a job that does not need them. The environment declaration belongs only on the job that actually deploys.

Custom Deployment Protection Rules (GitHub Apps)

Since 2023, GitHub supports custom deployment protection rules via GitHub Apps. Instead of GitHub pausing and waiting for a human, it calls a webhook on your app, which applies its own logic and then calls the GitHub API to approve or reject. Teams at scale use this to wire in:

  • Change Advisory Board (CAB) ticket validation — no ticket, no deploy.
  • Feature-flag readiness checks — confirm the flag is disabled before the code that uses it lands.
  • Business-hours enforcement — automatically reject deployments outside the allowed window, or escalate to an on-call reviewer.
  • Canary health checks — query your observability platform and reject a production promotion if error rates are elevated.
For most teams, required reviewers plus branch/tag rules are sufficient and require zero code. Invest in custom deployment protection rules only when you need automated, policy-as-code enforcement that a human gate cannot provide quickly enough (e.g., a 30-second canary auto-rollback decision).

Common Failure Modes

Reviewer is the triggerer. With self-review prevention enabled, if the only configured reviewer is the engineer who pushed the tag, the deployment is stuck. Always configure a team with at least two members, or set a backup reviewer.

Secret name collision across scopes. A developer adds a repo-level DATABASE_URL pointing to staging, unaware that the production environment has its own DATABASE_URL. Staging jobs correctly pick up the repo-level value. Everything looks fine — until someone removes the environment secret, and suddenly the production deploy starts using the staging database URL. Audit and document your secret hierarchy.

Approval timeout. GitHub holds pending deployments for 30 days, then expires them. If your reviewer is on vacation and no backup is configured, the release expires silently. Set calendar reminders or use Slack notifications (via workflow_run or a custom step) to alert the team when a deployment is awaiting approval.

Bypassing the gate via workflow_dispatch. A workflow_dispatch trigger with a branch that is not in the environment's allowed-branch list will still hit the protection rules. But if someone creates a workaround workflow that skips the deploy-staging job and goes straight to deploy-production, the gate still fires — because protection rules are per-environment, not per-workflow. This is the correct mental model: the gate is on the environment, not on the workflow file.