Staging and Production Deployment Promotion

“Promotion” is the practice of moving one specific, already-built artifact through a sequence of environments — typically staging, then production — instead of rebuilding the software separately for each environment. The artifact that passes tests in staging is the exact same artifact that runs in production. This lesson covers how to model that flow with GitHub Environments, how to gate production behind human approval, and how to roll back safely when a promoted build misbehaves.

Overview: How Promotion Works

A promotion pipeline separates two concerns that beginners often merge: building software and deciding where a build is allowed to run. You build an artifact (a container image, a compiled binary, a static bundle) exactly once per commit, publish it to a registry, and then reference that same immutable artifact when deploying to staging and, later, to production. This is the build-once, deploy-many principle, and it matters because rebuilding per environment risks drift — a different base image layer, a dependency resolved slightly differently, or a compiler flag left out can mean the thing you tested in staging is not the thing you shipped.

It is also worth being precise about vocabulary here. Continuous integration (CI) is the practice of automatically building and testing every change. Continuous delivery means every change that passes CI is automatically prepared for release and can be deployed with a manual trigger or approval — a human still decides when production actually changes. Continuous deployment goes one step further and removes that human gate entirely, deploying automatically once checks pass. Staging-to-production promotion, as covered in this lesson, is a continuous delivery pattern: the pipeline does the work, but a required reviewer approves the step that changes production.

GitHub models this with Environments. An environment is a named deployment target (for example staging or production) that a job references with the environment: key. Environments can carry their own secrets, their own variables, and protection rules configured in the repository settings: required reviewers, a wait timer, and a branch or tag policy restricting which refs may deploy to them. A job that targets a protected environment pauses and waits for an approval from an authorized reviewer before its steps run.

Syntax and Workflow Structure

A promotion workflow generally has three kinds of jobs: one that builds and publishes the artifact once, one that deploys it to staging (usually automatic), and one that deploys the identical artifact to production (usually gated). The production job depends on the earlier ones with needs:, and it re-uses the digest produced by the build job rather than issuing a new build. Permissions should be scoped to only what each job needs: read access to repository contents, write access to the package registry from the build job, and id-token: write if you authenticate to a cloud provider or registry using OpenID Connect instead of a long-lived credential.

Two identifiers matter for an artifact: its tag and its digest. A tag such as myapp:latest or even myapp:1.4.0 is a mutable pointer — someone can push a new image under the same tag later, silently changing what that tag resolves to. A digest such as sha256:9f1c2a... is a content hash of the image itself; it can never point to different bytes. Promotion workflows should pass the digest between jobs and environments, not the tag, so that “deploy this to production” always means the exact bits that were validated in staging.

Identifier Mutable? Use for promotion?
Tag (e.g. :main, :1.4.0) Yes — can be reassigned No — convenient for humans, unsafe for automation
Digest (e.g. @sha256:...) No — content-addressed Yes — guarantees the same bytes run everywhere

Examples

Example 1: build once, deploy the same digest to staging then production. The workflow below builds and pushes an image, records its digest as a job output, deploys that digest to staging automatically, then deploys the same digest to production. The production environment has required reviewers configured in the repository settings (Settings, then Environments, then Production, then “Required reviewers”), so GitHub pauses the deploy-production job and notifies the reviewers instead of running it immediately.

name: Build, Stage, and Promote to Production

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image_digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - name: Deploy image by digest
        run: |
          echo "Deploying ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image_digest }} to staging"
          # ./deploy.sh staging "${{ needs.build.outputs.image_digest }}"

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Deploy the exact staging-tested digest
        run: |
          echo "Deploying ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image_digest }} to production"
          # ./deploy.sh production "${{ needs.build.outputs.image_digest }}"

Expected behavior: on every push to main, build runs first and publishes one image. deploy-staging runs immediately afterward with no gate. deploy-production starts, shows as “Waiting” in the Actions UI, and does not execute its steps until a reviewer approves it from the run page. Because both deploy jobs reference needs.build.outputs.image_digest, production always receives the identical artifact staging received — nothing gets rebuilt.

Example 2: add a health check and an automatic rollback. Deploying is not the end of the job; you need to confirm the new version is actually healthy before calling it done, and you need a way back if it is not.

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Record current digest for rollback
        run: echo "previous_digest=$(./current-digest.sh production)" >> "$GITHUB_ENV"
      - name: Deploy new digest
        run: ./deploy.sh production "${{ needs.build.outputs.image_digest }}"
      - name: Health check
        id: health
        run: |
          for i in 1 2 3 4 5; do
            if curl -sf https://app.example.com/healthz; then
              exit 0
            fi
            sleep 10
          done
          exit 1
      - name: Roll back on failed health check
        if: failure() && steps.health.outcome == 'failure'
        run: ./deploy.sh production "${{ env.previous_digest }}"

Expected behavior: after deploying, the job polls /healthz up to five times over roughly fifty seconds. If it never returns success, the health-check step fails, the rollback step runs (because its if: condition checks for a prior failure) and redeploys the digest that was running before this job started. The job as a whole still reports failed, which is correct — it should page the team even though the rollback succeeded, because a bad build almost reached users.

Example 3: promote a specific staging build later, decoupled from the original CI run. Some teams do not want production deploys tied to the moment a commit lands; instead a release manager verifies a build has soaked in staging for a day, then triggers promotion separately using the digest that was recorded when it was built.

name: Promote to Production

on:
  workflow_dispatch:
    inputs:
      image_digest:
        description: "Digest already validated in staging, e.g. sha256:abc123..."
        required: true

permissions:
  contents: read
  packages: read
  id-token: write

jobs:
  promote:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    steps:
      - name: Verify digest exists in the registry
        run: docker buildx imagetools inspect ghcr.io/${{ github.repository }}@${{ github.event.inputs.image_digest }}
      - name: Deploy verified digest
        run: ./deploy.sh production "${{ github.event.inputs.image_digest }}"

Expected behavior: the release manager opens the Actions tab, selects this workflow, and manually enters the digest that has already been running in staging. The workflow verifies the digest still exists in the registry before deploying it, and the production environment’s required reviewers still apply, so a second person must approve even a manually triggered promotion. Nothing is rebuilt; only an already-verified artifact moves forward.

Step by Step

  1. Create two environments in the repository: go to Settings, then Environments, and add staging and production.
  2. On the production environment, add required reviewers under protection rules, and restrict deployment to the main branch under deployment branch policies.
  3. Store environment-specific values as environment secrets and variables (for example a different DEPLOY_HOST per environment), not as repository-wide secrets shared by every job.
  4. Write a build job that publishes one artifact and exposes its digest as a job output.
  5. Write a deploy-staging job that references environment: staging and deploys that digest.
  6. Write a deploy-production job that references environment: production, depends on both prior jobs with needs:, and deploys the same digest — never a fresh build.
  7. Add a post-deploy health check step, and a rollback step gated on that check failing.
  8. Push a change to main and confirm in the Actions run that the production job pauses for approval and that the deployed digest matches the one staging received.

Common Mistakes

Mistake 1: rebuilding the image for production instead of reusing the staged artifact.

  deploy-production:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:prod

This builds a second, different image at deploy time. Even with an identical Dockerfile, a base image tag can resolve to a newer layer, a package index can serve a different version, or build cache can diverge — so production may not run what staging tested. Fix it by capturing the digest as a build-job output once, as in Example 1, and passing that same value into every downstream deploy job.

Mistake 2: no required reviewers and shared secrets between environments. A common early setup uses environment: production purely for the deployment history view, without configuring any protection rule, and reads a repository-level secret like secrets.DEPLOY_KEY that staging jobs can also read. That means any workflow run with write access can push to production the moment it reaches that job, with no human gate, and a compromised or misconfigured staging job can access production credentials. Fix it by adding required reviewers to the production environment in the repository settings, and storing DEPLOY_KEY (or better, an OIDC role for cloud authentication) as an environment-scoped secret attached only to production, so staging jobs cannot read it even if their YAML tries.

Best Practices

  • Deploy the same digest to every environment; never let a later stage rebuild from source.
  • Put required reviewers and a branch policy on the production environment; leave staging ungated so feedback stays fast.
  • Scope secrets per environment so staging credentials cannot reach production and vice versa.
  • Only deploy after tests and security scans have passed on the same commit — make the deploy jobs depend on the test jobs with needs:.
  • Always run a post-deploy health check and give the workflow an explicit rollback path to the previous known-good digest.
  • Never trigger a production-affecting job from pull_request or pull_request_target events on untrusted forks; those events can carry attacker-controlled code, and combining them with privileged secrets or a self-hosted runner is a known way to leak credentials.
  • Pin third-party actions to a stable tag or, for higher assurance, a full commit SHA; a tag can be moved by the action’s maintainer, while a SHA cannot, at the cost of manual updates when you want new behavior.
  • Set explicit minimal permissions: at the workflow or job level instead of relying on the default token scope.

Practice Exercises

  • Starting from Example 1, add a third environment named canary between staging and production that deploys the same digest to five percent of traffic before the full production job runs.
  • Configure a required reviewer and a five-minute wait timer on a production environment in a test repository, then trigger a workflow and observe the run pause in the Actions UI.
  • Extend Example 2 so the rollback step also posts a message to a notification step (do not hardcode a real webhook URL — use secrets.ALERT_WEBHOOK) when a rollback occurs.
  • Rewrite Example 3 so it fails clearly if the given image_digest input does not match the digest recorded for the most recent successful staging deployment.

Summary

Promotion is about trust and traceability: build one artifact, prove it in staging, and move that exact artifact — identified by its immutable digest, never a mutable tag — into production behind a human-approved, environment-scoped gate. GitHub Environments give you the protection rules and secret scoping to enforce that, needs: and job outputs let you thread the same digest through every stage, and a health check with a rollback step turns a bad promotion from an incident into a quick, automated recovery. Treat every workflow in this lesson as a template: adapt the registry, hostnames, health endpoint, and deployment script to your own infrastructure, and never hardcode real credentials into any of it.