Build Once, Deploy Many with GitHub Actions

One build job publishes the image and emits its digest, and the test and deploy jobs pull that digest. The YAML, the wiring table, and the cost math.

Last verified:

Build once, deploy many on GitHub Actions means one build job publishes a single artifact and emits its digest as a job output, and every job after it reads that digest through the needs context instead of running the build again. The pipeline then pays for one build per commit rather than one per environment, and staging and production run the exact bytes the test job exercised.

This guide covers the shapes that quietly rebuild per environment, the digest handoff that removes them, a workflow with a build job, a test job, and two deploy jobs all pinned to one digest, and a per-release cost and wall clock model with rates applied. It sits under Docker builds on GitHub Actions.

Diagnosis

A pipeline drawn as build once often rebuilds at the YAML level. Three shapes account for most of it, and each one puts the cost somewhere different.

ShapeWhat you see in the workflowWhat it costs
The deploy job buildsA docker build or build-push-action step inside deploy-staging and deploy-productionBuild minutes billed once per environment, and each environment ships bytes nothing tested
The deploy references a moving tagghcr.io/acme/app:main in a manifest, a Helm value, or a deploy scriptThe tag can resolve to a newer manifest between the staging apply and the production apply
Build arguments differ per environment--build-arg API_URL=... supplied separately in each deploy jobOne image per environment by construction, so there is nothing to promote

Confirm it from the registry

Ask the registry what each environment tag currently points at:

docker buildx imagetools inspect ghcr.io/acme/app:staging --format '{{.Manifest.Digest}}'
docker buildx imagetools inspect ghcr.io/acme/app:production --format '{{.Manifest.Digest}}'

Two different sha256: values for one release means two builds happened. The image digest is computed over the manifest, so any difference in a layer, in the config, or in the platform set produces a different value.

Confirm it from the bill

The Reports page settles the same question from the billing side. The Docker Builder billing table lists one row per session with profile, architecture, duration, and cost, so a single merge to main that produced three sessions tells you the pipeline built three times. The CI billing table lists per-job billed time by job name, which is where a deploy-production job carrying a hidden build step shows a duration close to the build job's rather than close to an apply.

A rebuild costs more than the minutes. A base image tag that moved overnight, a package index that resolved a new patch release, and a timestamp baked into a layer all change the output from identical source, so the second build produces a different image and the test result from the first build no longer describes it.

Fix

Publish the image once, then pass the digest forward.

Warpbuilds/build-push-action@v6 is a drop-in replacement for docker/build-push-action that runs the build on a WarpBuild builder profile (remote Docker builders documentation). It sets a digest output when the build pushes, and that output is the handoff. Promote it to a job output, and every downstream job addresses the image by content.

name: release
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    outputs:
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: build
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/acme/app:sha-${{ github.sha }}
          profile-name: platform-amd64
          timeout: 600000

  test:
    needs: [build]
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - name: Integration suite against the published image
        run: |
          docker run --rm -d --name app -p 8080:8080 \
            "ghcr.io/acme/app@${{ needs.build.outputs.digest }}"
          ./scripts/integration-test.sh http://localhost:8080

  deploy-staging:
    needs: [build, test]
    runs-on: warp-ubuntu-latest-x64-2x
    environment: staging
    concurrency:
      group: deploy-staging
      cancel-in-progress: false
    steps:
      - name: Apply
        run: |
          kubectl set image deployment/app \
            app="ghcr.io/acme/app@${{ needs.build.outputs.digest }}"
          kubectl rollout status deployment/app --timeout=5m

  deploy-production:
    needs: [build, deploy-staging]
    runs-on: warp-ubuntu-latest-x64-2x
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - name: Apply
        run: |
          kubectl set image deployment/app \
            app="ghcr.io/acme/app@${{ needs.build.outputs.digest }}"
          kubectl rollout status deployment/app --timeout=5m

Four things in that file carry the pattern.

The build job is the only job with a build step. Everything downstream reads needs.build.outputs.digest and pulls.

deploy-production lists build in needs even though its real ordering dependency is deploy-staging. The needs context exposes outputs only for the jobs named in that list, so dropping build leaves the expression empty and the deploy pulls ghcr.io/acme/app@.

The deploy jobs sit on warp-ubuntu-latest-x64-2x. A job that pulls a manifest and calls an apply spends its minutes on network and on waiting for a rollout, so cores buy nothing there. The build job takes the 8x label because that is where the compile happens. The rest of the deploy job shape, including environments, protection rules, and the OIDC credential exchange, is covered in deployment jobs on GitHub Actions.

The tag sha-${{ github.sha }} exists for retention rather than for addressing. Registry cleanup policies commonly delete untagged manifests, and a manifest that only a deploy record references is untagged the moment the next build takes the branch tag.

Configuration

The wiring, line by line

WhereLineWhy it is required
Push stepid: buildsteps.build.outputs.digest needs a step id to reference
Build joboutputs.digest: ${{ steps.build.outputs.digest }}Job outputs are the only channel between jobs
Push steppush: trueThe digest comes back from the registry push, so a build that stays local reports nothing
Downstream jobneeds: [build, ...]The needs context covers listed jobs only, and a transitive edge does not expose outputs
Every image referenceimage@sha256:...Content addressing, so the pull either serves those bytes or fails
Push stepA tag alongside the digestKeeps the manifest tagged so untagged-manifest cleanup leaves it in place

Guard against an empty digest

An empty output produces a reference ending in @, and the resulting registry error names a parse problem rather than the missing handoff. One step at the top of each deploy job makes the failure legible:

      - name: Require a digest from the build job
        env:
          DIGEST: ${{ needs.build.outputs.digest }}
        run: |
          case "$DIGEST" in
            sha256:*) echo "deploying $DIGEST" ;;
            *) echo "build job produced no digest: '$DIGEST'" >&2; exit 1 ;;
          esac

Multi-platform and multi-target deploys

A build that publishes several architectures produces an image index, and the digest output is the index digest. A client pulling it selects the manifest matching its own architecture, so one output serves an amd64 cluster and an arm64 cluster from the same string. The jobs consuming that digest can run wherever the deploy tooling for each target lives.

What a digest cannot absorb

Configuration baked at build time. If the image compiles an API hostname into a bundle, the staging image and the production image are different artifacts and no handoff makes them one. Move those values to environment variables or a mounted config read at container start, then the build arguments hold only what is identical everywhere.

The build itself can also move off the job runner entirely. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger, so the build job can shrink to a checkout while a builder profile executes the build and keeps the layer cache between runs.

Cost or Time Model

Everything below is arithmetic on stated assumptions rather than a measurement. Substitute the durations printed in your own job logs before deciding anything on it.

Assumptions:

  • 60 merges to main per month, each promoted through staging and then production.
  • A cold image build occupies a runner for 6.0 minutes.
  • The integration suite runs 4.0 minutes.
  • A deploy job that pulls by digest and applies takes 2.0 minutes.
  • Rates from the pricing page, checked on 2026-08-13: warp-ubuntu-latest-x64-8x at $0.016 per minute, warp-ubuntu-latest-x64-4x at $0.008, warp-ubuntu-latest-x64-2x at $0.004.
  • Durations are held constant across both options, so the only variable is how many times the build runs.

Rebuilding in each deploy job:

JobRunnerMinutesRateCost
buildwarp-ubuntu-latest-x64-8x6.0$0.016$0.096
testwarp-ubuntu-latest-x64-4x4.0$0.008$0.032
deploy-staging, build plus applywarp-ubuntu-latest-x64-8x8.0$0.016$0.128
deploy-production, build plus applywarp-ubuntu-latest-x64-8x8.0$0.016$0.128
Total per release26.0$0.384

Building once and deploying the digest:

JobRunnerMinutesRateCost
buildwarp-ubuntu-latest-x64-8x6.0$0.016$0.096
testwarp-ubuntu-latest-x64-4x4.0$0.008$0.032
deploy-stagingwarp-ubuntu-latest-x64-2x2.0$0.004$0.008
deploy-productionwarp-ubuntu-latest-x64-2x2.0$0.004$0.008
Total per release14.0$0.144

Per release that is $0.24 and 12.0 minutes of runner time removed, which comes to $14.40 and 720 runner minutes across 60 releases. The dollars stay small for one service, and the wall clock is the number to argue from: the four jobs run in sequence, so those 12.0 minutes come off the gap between a merge and a production rollout every single time, roughly 12 hours per month of pipeline latency for one service. A fleet of ten services multiplies both columns.

For a baseline on the build job, warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price. GitHub list price checked on 2026-08-13 against the GitHub Actions minute multipliers reference.

Running the build on a remote Docker builder changes which line grows. The build job drops to a small runner that checks out and waits while a builder profile does the work, and the profile bills separately at $0.06 per minute for the 16 vCPU, 32 GB size (remote Docker builders documentation, checked on 2026-08-13). Build-once matters more there: a rebuild inside a deploy job opens another builder session at that rate rather than reusing what the build job already paid for.

A pipeline that ships binaries rather than images follows the same rule with artifacts standing in for digests, which is the subject of release build workflows on GitHub Actions.

FAQ

How do I pass an image digest from the build job to the deploy jobs?

Give the push step an id, promote its digest output to a job output with outputs.digest set to steps.build.outputs.digest, and read needs.build.outputs.digest in every downstream job. Job outputs are the only channel between jobs on GitHub Actions, and the value arrives as a plain string such as sha256: followed by 64 hexadecimal characters.

Why is the digest empty in my production deploy job?

Two causes account for most of it. The needs list of the deploy job has to name the build job directly, because the needs context exposes only the jobs listed there and a transitive dependency through the staging job gives you nothing. The other cause is a build step that never pushed, since the digest output comes back from the registry push and a build with push: false has no manifest to report.

What if each environment needs different build arguments?

Then the pipeline builds one image per environment by construction, and the digest handoff cannot help until the configuration moves out of the image. Read values such as API hostnames from environment variables or a mounted config at container start, keep the build arguments limited to things that are identical everywhere, and the same digest becomes promotable across every target.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.