Open a deploy-staging job and a deploy-prod job in the same pipeline and, often, you find two docker build steps that look identical. Same Dockerfile, same repo checkout, one line apart in the YAML. It reads like consistency. It is actually the opposite: the pipeline builds the image twice, once per environment, and nothing guarantees the two builds produce the same bits. What passed the staging test suite and what runs in production are, technically, two different artifacts that happen to share a Dockerfile.

What “build once” actually means

The fix is not a tooling purchase, it is a sequencing change: build the image exactly once, store it, and move that same artifact through environments by re-labeling it, never by rebuilding it from source again. In OCI terms, an image is addressed by its content digest, a SHA-256 hash of its manifest. Two builds from identical source can still land on different digests, because a package resolver picked a newer patch release, a base image moved under a floating tag, or a timestamp got baked into a layer. The digest is the only honest answer to “is this the same image”: if the digest matches, it is provably the same bytes; if it does not, no amount of Dockerfile similarity matters.

Promotion, then, is not a rebuild. It is copying or re-tagging the same digest into the next registry namespace:

# .github/workflows/build.yml — build once, capture the digest
- name: Build and push
  id: build
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: registry.example.com/app:${{ github.sha }}

- name: Record digest for later promotion
  run: echo "digest=${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT"
# promote.sh — runs at deploy-staging time, then again, unchanged, at deploy-prod time
DIGEST="sha256:1e9c...a41f"
crane copy registry.example.com/app@"$DIGEST" registry.example.com/app:staging
# later, same digest, no new build step:
crane copy registry.example.com/app@"$DIGEST" registry.example.com/app:prod

docker buildx imagetools create does the same job if you would rather stay in the Docker CLI. Either way, the deploy manifests for every environment reference a digest, or at minimum a commit-SHA tag that was never overwritten, never :latest.

The trap: a rebuild that silently changes what ships

The failure mode that build-once prevents is quiet by nature: nothing errors, the second build just produces something slightly different from the first. Two common causes:

  • A floating base image. FROM node:22 resolves to whatever node:22 points to on the registry today. Rebuild the same Dockerfile a week later and you get a different base layer, with its own patched libraries and, occasionally, its own regressions.
  • Unlocked dependency resolution. A build step that runs npm install instead of npm ci, or pip install -r requirements.txt without hashes, re-resolves the dependency graph at build time. A patch release published between the staging build and the prod rebuild changes what actually lands in the image, invisibly.

Pin the base image by digest (FROM node:22@sha256:...), commit the lockfile, and install from it exactly (npm ci, pip install --require-hashes). That closes the gap between “the Dockerfile is the same” and “the image is the same,” which is the distinction build-once actually depends on.

The test that proves it

The claim “we deploy what we tested” is falsifiable in one command. Compare the digest running in production against the digest that passed CI:

kubectl get pods -n prod -l app=my-app \
  -o jsonpath='{.items[0].status.containerStatuses[0].imageID}'

If that digest is not the exact one your test suite exercised, the pipeline is not doing what its name implies, no matter how green the checks look.

Environment-specific behavior still has to go somewhere; it just cannot go into the build. Configuration, feature toggles and secrets are read at container start, from environment variables, mounted config, or a config service, never baked in at build time. That separation is also what makes Infrastructure as Code coherent across environments: the same artifact, described by the same manifests, differing only in the values injected around it.

What to check in your own pipeline

Grep your workflow files for how many times docker build (or an equivalent) appears per release. Once is the answer. If it is more than once, everything downstream of the first build, every test, every scan, every sign-off, is validating an artifact that a later stage will not actually ship. This is exactly the kind of gap that CI/CD pipeline design is supposed to close, and it is one of the first things worth auditing when industrializing a CI/CD chain: before touching runners or caching, check whether the pipeline builds once or lies about it.