open atlas
↑ Back to track
CI/CD pipelines CICD · 03 · 03

Build once, promote many: immutable artifacts

Build once, pin the artifact by its sha256 digest, and promote that exact digest through staging to prod with config injected at runtime. Rebuilding per environment ships different bytes — "works in staging, breaks in prod" with no diff to explain it.

CICD Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

Staging was green for a week. The same commit went to production on Friday and the service crash-looped on boot — a native module failing to load against a libc it had never seen in staging. There was no code change between the two; git diff was empty. The pipeline had simply run docker build again at deploy time, and in the hours between the two builds the node:22 base tag had been re-pushed with an OS patch. Staging ran one set of bytes, production ran a different set, and the only artifact that proved it was a digest nobody had bothered to compare. Twelve hours of incident bridge to discover the bug was that two “identical” builds were not identical at all.

One build, one identity: the digest is the artifact

In the next ten minutes you will understand why two builds of the same commit can produce a crash in production with an empty git diff — and exactly what to pin so it never happens again.

The 12-factor model splits a deploy into three stages: build transforms your code repo into an executable bundle at a specific commit; release combines that exact build with the deploy’s config; run launches a process from a release. The hard rule is directionality — you cannot mutate code at run time and have it flow back into the build. “A release cannot be mutated once it is created.” Each gets a unique ID so you always know precisely what is running.

Build-once-deploy-many is the operational consequence: the build stage runs exactly one time per change, and what it produces is an immutable artifact. For a container, that artifact’s true identity is not its tag — it is its content digest, a sha256 hash of the image manifest. A tag like :latest, :v1.4, or node:22 is a mutable pointer; it can be re-aimed at different content at any moment. A digest is content-addressable: sha256:9b2f… is computed from the bytes, so any change — even one byte — yields a different digest. Pulling by digest guarantees you get the exact image that was built and tested, on every node, in every environment. The digest is the artifact; the tag is just a sticky note on top of it.

Promotion, then, is not a rebuild. It is moving that one digest forward — staging validated sha256:9b2f…, so production runs sha256:9b2f…, the same bytes, unchanged. What does change between environments is config, and that is injected at run time.

Config is per-environment; the bytes are not

If staging and prod run the same artifact, where do the database URL, the API keys, the feature flags live? Not in the image. The 12-factor config factor is blunt: store config in the environment, keep a strict separation between config and code, and the litmus test is that you could open-source the build tomorrow without leaking a single credential. Config is everything that varies between deploys; the artifact is everything that does not.

release = immutable artifact (sha256 digest)  +  per-environment config (injected at runtime)

Concretely, the same digest is launched in each environment with config supplied as environment variables, a mounted ConfigMap, or secrets pulled from a vault — never baked into a layer. Bake DATABASE_URL=staging-db into the image and you have just created a staging-only artifact: you are now forced to rebuild for prod, which reintroduces exactly the drift this principle exists to kill.

Rebuild drift: the empty-diff outage

Here is the failure mode that costs the most incident hours, precisely because there is nothing to look at. When a pipeline rebuilds the artifact separately for each environment, every rebuild re-resolves whatever is floating: a moving base tag, an unpinned transitive dependency, a latest of some build tool, even transient registry state. Common base tags are re-pushed frequently — for OS security patches a tag like node:22 or python:3.12 resolves to a different digest this month than last month while the tag string never changes. So two builds from the same commit, hours apart, can embed different glibc/musl, a different OpenSSL, a different patch of the runtime. The binaries diverge with no source change to explain it. This is “works in staging, breaks in prod” in its purest, most maddening form.

Why this works

Why does an empty git diff mislead so badly? Because the diff only covers your code. A per-environment rebuild also pulls inputs you don’t version: the base image behind a floating tag, OS packages behind apt-get install with no pinned versions, and any dependency with a caret range that resolved to a newer patch. None of those show in your repo, so the artifacts differ while the source is byte-identical. Build-once removes the entire class of bug by never giving the second build a chance to resolve anything differently — there is no second build.

The contrast in pipeline shape:

# BAD — rebuild per environment, deploy a mutable tag
# staging job
docker build -t myapp:latest .          # resolves node:22 -> digest A
docker push myapp:latest
kubectl set image deploy/app app=myapp:latest   # staging pulls A
# production job, hours later
docker build -t myapp:latest .          # resolves node:22 -> digest B (base re-pushed!)
docker push myapp:latest                # :latest now points at B
kubectl set image deploy/app app=myapp:latest   # prod pulls B — different bytes, empty git diff

# GOOD — build once, capture the digest, promote it, config at runtime
docker buildx build --push -t registry/myapp:1.4.0 --metadata-file out.json .
DIGEST=$(jq -r '."containerimage.digest"' out.json)
# DIGEST = sha256:9b2f...  — the immutable identity, captured ONCE
kubectl set image deploy/app app=registry/myapp@$DIGEST -n staging   # staging runs the digest
# ...staging passes its gates...
kubectl set image deploy/app app=registry/myapp@$DIGEST -n prod      # prod runs the SAME digest
# config differs per env via env vars / ConfigMap / secrets — never rebaked into the image
QuestionRebuild-per-env + :latest (anti-pattern)Build-once + promote digest
How many times is the artifact built?Once per environment (2–4×)Exactly once, per change
What identifies the deploy?A mutable tag (:latest) — can point anywhereA sha256 digest — content-addressable, immutable
Are staging and prod bytes identical?Not guaranteed — floating base/deps re-resolveGuaranteed — same digest pulled everywhere
Where does config live?Often baked in → forces a per-env rebuildInjected at runtime (env vars / ConfigMap / secrets)
Failure signature”Works in staging, breaks in prod”, empty git diffWhat you tested is what you ship
Pick the best fit

Staging validated the build. You must move that exact artifact to production. Pick the promotion mechanism.

Quiz

Staging is green; the same commit crash-loops in prod. git diff between the two deploys is empty. What is the most likely root cause?

Quiz

Why is promoting by sha256 digest stronger than promoting by re-pointing the :latest tag?

Recall before you leave
  1. 01
    Explain rebuild drift end to end: how a per-environment rebuild from the same commit produces different binaries with an empty git diff.
  2. 02
    What does 'release = artifact + config' mean, and why must config be injected at runtime rather than baked into the image?
Recap

Build-once-deploy-many says the artifact is produced exactly one time per change and then promoted unchanged through every environment. Its real identity is not a tag — a tag like :latest or node:22 is a mutable pointer that can be re-aimed at any content — but its sha256 content digest, which is computed from the image bytes and is therefore immutable: pull by digest and you get exactly what was built and tested. Promotion is moving that one digest forward, so staging validates sha256:… and production runs the same sha256:…. Config is the part that varies between environments, so it lives outside the artifact and is injected at run time as env vars, a ConfigMap, or secrets — release = the immutable artifact + per-environment config, and baking config in would force a per-env rebuild. That rebuild is the whole danger: building separately for each environment re-resolves floating base tags (re-pushed for OS patches into new digests), unpinned OS packages, and caret-ranged deps, so two builds from the identical commit embed different bytes. The outage that follows — “works in staging, breaks in prod” with an empty git diff — has no source change to explain it, because the differing inputs were never in your repo. Build once, pin and promote the digest, inject config at runtime, and the bytes you tested are the bytes you ship. Now when you see an incident where staging was green but prod crashed — and git diff is empty — you know where to look: compare the two sha256 digests. If they differ, rebuild drift is the culprit, not your code.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.