# Build once, deploy many

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*](https://www.informit.com/articles/article.aspx?p=1621865&seqNum=3) has a section called "Only Build Your Binaries Once", and [the Twelve-Factor App](https://12factor.net/build-release-run) 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:

   ```yaml title=".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](https://hushrun.com/docs/guides/promote-docker-image.md).
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:

```console
$ hushrun digest --rev develop
sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8  default
$ hushrun digest --rev main
sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8  default
```

## With hushrun

[Promotion](https://hushrun.com/docs/promotion.md) 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.

```yaml title="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](https://hushrun.com/docs/promotion/github.md) and [on GitLab CI](https://hushrun.com/docs/promotion/gitlab.md) set it up from there.

## Related

- [Promote a Docker image between environments](https://hushrun.com/docs/guides/promote-docker-image.md)
- [Why CI runs the same tests twice](https://hushrun.com/docs/guides/duplicate-ci-runs.md)
