Deployment Environments and Protection Rules
A GitHub Actions environment is a named deployment target, such as staging or production, that a job declares with the environment: key. Declaring an environment does two things: it records the run on the repository’s Deployments view, and it activates whatever protection rules are configured for that environment name in the repository settings. Protection rules are not written in YAML. They live under Settings > Environments and include required reviewers, a wait timer, and deployment branch or tag policies. This lesson covers how to configure those rules, how they interact with a workflow, and how to scope environment secrets so production credentials are never exposed to an unreviewed run.
Overview / How it works
Environments sit between your workflow YAML and your infrastructure. A job that references environment: production will not start its steps until every protection rule on the production environment is satisfied. Required reviewers pause the job in a ‘Waiting’ state and notify the named people or team; the job resumes only after an approval, or is cancelled on rejection. A wait timer delays the job by a fixed number of minutes regardless of reviewers, which is useful for giving a canary release time to bake or giving an on-call engineer a window to cancel. A deployment branch or tag policy restricts which refs are even allowed to trigger a run against that environment, independent of who approves it.
Environments also scope secrets and variables. A secret created under an environment is only readable by a job that both references that environment name and has cleared its protection rules. This is what lets a single repository run continuous delivery and continuous deployment side by side: a job targeting staging can have no protection rules and deploy automatically on every merge to main, while a job targeting production pauses for a human approval before its steps, including any deploy commands, ever execute. The job steps do not need to change between the two; only the environment’s configuration does.
Syntax or workflow structure
At the job level, environment: accepts either a plain string or an object with name and an optional url:
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
The url is purely informational: it appears as a clickable link on the deployment record and on the workflow run summary, and it does not affect protection rules. Everything that actually gates the job, required reviewers, wait timer, and deployment branch policy, is configured in the repository under Settings > Environments > (environment name), or through the REST API. Once an environment exists, you can add secrets and variables scoped to it from the same settings page; they are referenced in YAML exactly like repository secrets, as ${{ secrets.NAME }}, but only resolve for jobs that declare that environment.
Examples
Example 1: an unprotected staging environment. This establishes the deployment record and URL without adding any approval gate, appropriate for a low-risk target.
name: Deploy to Staging
on:
push:
branches: [main]
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy application
run: ./scripts/deploy.sh staging
Expected behavior: every push to main deploys to staging immediately. The run shows up under the repository’s Deployments tab linked to the staging environment, but nothing pauses the job unless protection rules are later added to staging itself.
Example 2: a protected production environment. Assume the production environment has been configured in Settings with one or two required reviewers and a deployment branch policy limited to tags matching v*.*.*.
name: Deploy to Production
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
permissions:
contents: read
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy.sh production
- name: Health check
run: ./scripts/health-check.sh https://app.example.com/healthz
Expected behavior: pushing a tag runs test first. If tests pass, deploy enters a Waiting state because production requires review; the workflow run and email/notification show who needs to approve. Only after approval do DEPLOY_HOST and DEPLOY_TOKEN resolve and the deploy steps run. The concurrency group prevents a second production deploy from starting while one is in flight or waiting for approval.
Example 3: one workflow, multiple environments, with an automatic rollback path.
name: Multi-Environment Deploy
on:
workflow_dispatch:
inputs:
target:
description: Environment to deploy
required: true
type: choice
options:
- staging
- production
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: ${{ inputs.target }}
url: ${{ steps.set-url.outputs.url }}
steps:
- uses: actions/checkout@v4
- id: set-url
run: echo "url=https://${{ inputs.target }}.example.com" >> "$GITHUB_OUTPUT"
- name: Deploy
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: ./scripts/deploy.sh ${{ inputs.target }}
rollback:
if: failure()
needs: deploy
runs-on: ubuntu-latest
environment:
name: ${{ inputs.target }}
steps:
- uses: actions/checkout@v4
- name: Roll back to last known good release
run: ./scripts/rollback.sh ${{ inputs.target }}
Expected behavior: a person triggers the workflow and picks staging or production from a dropdown. The job’s environment name is set dynamically from that choice, so secrets.DEPLOY_HOST resolves to whichever environment’s own secret value, staging and production can point at entirely different hosts under the same secret name. Choosing production pauses for that environment’s reviewers; choosing staging does not, assuming staging has no protection rules. If the deploy job fails for either target, rollback runs automatically against the same environment.
Step by step
To configure protection rules for an existing environment:
- Open the repository, go to Settings > Environments, and select or create the environment (for example,
production). - Under Deployment protection rules, add required reviewers, individual users or a team, who must approve before jobs referencing this environment run.
- Optionally set a wait timer, in minutes, that delays the job even after approval.
- Under Deployment branches and tags, restrict which branches or tags are allowed to deploy, such as only the default branch or tags matching a release pattern.
- Under Environment secrets and Environment variables, add any values the deploy job needs; these are only visible to jobs that declare this environment name.
- In the workflow file, add
environment: { name: production, url: ... }to the job that performs the deploy, and reference secrets with${{ secrets.NAME }}as usual. - Trigger the workflow and confirm the job pauses in a Waiting state, and that approving it unlocks the remaining steps.
The same setup can be scripted with the GitHub CLI, which is useful for reproducing environment configuration across repositories:
gh api --method PUT repos/OWNER/REPO/environments/production \
-F "wait_timer=10" \
-F "reviewers[][type]=Team" \
-F "reviewers[][id]=123456"
gh secret set DEPLOY_HOST --env production
gh secret set DEPLOY_TOKEN --env production
gh variable set DEPLOY_REGION --env production --body "us-east-1"
Replace OWNER/REPO, the reviewer team ID, and the secret and variable values with your own; running gh secret set without a value prompts for it interactively rather than putting it on the command line.
Common Mistakes
Mistake 1: the environment exists in settings, but the job never references it. Protection rules only apply to jobs that declare the environment name. A job that deploys to production without an environment: key bypasses every reviewer, wait timer, and branch restriction configured for that environment, even though the environment itself is fully set up.
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh production
Correction: add the environment key so the job is actually gated.
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: ./scripts/deploy.sh production
Mistake 2: production credentials stored as repository secrets instead of environment secrets. Repository-level secrets are readable by any job in any workflow run in that repository, with no reviewer gate at all. If a production token is stored there ‘for convenience,’ any job, including a routine test job on a normal pull request, can read it.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run integration tests
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: npm run test:integration
This job has no environment: key, yet it still receives DEPLOY_TOKEN because the secret lives at the repository level. Correction: move the secret into the production environment’s secrets, remove the repository-level copy, and only read it from the job that actually declares that environment.
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./scripts/deploy.sh production
Now DEPLOY_TOKEN only resolves inside a run that has cleared production’s required reviewers.
Best Practices
- Keep the reviewer list for production small and specific; a large approver pool erodes the point of the gate.
- Pair required reviewers with a deployment branch or tag policy, so even an approved run can only originate from your release branch or tagged commits, not an arbitrary feature branch.
- Store all production-grade credentials as environment secrets, never repository secrets, so access always passes through the environment’s protection rules.
- Use a wait timer on production for automatic canary bake time even when no human review is required, or as a cooling-off period an on-call engineer can use to cancel.
- Set an explicit minimal
permissions:block, most deploy jobs only needcontents: readplus whatever registry or cloud permission the deploy step requires, rather than relying on default token scope. - Add a
concurrencygroup scoped to the environment so two deploys to the same target can never race each other. - Never trigger a job that reads production environment secrets from
pull_request_targetagainst untrusted fork code; a fork PR can alter the workflow or the code being deployed, and required reviewers protect the deploy gate but not arbitrary code execution inside the same job. - Include a health check step after every deploy and keep a rollback path, such as the dedicated job in Example 3, ready to run without needing a fresh design under pressure.
- Treat every example in this lesson as a template: adapt hostnames, scripts, and credential names to your own infrastructure, and never commit real hostnames or tokens into the workflow file.
Practice Exercises
- Create a
stagingand aproductionenvironment in a test repository. Add a required reviewer only toproduction, then write a workflow with two jobs, one deploying to each, triggered by the same push event. Confirm staging deploys immediately and production waits for approval. - Add a deployment branch policy to
productionthat only allows tags matchingv*.*.*. Push a commit directly tomainand confirm a job referencingenvironment: productionis blocked before it even reaches the reviewer stage, then push a matching tag and confirm it proceeds to the Waiting state. - Take the multi-environment workflow from Example 3, move
DEPLOY_HOSTfrom a repository secret to separatestagingandproductionenvironment secrets with different values, and run the workflow against each target to confirm the deploy step receives the correct host for the chosen environment.
Summary
Environments turn a deployment target into a first-class, protectable object: the environment: key on a job links it to reviewer requirements, wait timers, and branch or tag policies configured outside the YAML, and to secrets that only resolve once those rules are satisfied. The same workflow file can express continuous delivery for a low-risk target and continuous deployment, or gated continuous delivery, for production simply by how that environment is configured, without duplicating job logic. The two failure modes to watch for are a deploy job that forgets to declare its environment, silently skipping every protection rule, and credentials stored at the repository level where any job can read them regardless of environment gates.
