This is the fifth and final post in our GitHub and GitHub Actions series, building on Actions basics, reusable workflows, and securing Actions.
Deploying to staging and production is, in most real applications, close to the same process with different targets and different credentials — which makes it exactly the kind of thing that should be one parameterized workflow, not two separate files that inevitably drift apart the moment someone updates one and forgets the other. GitHub Environments is the built-in feature for handling this correctly.
Environments: scoped secrets and optional approval gates
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
default: staging
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment || 'staging' }}
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
aws-region: eu-west-2
- run: aws s3 sync ./out s3://${{ secrets.S3_BUCKET }} --delete
- run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"The environment: key on the job is what makes this work. Configured in a repository's Settings → Environments, each environment (staging, production) gets its own set of secrets — meaning secrets.S3_BUCKET resolves to a genuinely different value depending on which environment the job is running against, from the exact same workflow file and the exact same secret name.
Production environments can also require manual approval before a job proceeds — configured once, in the environment's settings, not in the workflow file itself:
Environment protection rules:
✓ Required reviewers: platform-team
✓ Wait timer: 0 minutes
A deploy targeting production pauses and waits for an approval from a designated reviewer; a deploy targeting staging (with no such rule configured) proceeds immediately. Same workflow file, genuinely different behavior, entirely driven by which environment is targeted — exactly the outcome we want, achieved without introducing a second workflow file to maintain and keep in sync.
Branch-based environment selection
on:
push:
branches: [main, staging]
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
steps:
- uses: actions/checkout@v4
# ...An equally common pattern: environment selection driven by which branch triggered the run, rather than a manual choice — a push to staging deploys to the staging environment; a push to main deploys to production (potentially pausing for the approval gate configured above). This is the shape most teams converge on for a genuinely continuous deployment setup, where merging to a specific branch is the deploy trigger, with no separate manual step required for the common case.
Environment-specific configuration beyond secrets
- name: Build static site
env:
NEXT_PUBLIC_SITE_URL: ${{ vars.SITE_URL }}
NEXT_PUBLIC_API_URL: ${{ vars.API_URL }}
run: npm run buildBeyond secrets, GitHub Environments also support variables (vars.*) — non-sensitive, environment-specific configuration values, following the same scoping rules as secrets but without encryption, since they're not sensitive. This is the direct GitHub Actions equivalent of the environment-specific .tfvars files and inventory group variables covered earlier in this blog for Terraform and Ansible: the mechanism differs, the underlying pattern — configuration that genuinely differs per environment, kept separate from the logic that consumes it — is identical.
A realistic full pipeline shape
Bringing this together with concepts from earlier in this series: a real multi-environment pipeline typically combines a reusable workflow (from the third post) with per-environment secrets and variables, called once per environment:
jobs:
deploy-staging:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
secrets: inherit
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: production
secrets: inheritneeds: deploy-staging here expresses a genuinely common real deployment requirement: production only deploys after staging has succeeded — an automated gate, on top of whatever manual approval the production environment itself requires, ensuring a build that failed to even deploy to staging never gets a chance to reach production at all.
What to actually remember from this series
- GitHub Environments scope secrets and variables per environment, from a single workflow file — the fix for "staging and production deploys drift apart because they're two different files."
- Environment protection rules (required reviewers) add manual approval gates, configured once in repository settings, not scattered through workflow logic.
- Branch-based environment selection is the common pattern for genuinely continuous deployment — merging to a specific branch is the deploy trigger.
needs:between environment-scoped jobs expresses real deployment ordering — production only proceeding after staging has already succeeded.
That closes out our GitHub and GitHub Actions series — from Git conventions through Actions fundamentals, reusable workflows, security, and now multi-environment deployment. This is the exact pattern behind our own CI/CD case study — and exactly the kind of hands-on work we build our DevOps & Automation training around.
