Build the mental model
A GitHub Actions environment is not a server. It is a named record in your repository that stands for a deploy target — staging, production, eu-prod — and it carries three things with it: its own secrets, its own variables, and its own protection rules. A job opts in with a single `environment:` key, and the moment it does, two things change in ways that are easy to under-appreciate.
The first is scoping. A secret defined on the production environment is injected only into jobs that declare `environment: production`. The staging job cannot read it — not because someone remembered to be careful, but because it is never placed in that job's context at all. This is the entire reason environments exist. Without them every credential sits at repository scope, which means the staging deploy job, and every step and every third-party action running inside it, holds production keys it has no business holding. Environments turn a policy — the staging job must not touch prod — into a mechanical guarantee that survives a careless copy-paste.
The second is gating. Protection rules — required reviewers, a wait timer, a deployment branch allowlist — are evaluated before the job starts. A job waiting for approval has not executed a single step, so nothing is half-deployed and no secret has been decrypted while it waits. Any one of the named reviewers can approve, and the run resumes on the same commit it was queued with.
The nuance people miss is that the gate sits on the job, not the workflow. If deploy and smoke tests live in one job, approval blocks both, and a reviewer is woken to authorise work a machine could have done unattended. Split them with `needs:` and reserve the human decision for the irreversible step.
APPROVAL GATE IN A DEPLOY PIPELINE
----------------------------------
push to main
|
v
+----------+ +-------------------+ +--------------------+
| build | --> | deploy-staging | --> | deploy-production |
| (no env) | | env: staging | | env: production |
+----------+ | secrets: staging | | secrets: prod only |
+-------------------+ +--------------------+
automatic ^
|
+------------------------+
| PROTECTION RULES |
| - required reviewers |
| - wait timer |
| - branch: main only |
+------------------------+
while waiting: job is QUEUED, not started
no step has run, no production secret has been decryptedConnect it to a real scenario
Create `staging` and `production` under Settings > Environments. On production, enable required reviewers, name two people, and restrict deployment branches to `main`. Then add a secret called `DEPLOY_TOKEN` separately under each environment with different values — do not keep a single copy at repository scope.
In the workflow, deploy-staging and deploy-production are separate jobs, and both write the identical text `${{ secrets.DEPLOY_TOKEN }}`. The value that actually arrives differs, because it is resolved from the environment the job declared. That is the point worth internalising: the same expression, a different credential, decided by the job's environment rather than by anyone's discipline.
Notice too that build is its own job and hands the compiled output over as an artifact. Production redeploys the exact artifact staging validated rather than rebuilding from source, which removes a whole class of works-on-staging-only surprises caused by a dependency resolving differently on a later run.
Now merge a pull request and watch. Build and staging complete unattended. The production job sits in a waiting state. If you are a named reviewer, approve it from the run page, then try the other path on a later run and reject one: the production job ends without executing a single step, so nothing was partially deployed and the production secret was never handed to a runner.
Try the working example
name: Deploy
on:
push:
branches:
- main
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Deploy the built artifact to staging
run: ./scripts/deploy.sh staging
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
smoke-staging:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Smoke test staging
run: ./scripts/smoke.sh https://staging.example.com
deploy-production:
needs: smoke-staging
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist
- name: Deploy the same artifact to production
run: ./scripts/deploy.sh production
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
On a push to main the build job runs and uploads dist as an artifact. deploy-staging then runs unattended using the staging environment's DEPLOY_TOKEN, and smoke-staging follows it. deploy-production does not start; it sits in a waiting state until one of the named reviewers approves, and only then does its first step execute. If a reviewer rejects, or the approval times out, that job ends without running any step and the production secret is never delivered to a runner. Whatever goes wrong inside the staging job, it cannot read the production environment's secrets, because they are not present in its context.5-minute try-it
Add a smoke-test step inside deploy-production and run the workflow: notice that approval now blocks that step too, even though a machine could have run it unattended. Then pull it back out into its own job wired with `needs:`. Next, add a five-minute wait timer to the production environment and observe when the job actually begins relative to the approval. Finally, temporarily point the staging job at a secret that only exists on production and confirm the deploy fails on an empty value rather than succeeding.
One important caution
Keeping the secret at repository scope and only naming an environment on the job — every job can still read it, so you have a label, not a boundary
Putting deploy in the same job as build, tests or smoke checks, so the human approval blocks work that never needed a human at all
GitHub Docs: Using environments for deployment — CI/CD with GitHub Actions