Skip to content

Build once, deploy many

Updated

Build once, deploy many means you build one artifact, test it, and deploy that same artifact to every environment, instead of rebuilding for each one. What reaches production is then what you tested, byte for byte.

The idea is an old one. Humble and Farley's Continuous Delivery has a section called "Only Build Your Binaries Once", and the Twelve-Factor App keeps build, release and run as separate stages.

Why a rebuild is a risk

A rebuild from the same commit isn't guaranteed to give the same artifact. A floating base image, a dependency range or a build tool can change between two builds. Then:

  • production runs something staging never tested;
  • a failure in production can't be reproduced from staging's artifact;
  • you pay for the build, and usually the tests, a second time.

What it asks of your build

One artifact has to run in every environment, so it can't carry an environment's settings. Configuration moves from build time to run time: environment variables, secrets and config files.

The usual problem is a value the build bakes in. Next.js inlines NEXT_PUBLIC_* variables and Vite inlines VITE_* variables at build time, so an image built for staging keeps staging's values in production. Read those values at run time, or build one artifact per environment for that service and accept the difference.

How to do it

  1. Build once, on the first branch a change reaches, and push the image.

  2. Keep the digest. A tag can move; sha256:… names exact content. In GitHub Actions, docker/build-push-action gives it as an output:

    .github/workflows/deploy-staging.yml
    jobs:
      build:
        runs-on: ubuntu-latest
        outputs:
          digest: ${{ steps.build.outputs.digest }}
        steps:
          - uses: docker/build-push-action@v6
            id: build
            with:
              push: true
              tags: us-docker.pkg.dev/acme-staging/api/server:${{ github.sha }}
  3. Test that image, on staging, by its digest.

  4. Copy it, don't rebuild it. Move it to production's registry by digest: see Promote a Docker image between environments.

  5. Deploy by digest, never by a tag.

The hard part

The steps above work on one branch. Most teams have two: changes land on develop for staging, then on main for production, and the production workflow starts with a new commit and no link to what staging tested.

To reuse staging's image, production has to answer one question: did staging already validate these exact files? Comparing commits can't answer it, since a merge commit has a SHA of its own. Comparing the files can. hushrun hashes them into a source digest, the same for any commit with the same files:

$ hushrun digest --rev develop
sha256:bad90344…28f8  default
$ hushrun digest --rev main
sha256:bad90344…28f8  default

With hushrun

Promotion does this in the CI you already have. Each stage asks a planner what's left for this digest. When staging already built and validated it, production copies staging's image by digest and deploys it, with no second build or test run. Each step is recorded as a signed event, so you can check later what ran.

hushrun.yaml
version: 1
repository: github.com/acme/api

checks:
  lint: { kind: source, run: pnpm lint }
  unit: { kind: source, run: pnpm test, timeout: 20m }

artifacts:
  image:
    type: oci
    locations:
      staging: us-docker.pkg.dev/acme-staging/api/server
      production: us-docker.pkg.dev/acme-prod/api/server

stages:
  staging:
    branch: develop
    require: [lint, unit]
  production:
    branch: main
    after: staging
    require: [lint, unit]

Promotion on GitHub Actions and on GitLab CI set it up from there.

Markdown