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.
Declaring one
Section titled “Declaring one”jobs: deploy: runs-on: ubuntu-latest environment: name: production url: https://example.com steps: - run: ./deploy.shOr the short form when you need no URL:
environment: productionThe 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.
What the rules do
Section titled “What the rules do”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.
Environment secrets
Section titled “Environment secrets”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.
Environment variables
Section titled “Environment variables”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.
The deployment URL
Section titled “The deployment URL” environment: name: production url: https://example.comThe 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.
Deployment history
Section titled “Deployment history”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 belongs with the environment
Section titled “Concurrency belongs with the environment”concurrency: group: deploy-${{ github.ref }} cancel-in-progress: falseTwo 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: falsecancel-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.
A staged pipeline
Section titled “A staged pipeline”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 ./distBoth 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.
Exercise
Section titled “Exercise”-
Create
stagingandproductionenvironments. Add yourself as a required reviewer onproductiononly. -
Store the same secret name in both, with different values. Confirm each job reads its own.
-
Build the staged pipeline above. Confirm staging deploys automatically and production waits.
-
Approve, then look at the Environments panel and read the deployment history.
-
Restrict
productionto themainbranch. Try to deploy from a feature branch and read the failure — note that editing the workflow file cannot bypass it. -
Add a job without an
environment:that tries to read the production secret. Confirm it is empty.