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.
Wait timers and deployment branch policies
Beyond required reviewers, an environment can enforce two other protection rules worth knowing about:
Environment protection rules:
✓ Required reviewers: platform-team
✓ Wait timer: 10 minutes
✓ Deployment branches: only "main"
A wait timer delays a deployment automatically for a fixed period after it's triggered — useful as a built-in "cooling off" window, giving a team a chance to catch and cancel an accidental or premature deploy before it actually runs, without requiring someone to actively click approve. Deployment branch policies restrict which branches are even allowed to deploy to a given environment at all — configuring production to only accept deploys from main means a workflow accidentally (or maliciously) triggered from a feature branch is rejected before it ever reaches the deploy job, independent of anything the workflow file's own on: conditions say.
Rolling back a bad production deploy
Because the workflow is parameterized by environment, rolling back is the same mechanism as deploying forward — re-running the same workflow against the previous known-good commit:
gh workflow run deploy.yml --ref <previous-good-commit-sha> -f environment=productionThis re-triggers the exact same deploy job, targeting production, but checking out a specific prior commit instead of the current HEAD — going through the same approval gate and the same scoped credentials as any other production deploy, rather than requiring a separate, less-tested "rollback" workflow that might not have received the same scrutiny as the main deploy path.
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.
Recording a deployment in GitHub's own deployment history
Beyond the workflow logs themselves, GitHub tracks a structured deployment history per environment — worth using explicitly rather than relying purely on Actions run logs to answer "what's currently deployed to production, and when did it get there":
- name: Create GitHub deployment record
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: context.payload.deployment.id,
state: 'success',
environment_url: 'https://production.novuspark.com',
});This populates the "Environments" view in a repository's own GitHub UI — a running history of every deployment to every environment, each linked to the exact commit and workflow run that produced it, genuinely useful as a lightweight audit trail without standing up a separate deployment-tracking tool.
Handling secrets rotation without a deploy
An environment's secrets occasionally need rotating (a database password change, a credential rotation after a team member leaves) independent of any actual code deploy. Because secrets are configured at the environment level in repository settings rather than in the workflow file, rotating one doesn't require a code change or a new commit at all — the next workflow run against that environment automatically picks up the updated value, the same "the secret lives outside the code that references it" separation covered for Ansible Vault and Terraform variables throughout this blog.
Restricting who can approve production deployments
Required reviewers should reflect genuine separation of duties, not just "whoever happens to be online" — GitHub lets you restrict reviewers to a specific team, ensuring the person approving a production deploy is someone with actual accountability for that environment, the same least-privilege thinking applied to human approval steps rather than only to machine credentials.
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, wait timers, deployment branch policies) add real safeguards, 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.
- Rolling back reuses the same parameterized workflow against a prior commit, rather than a separate, less-tested rollback path.
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.
