Skip to content

GitHub Environments and the Approval Gate You Actually Want

Production deploys need a real approval gate. Use GitHub Environments with native protection rules and environment-scoped secrets, not workflow if: hacks or third-party manual-approval actions.

Production deploys need a human in the loop, and most teams reach for the wrong primitive. The common shapes are a workflow if: condition that checks a label, a marketplace manual-approval action that blocks on issues: write, or a workflow_dispatch button that any contributor can fire. Build the artifact once from main, deploy it to staging automatically, and promote the same artifact to production only after a PM or QA approves at a GitHub Environment gate. No per-stage rebuild — same artifact, two environments, one approval; that is what gates the credential and the human action together and leaves an audit row tied to the run.

What an Environment actually is

A GitHub Environment is a named object on a repo with three things: environment secrets, environment variables, and deployment protection rules. It lives at Settings → Environments in the UI and at /repos/{owner}/{repo}/environments in the REST API. A job that targets an environment cannot decrypt its secrets or run its steps until the protection rules have passed. The protection rules and the secret access are the same gate; this is the property that makes the abstraction worth using.

The three deployment protection rules

These are not branch protection rules. Branch protection (and the newer repository rulesets) gate the merge into main. Deployment protection rules live on the Environment object and gate the deploy that targets it; they run after the merge, when a job picks up the artifact. GitHub ships three of them.

Required reviewers. Up to six users or teams. Reviewers need at least read access to the repo. Only one of the listed reviewers needs to approve for the job to proceed; the others can ignore the request. The approval form has a comment box, which is the audit trail per run. A Prevent self-review toggle exists and is off by default; flip it on every production environment.

Wait timer. An integer between 1 and 43,200 minutes (30 days). The deploy job sits in the Waiting state for that duration after it triggers. Wait time does not count toward billable Actions minutes.

Deployment branch and tag policies. Three modes: no restriction, protected branches only, or selected branches and tags by name pattern (main, release/*, v*.*.*). This filters which refs can request a deploy to the environment at all; without it, a feature branch can sit at the gate waiting for someone to approve it.

There is also an escape hatch. A job can target an environment without queuing for the gate or creating a deployment record by setting deployment: false at the job level (shipped March 2026). This is the right shape for plan-only jobs — a terraform plan, a dry-run build, a manifest generator — that need read access to environment secrets but should never trigger an approval. The caveat: deployment: false is mutually exclusive with custom deployment protection rules; an environment that uses a custom rule still requires auto-deploy on every job that targets it.

Wiring it up

One build per tag, two environments, one approval. A tag push on main builds the artifact once, pushes it to a registry, and deploys it to staging automatically. A separate workflow_dispatch workflow promotes the same image to production and waits at the Environment gate for a PM or QA approval. The build is never repeated per stage; the production deploy is the same bytes that ran through staging.

yaml
# .github/workflows/release.yml# Tag push on main: build the artifact once, deploy to staging automatically.name: releaseon:  push:    tags: ['v*.*.*']
permissions:  contents: read  packages: write  id-token: write
jobs:  build:    runs-on: ubuntu-latest    outputs:      image: ghcr.io/${{ github.repository }}:${{ github.ref_name }}    steps:      - uses: actions/checkout@v6      - uses: docker/login-action@v3        with:          registry: ghcr.io          username: ${{ github.actor }}          password: ${{ secrets.GITHUB_TOKEN }}      - uses: docker/build-push-action@v6        with:          push: true          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
  deploy-staging:    needs: build    runs-on: ubuntu-latest    environment:      name: staging      url: https://staging.example.com    steps:      - uses: actions/checkout@v6      - run: ./deploy.sh staging "${{ needs.build.outputs.image }}"        env:          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

staging is still an Environment — it holds the staging deploy credential, the staging URL, and (optionally) a tag policy. It just has no required reviewers, so the deploy job runs as soon as the build finishes.

Production lives in a separate workflow. It takes a tag as input, never rebuilds, and pulls the existing image from the registry. The production Environment carries the required-reviewers list; the run sits at the gate until a PM or QA approves.

yaml
# .github/workflows/promote.yml# Manual promote of an existing image to production. Required reviewers gate the deploy.name: promoteon:  workflow_dispatch:    inputs:      tag:        description: 'Release tag to promote (e.g. v1.4.2)'        required: true
concurrency:  group: deploy-production-${{ inputs.tag }}  cancel-in-progress: false
permissions:  contents: read  packages: read  id-token: write
jobs:  deploy-production:    runs-on: ubuntu-latest    environment:      name: production      url: https://app.example.com    steps:      - uses: actions/checkout@v6      - run: ./deploy.sh production "ghcr.io/${{ github.repository }}:${{ inputs.tag }}"        env:          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

DEPLOY_TOKEN lives as an environment secret on each Environment — the staging deploy reads the staging token, the production deploy reads the production token, and neither workflow can read the other. The id-token: write permission lets the deploy step mint an OIDC token for short-lived cloud credentials when the target supports it. Setting a tag policy of v*.*.* on the production Environment means the promote workflow can only run against release tags; an attacker who triggers workflow_dispatch with a feature-branch name stops at the gate. The concurrency block at the top of promote.yml keeps two simultaneous promotes of the same tag from racing — the second one sits in the queue until the first finishes.

The reviewer experience: someone with deploy authority runs gh workflow run promote.yml -f tag=v1.4.2 or clicks Run workflow in the UI. GitHub queues the deploy-production job, posts it to the Deployments view, and emails the listed reviewers. The reviewer clicks Review deployments, ticks production, optionally types a comment, and clicks Approve and deploy or Reject. The comment is the audit string.

The same flow over the API, useful for change-ticket bots:

bash
# Trigger the promote workflow against an existing taggh workflow run promote.yml -f tag=v1.4.2
# Find the resulting run idgh run list --workflow=promote.yml --limit 1
# List pending approvals for that rungh api /repos/OWNER/REPO/actions/runs/RUN_ID/pending_deployments
# Approve via APIgh api -X POST /repos/OWNER/REPO/actions/runs/RUN_ID/pending_deployments \  -F environment_ids[]=PROD_ENV_ID \  -F state=approved \  -F comment="Approved per CHG-12345"

state is approved or rejected. A rejected run stops the deploy-production job and marks the production environment as failed for that run; the image stays at the registry tag, untouched. Rollback is the same flow with a previous tag: trigger promote.yml with the last good v*.*.*, approve at the gate, deploy. The artifact is immutable, so there is no separate rollback workflow.

What you still get wrong

The three settings that get missed most often are not about YAML; they are about how the environment is configured.

Self-approval default

By default, a user who triggers a deploy can approve their own deploy if they appear in the required-reviewers list. This is the wrong default for any shop subject to separation-of-duties controls. The Prevent self-review toggle has lived on the environment settings page since October 2023, and turning it on is a one-click change with no workflow impact. Treat it as part of the environment template, not a per-repo decision.

Reviewer teams vs. individuals

The required-reviewers list is its own thing. It is not CODEOWNERS, and CODEOWNERS rules do not flow into it. List teams here, not individuals, so reviewer rotation lives in team membership rather than in environment settings spread across dozens of repos. If you list individuals, expect to be the person who edits environment settings every time someone moves teams.

Environment secrets are the real gate

The security argument for Environments is not the human pause; it is that the deploy credential is unreadable until the gate passes. A repo secret is decrypt-eligible by every workflow in the repo, including PR-triggered workflows on a label-bypassed reviewer. An environment secret is decrypt-eligible only by a job whose environment: target has passed its protection rules. If your production token is in a repo secret, your gate is a comment in YAML, not a real boundary.

When NOT to reach for this

Three cases where the right answer is not an Environment.

GitOps repos. When Argo CD or Flux owns the rollout, the GHA workflow only opens a PR or pushes a manifest. The deploy gate belongs in the CD controller, where the sync policy lives. Adding an Environment to the PR-opening workflow puts a human in the wrong place.

Cross-repo orchestration. When an orchestrator workflow fires child workflows in other repos, the gate belongs in the orchestrator. Gating both the orchestrator and the children leads to two approval prompts for one deploy, and reviewers stop reading them.

No deploy target. Housekeeping crons, dependency-update bots, and other jobs without a production endpoint do not need an environment. Adding one to satisfy a checklist is friction without a security gain.

GHEC, GHES, Free tier

Tier coverage is where most secondary write-ups are wrong. Verify against the GitHub Plans page at publication time, since wording shifts. Per current docs:

  • Free. Environments work on public repos, including protection rules. On private repos, the environment object exists, but required reviewers and wait timers are not available.
  • Pro and Team. Environments work on private repos. Required reviewers and wait timers are available on public repos only on these plans.
  • Enterprise. Required reviewers, wait timers, branch policies, and custom deployment protection rules work on private and internal repos.

If your production code lives in a private repo on Pro or Team, the only deployment protection rules you get are branch and tag policies. That is enough to keep a feature branch from requesting a prod deploy, but it is not the reviewer gate. For the human approval and audit row, you need Enterprise.

Custom deployment protection rules

When the source of truth for "can we deploy now" lives outside GitHub, you can register a GitHub App as a custom deployment protection rule. The app subscribes to the deployment_protection_rule webhook and calls back to the REST API to approve or reject the deployment. This is the integration point for a ServiceNow change window, a Datadog deploy gate, a Honeycomb SLO check, or a homegrown ticket system. The public-beta announcement is from April 2023; the current docs page no longer carries a beta banner, but check the linked docs before you build a compliance design on top of it.

The trade-off is honest: the external system becomes a deploy-blocking dependency, and the GitHub App needs the same ops attention as any other critical integration. Use it when the change-management system already exists and is the system of record. Do not stand one up just to feel sophisticated.

Closing

Build the artifact once from main, deploy it to staging automatically, and promote the same artifact to production behind a required reviewer. Put each deploy credential in its environment's secrets, not in repo secrets. Turn on Prevent self-review. Set a tag policy on the production environment so only release tags can promote. The boundary cases are clear: GitOps controllers own their own gate, orchestrators own their children, and housekeeping jobs need none of this. Everything else fits the default.

References

Related Posts