Skip to content

GitHub Actions Environments

Lesson 10 of 11Intermediate4 min readGitHub Actions & CI/CD · Advanced ActionsVerified: GitHub environments and protection rules, August 2026

An environment is a named deployment target with rules attached. Adding one line to a job changes who can trigger it, which secrets it can read, and whether it runs at all without a human saying yes.

Crucially, those rules are enforced by GitHub, not by the workflow file. A contributor who can edit the workflow cannot edit the environment’s protection rules.

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- run: ./deploy.sh

Or the short form when you need no URL:

environment: production

The environment does not need to exist first — referencing it creates it. That is convenient and it is also the trap: a typo creates prodution with no protection rules at all, and the job sails straight through. Create environments deliberately in Settings → Environments and treat a new one appearing there as something to investigate.

Required reviewers. The job pauses before its first step and waits for approval. Reviewers are named users or teams; the run shows who can approve, and the deployment record shows who did.

Wait timer. A fixed delay before the job starts, giving a window to cancel. Useful in front of a production deployment that follows an automated staging one.

Deployment branches and tags. Restricts which refs may deploy to this environment. This is the rule that stops a feature branch from deploying to production — including one that was force-pushed into a workflow file with the environment name removed, because the restriction lives on the environment.

jobs:
deploy:
environment: production
steps:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

What it doesScopes a secret so only jobs targeting that environment can read it.

Why we run itA repository secret is readable by every workflow in the repository. An environment secret is readable only by a job that declared the environment — and therefore only after the environment's protection rules have been satisfied.

Expected result`secrets.DEPLOY_KEY` resolves in the deploy job and is empty in every other job.

The resolution order when a name exists at several scopes is environment → repository → organisation. Most specific wins.

This is the strongest use of environments. A production credential stored as an environment secret, behind a required reviewer, cannot be read by a pull request workflow, by a scheduled job, or by any job that did not declare the environment — regardless of what the workflow file says.

production and staging environments holding same-named secrets with different values is the standard pattern, and it means the workflow needs no conditionals to pick the right credential.

The same scoping applies to non-secret configuration through vars:

- run: ./deploy.sh --bucket "${BUCKET}" --region "${REGION}"
env:
BUCKET: ${{ vars.DEPLOY_BUCKET }}
REGION: ${{ vars.AWS_REGION }}

Use vars for anything that is configuration rather than credential — bucket names, cluster names, role ARNs, subscription IDs. They are visible in logs, which makes debugging a deployment much easier than when everything is masked. See environment variables.

environment:
name: production
url: https://example.com

The URL appears on the run, on the pull request, and in the repository’s Environments panel, giving a one-click path from a commit to the running result. It can be dynamic:

environment:
name: preview
url: ${{ steps.deploy.outputs.preview-url }}

Referencing a step output here works because the URL is resolved after the job’s steps run.

Every job that declares an environment creates a deployment record: what was deployed, from which commit, by whom, when, and whether it succeeded. The Environments panel shows the current deployment per environment and the history behind it.

This is worth more than it first appears. “What is in production right now, and who put it there” is a question that otherwise gets answered by reading workflow logs, and during an incident that is minutes you do not have.

concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false

Two deployments to one environment at once will interleave. Group by environment — not by ref, if several refs can deploy to the same place:

concurrency:
group: deploy-production
cancel-in-progress: false

cancel-in-progress: false queues rather than cancels. Cancelling a deployment part-way leaves the target in a state neither version describes; see deploying to AWS.

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build
- uses: actions/upload-artifact@v7
with:
name: dist
path: ./dist
if-no-files-found: error
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/download-artifact@v8
with:
name: dist
path: ./dist
- run: ./deploy.sh ./dist
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/download-artifact@v8
with:
name: dist
path: ./dist
- run: ./deploy.sh ./dist

Both deployments consume the same artifact. Rebuilding between staging and production would mean production runs bytes staging never validated — the single most common way a “we tested it in staging” claim turns out to be false.

staging has no required reviewers, so it deploys automatically. production does, so the run pauses. The workflow file expresses the sequence; the environments express the policy.

  1. Create staging and production environments. Add yourself as a required reviewer on production only.

  2. Store the same secret name in both, with different values. Confirm each job reads its own.

  3. Build the staged pipeline above. Confirm staging deploys automatically and production waits.

  4. Approve, then look at the Environments panel and read the deployment history.

  5. Restrict production to the main branch. Try to deploy from a feature branch and read the failure — note that editing the workflow file cannot bypass it.

  6. Add a job without an environment: that tries to read the production secret. Confirm it is empty.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Want production-ready workflow templates? The Professional Toolkit has five, with permissions set correctly.