GitHub Actions Environments: Protection Rules, Approvals and Deployment History
An environment is a named target that applies protection rules (required reviewers, wait timers, branch/tag policies) before a job reaches a runner, exposes its secrets only to that job and keeps a deployment history. Bookstore's staging/production deploy workflow, plan limits for private repositories, deployment: false, per-environment concurrency and queue: max; where act and actionlint stop, with real output.
Version note: The examples in this post were run with act 0.2.97, actionlint 1.7.12, Docker 29.2, .NET 10, actions/checkout@v7 and actions/download-artifact@v8. Plan restrictions and new features were checked against the GitHub documentation as of November 2026.
Guess First
In Bookstore's deploy workflow, the staging job runs in the concurrency: deploy-staging group with cancel-in-progress off. Within three minutes a teammate pushes three commits to main: A, B and C. While A's deploy is running, B and C queue up. How many deploys reach staging in the end: three, two or one?
Second question: in the production job you mistyped the environment name as environment: prodcution. No such environment exists in the repository. Does the workflow fail, or does it deploy while skipping production's required reviewer rule?
What an Environment Is: A Name, Rules and Secrets
In the previous posts we built the CI (Continuous Integration) side: build, test, artifact. Now that artifact has to go somewhere, and "somewhere" is not a single place. Bookstore.Api goes to staging first, then to production. They have different addresses, different credentials and different people allowed to deploy. In GitHub Actions the concept that carries those differences is the environment: a named target defined in the repository settings. When a job binds to it with environment:, three things happen: the environment's protection rules are evaluated before the job is sent to a runner, the environment's secrets and variables are exposed to that job only, and GitHub creates a deployment record and writes it to the environment's history.
The deploy workflow we share across the series takes the api-publish artifact from post 07 to two environments:
name: deploy
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet publish api/src/Bookstore.Api -c Release -o out/api
- uses: actions/upload-artifact@v7
with:
name: api-publish
path: out/api
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ vars.APP_URL }}
concurrency:
group: deploy-staging
cancel-in-progress: false
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: api-publish
path: out/api
- name: Deploy
env:
APP_URL: ${{ vars.APP_URL }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy/deploy.sh staging out/api
deploy-production:
needs: deploy-staging
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment:
name: production
url: ${{ vars.APP_URL }}
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
name: api-publish
path: out/api
- name: Deploy
env:
APP_URL: ${{ vars.APP_URL }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy/deploy.sh production out/api
Notice that both deploy jobs use the same secret name (DEPLOY_TOKEN) and the same variable name (APP_URL). The values come from the environment: the staging job sees staging's token, the production job sees production's. There is no branching like if: env == 'prod' in the YAML (YAML Ain't Markup Language). The "same package, different configuration" principle holds here too: the artifact is built once and deployed twice. The deploy script is deliberately a placeholder in this post; we build the real targets, Azure App Service and our own Linux server, in the next two posts:
#!/usr/bin/env bash
# Placeholder deploy: real targets come in posts 10 and 11
set -euo pipefail
environment="$1"
package_dir="$2"
: "${DEPLOY_TOKEN:?DEPLOY_TOKEN is empty for $environment}"
files=$(find "$package_dir" -type f | wc -l | tr -d ' ')
echo "Deploying $files files to $environment at $APP_URL"
echo "Token length: ${#DEPLOY_TOKEN}"
The ${DEPLOY_TOKEN:?...} line is a small but valuable safety net: if the secret is empty, the script fails with a meaningful message. In a job where the environment secret is not defined, ${{ secrets.DEPLOY_TOKEN }} does not error, it returns an empty string; without this check the deploy tool would be called without a token and produce a much vaguer error.
Running Locally with act and Where It Stops
I first ran the workflow through actionlint (no errors), then simulated a push to main with act. act understands the environment key, but protection rules, approvals and deployment records do not exist in its world; it also takes secrets and variables from the command line as a single pool:
act push -W .github/workflows/deploy.yml \
--var APP_URL=https://staging.bookstore.example \
--secret DEPLOY_TOKEN=stg-0123456789
[deploy/build] ✅ Success - Main actions/upload-artifact@v4 [1.102363958s]
[deploy/build] 🏁 Job succeeded
[deploy/deploy-staging] ✅ Success - Main actions/download-artifact@v4 [365.754917ms]
[deploy/deploy-staging] | Deploying 13 files to staging at https://staging.bookstore.example
[deploy/deploy-staging] | Token length: 14
[deploy/deploy-staging] ✅ Success - Main Deploy [65.118958ms]
[deploy/deploy-staging] 🏁 Job succeeded
You probably noticed the artifact actions show up as @v4 in the output. act 0.2.97's built-in artifact server does not yet understand the newer requests of upload-artifact@v7 and download-artifact@v8 (v7 added a mime_type field to the CreateArtifact request and the server rejects it with unknown field "mime_type"; open issue #6022 in the act repository). For local runs I worked on a copy that rewrites those two lines to v4; the workflow file itself stays on v7/v8. On the main push the production job never started because of its if: condition.
Now let's try a tag push. I give act an event file whose ref field is refs/tags/v1.4.0, and this time I pass the production values:
act push -W .github/workflows/deploy.yml -e tag-event.json \
--var APP_URL=https://bookstore.example \
--secret DEPLOY_TOKEN=prod-abcdefghijklmnop
[deploy/deploy-staging] | Deploying 13 files to staging at https://bookstore.example
[deploy/deploy-staging] | Token length: 21
[deploy/deploy-staging] 🏁 Job succeeded
[deploy/deploy-production] | Deploying 13 files to production at https://bookstore.example
[deploy/deploy-production] | Token length: 21
[deploy/deploy-production] 🏁 Job succeeded
Look at the first line: the staging job received the production address and the production token. Since act has no environment scoping, both jobs were fed from the same pool. That does not happen on GitHub; but this output shows what an environment is really for: which value a secret with a given name resolves to is decided not by the workflow but by the environment the job binds to. The protection rule, approval and deployment history behavior in this post could not be run with act, so it relies on the GitHub documentation, and the workflows were not run on GitHub.
Protection Rules: Required Reviewers, Wait Timers and Branch Policies
The real power of an environment is in the rules applied before the job is sent to a runner. The documentation says it plainly: a job that references an environment is not sent to a runner until all deployment protection rules pass. So a job waiting for approval does not burn runner minutes.
Required reviewers: you pick up to six users or teams, and one approval is enough. If "Prevent self-review" is checked, the person who triggered the deploy cannot approve their own deploy; leaving it off for production turns the rule into a one-person formality. On a run waiting for approval, the reviewer approves or rejects with "Review deployments" in the Actions tab.
Wait timer: a delay of 1 to 43,200 minutes (30 days) after the job is triggered. The waiting time does not count toward billable time. Holding production for 15 minutes after the staging deploy is a cheap buffer for seeing staging's alerts.
Deployment branches and tags: there are three options. "No restriction", "Protected branches only" and "Selected branches and tags". With the last one you define name patterns separately for branches and tags; patterns match with Ruby's File.fnmatch rules and * does not cross /. In Bookstore, production can only be deployed from v* tags. The if: condition in the workflow is a convenience; the real guarantee is this policy, because anyone changing the YAML can delete the if:, but only a repository admin can change the environment settings.
Admin bypass: by default, repository admins can bypass the rules and force a deploy. If you uncheck "Allow administrators to bypass configured protection rules", admins have to wait as well.
If you would rather manage these settings in code than click through the UI, there is a REST (Representational State Transfer) API (Application Programming Interface). One call for production:
curl -L -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
https://api.github.com/repos/OWNER/bookstore/environments/production \
-d '{
"wait_timer": 15,
"prevent_self_review": true,
"reviewers": [{ "type": "Team", "id": 1234567 }],
"deployment_branch_policy": { "protected_branches": false, "custom_branch_policies": true }
}'
curl -L -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
https://api.github.com/repos/OWNER/bookstore/environments/production/deployment-branch-policies \
-d '{ "name": "v*", "type": "tag" }'
These calls were written against the body schema in the documentation and were not executed, to avoid using a token. protected_branches and custom_branch_policies must be opposites; set both to the same value and you get a 422. Adding a branch policy also requires custom_branch_policies: true first, otherwise you get a 404.
Plan Restrictions: What Private Repositories Get and Don't
You need to know this before you start experimenting with environments, because some options may simply not appear on the settings screen:
| Feature | Public repository | Private repository |
|---|---|---|
| Defining environments | All plans | Pro, Team, Enterprise |
| Deployment branch and tag policy | All plans | Pro, Team, Enterprise |
| Environment variables | All plans | Pro, Team, Enterprise |
| Environment secrets | All plans | Pro, Team, Enterprise (not on Free) |
| Required reviewers, wait timer, admin bypass | All plans | Enterprise only |
| Custom protection rules | All plans | Enterprise only |
The fifth row surprises people most: in a private repository of a company on the Team plan, you cannot define required reviewers for production. Your tools in that case are branch/tag policies and branches protected by rulesets (the Rulesets post in the Git & GitHub series). If a repository is switched from public to private, its protection rules and environment secrets are ignored; they come back when you make it public again.
Environment Secrets, Variables and Precedence
In post 08 we saw secrets at the repository and organization level. The environment is the third and narrowest level. If the same name exists at more than one level, the narrowest wins: environment overrides repository, repository overrides organization. In Bookstore, APP_URL and DEPLOY_TOKEN are defined only at the environment level, so there is no chance of "a value coming from the wrong level".
Two details matter. First, environment secrets are exposed only to jobs that reference that environment, and if approval is required the job cannot see them until approval arrives. A PR (pull request) workflow or a test job has no path to the production token. Second, if you use self-hosted runners, an environment secret gives you no extra isolation; for anyone with access to the runner machine it is no different from a repository secret. Variables are read through the vars context and are plain configuration, not secrets; they are not masked in logs.
Deployment History and the Environment URL
Every job that uses an environment creates a deployment record. The "Deployments" section on the repository home page and the /deployments page are fed from these records: which commit went to which environment, by whom, when, and which one is active now. When something breaks in production, the answer to "which SHA (Secure Hash Algorithm) went out last?" is one click away.
The url: field becomes the record's environment_url and shows up as a clickable link in the run summary and in the deployment box on the PR. The value can be an expression; if the deploy step produces the real address (for example a separate preview environment per PR), it is read from a step output: url: ${{ steps.deploy.outputs.app_url }}. The environment's name: also accepts expressions, but with fewer contexts: only github, inputs, vars, needs, strategy and matrix. That is why picking the environment from a workflow_dispatch input works.
Using an Environment Without Deploying: deployment: false
Not every use of an environment is a deploy. Bookstore has a job that checks production's health endpoint every morning with a token that belongs to production. The job needs a production secret, but if every run landed in the deployment history as "deployed to production", the history would become meaningless. deployment: false, which arrived in March 2026, is exactly for this:
name: prod-smoke
on:
schedule:
- cron: '0 6 * * *'
timezone: "Europe/Istanbul"
workflow_dispatch:
permissions:
contents: read
jobs:
smoke:
runs-on: ubuntu-latest
environment:
name: production
deployment: false
steps:
- name: Check health
env:
APP_URL: ${{ vars.APP_URL }}
SMOKE_TOKEN: ${{ secrets.SMOKE_TOKEN }}
run: |
curl --fail --silent --show-error \
-H "Authorization: Bearer $SMOKE_TOKEN" "$APP_URL/health"
The job uses production's secrets and variables, but no deployment record is created. Thanks to timezone, which came in the same changelog, cron can now be written with an IANA (Internet Assigned Numbers Authority) time zone name instead of UTC (Coordinated Universal Time); the job runs at 06:00 Istanbul time.
The restriction: deployment: false cannot be combined with a custom deployment protection rule; custom rules expect a deployment object. That is the only exception the documentation mentions; the rule that a job referencing an environment must pass all protection rules before reaching a runner still stands. So if reviewers are required for production, the morning smoke test will wait for approval too. In that case a separate, reviewer-free production-readonly environment with its own read-only token is the cleaner solution.
The tools have not caught up with this at the same pace. actionlint 1.7.12 accepts the file without complaint, while act 0.2.97 does not recognize the key in its schema and refuses to start the workflow at all:
Error: workflow is not valid. 'prod-smoke.yml': Line: 14 Column 5: Failed to match job-factory: Line: 16 Column 7: Failed to match string: Line: 16 Column 7: Expected a scalar got mapping
Line: 16 Column 7: Failed to match job-environment-mapping: Line: 17 Column 7: Unknown Property deployment
So this workflow was validated with actionlint only. If you want to try it locally, you have to remove the deployment: false line temporarily.
Preventing Overlapping Deploys: Concurrency per Environment
Back to the first question. In a concurrency group only one run executes at a time, and by default only one run can be pending. A newly arriving run cancels the pending one and takes its place. While A is running, B queues; when C arrives, B is canceled and C waits. Two deploys reach staging: A and C. Since B's code is contained in C, this is usually fine, even desirable: there is no point in sending every intermediate commit to staging one by one.
With cancel-in-progress: true, A would also have been cut off when C arrived. A good default for build and test, dangerous for deploys: a half-finished migration or file copy can leave the environment inconsistent. Write cancel-in-progress: false on deploy jobs, explicitly, even though it is the default; whoever reads the file should see that the decision was deliberate.
Tying the group name to the environment matters too. A workflow-level group like ${{ github.workflow }}-${{ github.ref }} does not stop two deploys from different branches from hitting the same staging in parallel. The group should be defined by the target: deploy-staging, deploy-production. Group names are case-insensitive; Prod and prod are the same group.
What if every deploy has to go out in order? Say you want every tag to have a record in production. Since May 2026, queue: max lets up to 100 runs wait in a group; once the queue is full the extras are canceled, and it cannot be combined with cancel-in-progress: true. actionlint 1.7.12 does not know this key yet:
concurrency:
group: deploy-production
queue: max
.github/workflows/deploy.yml:56:7: unexpected key "queue" for "concurrency" section. expected one of "cancel-in-progress", "group" [syntax-check]
act rejects the same key with Unknown Property queue. If you use it, you will have to skip that rule deliberately in your lint step or wait for a newer actionlint release.
In Production
Create the production environment before the workflow. If a workflow references an environment that does not exist, GitHub creates it itself, with no rules and no secrets. That is the answer to the second question: a new, unprotected environment called prodcution is born and the job runs without waiting. Since its secrets are empty, the deploy will most likely fail with a token error; but that is your luck, not your safeguard. Check environment names in PR review and catch empty secrets explicitly in deploy scripts.
Add reviewers as teams, not individuals. Production should not be locked while one person is on vacation. The six-reviewer limit counts a team as a single entry.
Pick the wait timer based on your alerting window. If staging's error rate metric is computed over a five-minute window, a five-minute wait timer sees nothing.
Use deployment history as a rollback tool. The SHA of the last successful production deployment points to the run you would re-run to go back. The rollback strategy itself is the topic of the upcoming Deployment Strategies series.
Think twice before deleting an environment. Deleting it also deletes its secrets and protection rules; jobs waiting on it fail automatically.
Common Pitfalls
Treating the workflow's if: condition as security. The guarantee that production is deployed only from tags is the environment's deployment tag policy; if: only reduces noise.
Looking for required reviewers on a private repository with the Team plan. That setting exists for private repositories only on Enterprise; if you cannot see it on the screen, what's missing is the plan, not a permission.
Writing a header in a one-line run:. In my first attempt I wrote the smoke test as a single line, run: curl ... -H "Authorization: Bearer $SMOKE_TOKEN" ...; actionlint said could not parse as YAML: mapping values are not allowed in this context. In an unquoted YAML value, the sequence : starts a new key. The fix is to write a block with run: |.
Trying to check a secret inside if:. if: secrets.DEPLOY_TOKEN != '' does not work; move the secret into the step's env: and check it in the script.
cancel-in-progress: true on a deploy job. What is right for builds means a half-deployed environment here.
Taking act output as evidence of environment behavior. In act the staging job happily read the production secret; a local run proves nothing about scoping or approvals.
A Small Experiment
★☆☆ Change the url: field of the deploy-staging job so it reads an app_url output that the deploy step writes to $GITHUB_OUTPUT. Run it with act and confirm the value shows up in the run summary.
★★☆ Add a target input of type: environment to workflow_dispatch and write a single deploy job with environment: ${{ inputs.target }}. Ask actionlint which contexts are allowed in name:: what does it say when you try secrets?
★★★ In your own public test repository, create the production environment through the REST API: prevent self-review on, one reviewer, a v* tag policy. Then trigger a manual production deploy from main and note the message the run is rejected with.
Environments solved the "who, where, when" question; but DEPLOY_TOKEN is still a long-lived secret stored somewhere. In the next post we remove that secret altogether: we log in to Azure without a client secret using OIDC (OpenID Connect) and deploy Bookstore to App Service with a staging slot and a swap.