Thuta Learning
AdvancedDevOps & Toolsbeginner

Deployment Environments and Approvals

What you'll walk away with

  • Explain the core ideas behind Deployment Environments and Approvals
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

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.

text
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 decrypted

Connect 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

yaml
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 }}
You should see
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 deploymentCI/CD with GitHub Actions

Easy traps

  • 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
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

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.

You'll know it worked when: 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.

Deployment Environments and Approvals | Thuta Learning