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

Build once, promote everywhere: artifacts and environments

Build the artifact exactly once and promote that same immutable, digest-pinned image through dev to staging to prod — never rebuild per environment. A rebuild from the same commit is not the same artifact; config is injected at runtime, and prod is gated behind approval.

CICD Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

Staging was green for three days. The release engineer kicked off the prod deploy, which — as the pipeline had always done — ran its own docker build from the very same commit SHA that staging had built. Same Dockerfile, same commit, same green tests. Prod fell over within ninety seconds. The postmortem found the difference: in the hours between the staging build and the prod build, a transitive dependency had published a new patch release, and the prod image resolved it where staging had pinned the older one. The two images were built from identical source and were not the same binary. “It passed staging” was a sentence about an artifact that no longer existed. The second twist of the knife came during rollback: the deploy targeted :latest, so rolling back “to the previous image” pulled a :latest that had since been re-pushed — not the previous image at all. This lesson is about the one discipline that makes both of those impossible: build the artifact once, and promote that exact artifact everywhere.

Build once, promote many

In ten minutes you’ll see exactly why the opening incident was inevitable — and why one line in your pipeline prevents it forever.

The principle is from the 12-factor build/release/run separation: a build turns source into one immutable artifact (a container image, a package), a release binds that artifact to a specific config, and run executes the release. The artifact is produced exactly once, then the same bytes move through environments — dev, then staging, then prod. You never run a second build for another environment.

The reason is the whole point of having a staging environment. If prod rebuilds, then the binary you tested in staging and the binary you ship to prod are two different builds — and “it passed staging” is now a claim about a different artifact. Promotion makes the guarantee literal: the bytes you tested are the bytes you run.

# WRONG — each environment rebuilds, so each runs a (potentially) different binary
# staging job
docker build -t app:staging .      # build #1
# prod job (hours later)
docker build -t app:prod .         # build #2 — NOT the same image as build #1
# RIGHT — build ONCE, capture the digest, promote that exact image everywhere
# build job (runs one time)
docker build -t registry/app:$GIT_SHA .
docker push registry/app:$GIT_SHA
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' registry/app:$GIT_SHA)
# DIGEST is now registry/app@sha256:abc123...  — promote THIS, never rebuild

Pin by digest, not by tag

When you write image: registry/app:latest in a deploy manifest, ask yourself: what, exactly, is that? The answer changes every time someone re-pushes the tag — and that mutability is the second failure in the opening story.

Promotion is only meaningful if “the same artifact” is unambiguous. A tag like :latest or even :v1.2.3 is a mutable pointer: it can be re-pushed to point at a different image at any time. :latest is the classic footgun — two deploys of :latest minutes apart can run two different images, and there is no record that anything changed. Even a semver tag can be force-pushed over.

The fix is to reference the artifact by its immutable content digestregistry/app@sha256:... — which is a hash of the image content itself. A digest cannot be re-pointed: sha256:abc is that image, byte for byte, forever. Deploy the digest, record the digest in the release, and a given release is reproducible and auditable down to the bit.

# Deploy manifest references the IMMUTABLE digest, not a floating tag
spec:
  containers:
    - name: app
      # NOT image: registry/app:latest   (mutable — can be re-pushed under you)
      # NOT image: registry/app:v1.2.3    (still a re-pushable tag)
      image: registry/app@sha256:abc123def456...   # immutable: this exact image, always

Config per environment, not artifact per environment

The same artifact runs in every environment; only the config differs. Database URLs, feature flags, secrets, and tenant settings are injected at runtime — environment variables, mounted secrets, config maps — not baked into the image. This is the “config in the environment” rule of 12-factor, and it is what makes one artifact serve all environments.

Baking environment config into the image breaks promotion two ways: it forces a rebuild per environment (because the prod image must differ from the staging image), and it leaks prod secrets into an artifact that lives in a registry. The same digest plus a per-environment config is the only shape that keeps the artifact immutable and the secrets out of it.

Why this works

Why is “rebuild from the same git commit for prod” not equivalent to promoting the staging artifact? Because a build is not a pure function of the source commit. Transitive dependency versions, base-image digests, build-tool versions, embedded timestamps, and network-fetched layers can all differ between two builds of the same commit minutes apart — exactly the drift that crashed prod in the opening story. So a rebuild can produce a different binary than the one you tested, defeating the entire purpose of staging. Promoting the exact artifact by digest ships the tested bytes; rebuilding ships a hopeful approximation that happened to share a commit hash.

Promotion and gates

The artifact moves through a gated path: dev auto-deploys on build, staging auto-deploys and runs integration tests, prod requires a manual approval. GitHub Actions models this with environments that carry required reviewers and wait timers — the prod environment simply won’t deploy until a named approver signs off. On approval, the pipeline promotes the already-tested artifact; it does not rebuild.

GitOps tools (Argo CD, Flux) make the promotion itself a git commit: a deploy is a change to the environment’s manifest that moves the pinned digest. That makes every promotion declarative, reviewable in a PR, auditable in git history, and revertible by reverting the commit — the rollback is git revert, which restores the exact previous digest, sidestepping the :latest rollback trap entirely.

Pick the best fit

You must move a tested release from staging to prod with confidence that prod runs the exact same code that passed staging. How do you ship it?

Quiz

Why build the artifact once and promote it through environments rather than rebuild it per environment?

Quiz

Why is deploying registry/app:latest dangerous, and what should you reference instead?

Recall before you leave
  1. 01
    State the build-once, promote-many principle and explain why a rebuild from the same git commit is not equivalent to promoting the tested artifact.
  2. 02
    Explain digest pinning versus tags, where environment config lives, and how prod gets gated in a build-once promotion pipeline.
Recap

The discipline is build once, promote many: a build turns source into one immutable artifact exactly once, and that same artifact — the same bytes — is promoted through dev, staging, then prod, never rebuilt per environment. Rebuilding is the trap, because a build is not a pure function of the commit: transitive dependency patches, base-image digests, and build-tool versions can differ between two builds of the same SHA, so a prod rebuild can ship a binary staging never tested even though it shares a commit hash. Promotion is only meaningful if the artifact is unambiguous, so you pin by immutable content digest (registry/app@sha256:…) rather than a mutable tag like :latest or :v1.2.3, which can be re-pushed to point at a different image and silently breaks both deploys and rollbacks. The same artifact serves every environment because config — database URLs, flags, secrets — is injected at runtime (12-factor config in the environment), never baked into the image, which would force per-environment rebuilds and leak secrets into the registry. The artifact moves through a gated path: dev auto-deploys, staging auto-deploys and runs integration tests, and prod sits behind a manual approval (GitHub Actions environments with required reviewers and wait timers) that promotes the already-tested digest rather than rebuilding. GitOps makes each promotion a git commit that moves the pinned digest in the environment manifest, so every deploy is declarative, reviewable, auditable, and revertible by reverting the commit — which restores the exact previous digest and sidesteps the :latest rollback trap entirely. Now when you see a pipeline with two docker build steps — one for staging, one for prod — you’ll know why “it passed staging” is no longer a guarantee, and exactly which single build step to remove.

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
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.