Handbooks / CI/CD & DevOps / Chapter 4
Docker Images as Build Artifacts
7 pages · ~5 min✓ Reviewed
Builds on Branching Strategies. Next up: Configuration and Environment Parity.
Build Once, Deploy Many: The Mental Model
1. The Rule
- CI builds the image once; every environment runs that exact artifact
- Rebuilding per environment gives different bytes for the same commit
- So staging proves nothing about prod
- Differences live in runtime config, never baked into the image
| Rebuild per env | Build once | |
|---|---|---|
| Bytes | differ per build | identical |
| Staging proves | nothing about prod | prod's exact image |
2. Image Identity
- Image = stack of immutable layers plus a manifest
- The manifest hash (digest) is its identity
- Same digest = same bytes everywhere
3. Pipeline and Config
- Buildonce
- Test
- Scan
- Pushregistry
- Promotedev → staging → prod
docker run -e APP_ENV=staging \
--env-file staging.env \
-v ./cfg:/etc/app myapp@sha256:<digest>same image; env vars, secrets, mounted config differ
Promote the same digest; change only the config.
Tags vs Immutable Digests
1. Tag vs digest
| Tag | Digest | |
|---|---|---|
| Looks like | myapp:1.4 | sha256:... |
| Points to | mutable pointer, can be re-pushed | hash of the manifest |
| Identity | a label | content-addressed, immutable |
2. Reference and tag
- Tag with the git SHA:
myapp:3f9c2ab - Avoid
:latestin any deployed manifest tag@digestpins the digest; the tag is only a label
docker pull myapp@sha256:<digest>
docker pull myapp:1.4@sha256:<digest>3. Read the digest, lock the tag
- Read the digest after push with either command
docker buildx imagetools inspect myapp:3f9c2ab
docker inspect --format '{{index .RepoDigests 0}}' myapp:3f9c2abMulti-Stage Builds and Layer Caching in CI
1. Multi-stage builds
FROM node:22 AS buildcompiles the app- Final
node:22-slimcopies only artifacts:COPY --from=build /app/dist ./dist - Compilers and dev dependencies stay behind: smaller image, smaller attack surface
FROM node:22 AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:22-slim COPY --from=build /app/dist ./dist
deps layer sits before the source layer
2. Layer order for cache hits
- Base image
COPY package*.json, then install depsCOPYsource last- A changed layer invalidates every layer after it, so slowest-changing steps go first
3. Cache in CI
- CI runners are ephemeral: the local cache is empty each run, so export it to a registry
docker buildx build \ --cache-from type=registry,ref=myapp:cache \ --cache-to type=registry,ref=myapp:cache,mode=max \ -t myapp:3f9c2ab --push .
| Option | Use |
|---|---|
type=registry |
Registries, Retention and Image Scanning
1. Registry and Retention
- Registry stores images by repository, tag and digest
- Examples: ECR, GHCR, Docker Hub, Harbor
- CI pushes with a short-lived credential, not a personal password
- Lifecycle policy expires untagged and old images
- ECR example: keep last 30, expire untagged after 7 days
Never expire a digest running in any environment, or rollbacks and pod restarts fail with ImagePullBackOff.
2. Scanning
trivy image --severity HIGH,CRITICAL \ --exit-code 1 myapp:3f9c2ab
CI gate: non-zero exit fails the build
| Where | When |
|---|---|
| Trivy or Grype in CI | before push or promotion |
| Registry scan (ECR basic or enhanced) | on push |
| Periodic rescan | new CVEs hit deployed images |
3. Trust
Promoting the Same Image Across Environments
1. Promote, Never Rebuild
- Devsha256:<digest>
- Stagingsame digest
- Prodsame digest
- Promotion moves the same digest forward
- A rebuild is a new image, so never rebuild
- Retagging adds a tag to the existing manifest: no layers copied
docker buildx imagetools create \ -t myapp:prod \ myapp@sha256:<digest>
2. Deploy by Digest, Promote by Git
- Manifests pin the digest, not a tag
- GitOps: a pipeline or PR bumps the digest in the env's manifest or Helm values
- Git history is the audit trail; rollback is a
git revert
containers:
- name: myapp
image: myapp@sha256:<digest>Kubernetes Deployment
3. Gates, Config, Rollback
| Concern | Rule |
|---|---|
| Gate | Tests, scan result and approval, judged on the digest to be deployed |
| Config | Env vars, ConfigMaps, Secrets or a secrets manager, injected at runtime |
Common Mistakes and Cheat Sheet
1. Four Mistakes
| Mistake | Result | Fix |
|---|---|---|
| Rebuild per environment | Prod runs an image no test saw | Build once, promote the digest |
Deploy :latest or a mutable tag | Nodes run different code | Deploy by digest, immutable tags |
| Secrets or env config in layers | Readable in image history | Inject at runtime |
| No registry cache in CI | Every run reinstalls deps | --cache-from / --cache-to |
Deleting a secret in a later layer still leaves it in image history. Rotate it, then inject at runtime.
docker buildx build -t myapp:3f9c2ab --push \ --cache-from type=registry,ref=myapp:cache \ --cache-to type=registry,ref=myapp:cache,mode=max .
CI runners are ephemeral: cache lives in the registry
2. Cheat Sheet
- Build once
- Tag git SHA
- Push
- Record digest
- Scan
- Deploy by digest
- Promoteretag or manifest bump
- Retain digests
Check yourself
CI pushes myapp:1.4 on Monday. A hotfix job pushes myapp:1.4 again on Tuesday. A prod pod restarts on a new node. What runs?
- The Tuesday image on the new node; older nodes still run Monday's
- Same tag, different code, because a tag is a mutable pointer
- Fix: deploy by digest, and turn on tag immutability
Why does a one-line README edit reinstall every dependency here?
COPY . .changes, so that layer and all later layers are invalidated, includingnpm ci- Fix: copy
package*.json, run the install, thenCOPY . .last
COPY . . RUN npm ci
A lifecycle policy keeps the last 30 images. Prod's digest is 45 images old. What happens on a pod restart?
- The digest was expired, so the pull fails with
ImagePullBackOff - Rollbacks break too
- Never expire digests deployed in any environment