Table of Contents#
- From Runtime Model to Build Pipeline
- BuildKit Is the Build Engine, Not Just a Flag
- Writing a Dockerfile That Caches Well
- Multi-Stage Builds as an Artifact Boundary
- Cache Mounts, Secrets, and Build-Time Data
- Tagging, Digests, and Promotion Workflows
- Supply-Chain Controls: SBOM, Provenance, Signing
- Multi-Architecture Builds with buildx
- A CI Build Pipeline End to End
- Choosing a Base Image
- Dockerfile Instruction Reference Notes
- Vulnerability Scanning in the Pipeline
- Linting and Build Reproducibility
- Registry Choices and Image Retention
- A Complete GitHub Actions Build Pipeline
- Rootless and Daemonless Build Alternatives
- Layer Count, Squashing, and Image Flattening
- Offline and Air-Gapped Image Delivery
- Build Context Sources and Private Dependencies
- Debugging a Failed or Suspicious Build
- Running Tests as a Build Stage
- Common Mistakes and Interview Traps
- Worked Practice Problems
- Summary and What's Next
From Runtime Model to Build Pipeline#
Part 1 treated the image as a given: something pulled from a registry and run. This chapter builds that artifact deliberately. The Dockerfile is the specification; BuildKit is the engine that turns it into layered, content-addressed, cacheable image content; the registry is where the result becomes shareable and auditable.
Treat a Dockerfile the way a release engineer treats a build manifest, not a shell script that happens to run in a container. Every instruction produces a layer with a content hash. Two builds with identical instructions and identical inputs should produce identical layers — that determinism is what makes caching, promotion, and rollback trustworthy. A Dockerfile that downloads "latest" packages, embeds a timestamp, or depends on unpinned network state breaks that guarantee quietly, and the failure only shows up as "it built differently on Tuesday."
From the Trenches: A team once "fixed" a flaky CI build by adding
apt-get update && apt-get install -y curlwith no version pin, reasoning that curl rarely changes. Six months later, a Debian security update changed a transitive dependency's default TLS behavior, and only the container build picked up the new package — the host toolchain used elsewhere in CI did not. The image passed CI and failed a production TLS handshake. The fix was pinning package versions and rebuilding on a schedule instead of relying on floating installs to "just work."
BuildKit Is the Build Engine, Not Just a Flag#
Modern Docker builds run on BuildKit by default; the legacy builder is effectively retired. BuildKit changes more than performance — it changes the execution model. It parses the Dockerfile into a build graph (a DAG of stages and instructions), resolves which nodes are independent, and can execute unrelated stages concurrently instead of strictly top to bottom. It also introduces first-class primitives — cache mounts, bind mounts, and secret mounts — that a naive docker build on the classic builder never had.
docker buildx version
docker buildx ls
docker build --progress=plain -t catalog-api:dev .docker buildx is the CLI front end for BuildKit. On recent Docker Engine releases it is the default builder, exposed transparently through docker build; docker buildx build gives access to the fuller feature set — multiple output types, multi-platform builds, and remote cache backends — that plain docker build does not always expose depending on driver.
Builder instances and drivers#
buildx builds run through a named builder instance backed by a driver. The default docker driver runs inside the existing Docker daemon and has real limitations: it cannot export multi-platform manifests to a local image store, and it cannot always store attestations without the containerd image store enabled. The docker-container driver runs BuildKit inside a dedicated container and unlocks multi-platform output, remote cache export, and attestations without daemon reconfiguration.
docker buildx create --name ci-builder --driver docker-container --use
docker buildx inspect --bootstrapFrom the Trenches: A pipeline that worked on an engineer's laptop failed in CI with a cryptic "multiple platforms feature is currently not supported" error. The laptop had implicitly created a
docker-containerbuilder at some point; the CI runner was still on the defaultdockerdriver. Standardizing on an explicitbuildx create --driver docker-containerstep in the pipeline — rather than trusting whatever driver happened to be active — removed the class of failure entirely.
Writing a Dockerfile That Caches Well#
BuildKit's cache is layer-keyed: each instruction's cache key depends on the instruction text and the content hashes of everything it reads. Reordering instructions so volatile inputs (source code) come after stable inputs (dependency manifests) means a source-only change does not invalidate the expensive dependency-install layer.
# syntax=docker/dockerfile:1
FROM node:20.17-bookworm-slim AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM base AS build
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run buildTwo details matter more than they look:
COPY package.json package-lock.json ./happens beforeCOPY . .. Editing application source will invalidate thebuildstage's later layers but not the dependency-install layer, because that layer's cache key never saw the changed files.- The
# syntax=docker/dockerfile:1directive pins the Dockerfile frontend. Without it, cache-mount and bind-mount syntax silently fail to parse on older BuildKit frontends. Pin a frontend version deliberately in a regulated pipeline instead of always floating to:1.
.dockerignore is part of caching, not just hygiene#
Every file in the build context is hashed by BuildKit to decide whether a COPY layer's cache is still valid. A .dockerignore that excludes .git, node_modules, build artifacts, and local .env files does two things: it shrinks the context sent to the daemon, and it prevents an unrelated local file (an editor swap file, a stale build output) from invalidating a cache that should have been a hit.
.git
node_modules
dist
*.log
.env*
| Choose this | When it is appropriate | Avoid it when |
|---|---|---|
| Copy manifest files first, then install, then copy source | Any language with a lockfile-driven dependency install (npm, pip, go.mod, Cargo) | The project has no separable dependency-install step |
Copy everything in one COPY . . | A tiny script with no dependency step at all | Any project where dependency install is the expensive part |
| Cache mount for the package manager cache | The package manager supports a persistent download/cache directory | The tool has no meaningful cache directory to reuse |
From the Trenches: A Python service's Dockerfile did
COPY . .beforepip install -r requirements.txt. Every commit — including documentation-only changes — triggered a full dependency reinstall in CI, adding four minutes to every pipeline run regardless of what actually changed. Splitting theCOPY requirements.txt .step out ahead of the source copy cut median build time by more than half with no other change.
Multi-Stage Builds as an Artifact Boundary#
A multi-stage Dockerfile uses one or more FROM ... AS <name> stages to build software, then copies only the finished artifact into a final, minimal runtime stage. This is the mechanism that keeps compilers, build tools, and source archives out of the image that actually runs in production.
# syntax=docker/dockerfile:1
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
COPY --from=build /out/api /usr/local/bin/api
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/api"]The build stage carries the entire Go toolchain and module cache. None of that reaches the final image — only the compiled binary crosses the COPY --from=build boundary. The runtime stage uses a distroless base with no shell, no package manager, and a non-root user baked in, which shrinks both image size and the attack surface available to anyone who gains code execution inside the container.
Independent stages build in parallel#
BuildKit's DAG scheduler builds stages that do not depend on each other concurrently. A Dockerfile with a separate FROM node:20 AS frontend-build and FROM golang:1.23 AS backend-build, both copied into a final stage, builds both toolchains at the same time rather than sequentially — a real wall-clock win in CI, not just a stylistic preference.
From the Trenches: An image audit found a "slim" production image was still 640MB because a single-stage Dockerfile shipped
gcc,make, and the full apt package cache alongside the compiled binary they were only needed to produce. Converting to a two-stage build with a distroless runtime target dropped the image to 24MB and removed an entire class of "there's a shell in prod, can an attacker use it" security review questions.
Cache Mounts, Secrets, and Build-Time Data#
Three BuildKit mount types solve three distinct problems, and conflating them is a common mistake.
| Mount type | Purpose | Persists in final image? |
|---|---|---|
type=cache | A reusable scratch directory for package manager downloads, cumulative across builds | No — never part of any layer |
type=bind | Temporarily attach a host or context path for one RUN instruction | No — only instruction output persists |
type=secret | Inject a credential (API token, private key) available only during the instruction | No — never written to a layer |
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm cidocker buildx build --secret id=npmrc,src=$HOME/.npmrc -t catalog-api:dev .Never use ARG or ENV to pass a credential into a build. Both are recorded in image history and inspectable with docker history or docker image inspect, even if a later instruction "removes" the file — the layer that contained it is still part of the image's content-addressed history. The type=secret mount is the only mechanism that keeps a credential out of the resulting layers entirely.
From the Trenches: A security scan flagged a production image because
docker history --no-truncrevealed a plaintext registry password from anARG NPM_TOKENpassed three build stages earlier — the team believed a laterRUN rmstep had removed it. It had removed the file from the final filesystem, not from the image's layer history. Every image built with that Dockerfile had to be treated as a credential leak and the token rotated.
Tagging, Digests, and Promotion Workflows#
Part 1 established that a tag is mutable and a digest is not. This chapter's concern is the workflow that produces both deliberately, instead of treating tagging as an afterthought.
A practical promotion scheme separates a build-time identifier from a deployment-time identifier:
docker buildx build \
--tag registry.example.com/catalog/api:2.6.0 \
--tag registry.example.com/catalog/api:git-3f9a2c1 \
--label org.opencontainers.image.revision=3f9a2c1 \
--label org.opencontainers.image.created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--push .
docker buildx imagetools inspect registry.example.com/catalog/api:2.6.0 --format '{{json .Manifest.Digest}}'2.6.0is the human-facing release tag that a change log or release note refers to.git-3f9a2c1ties the image directly to the commit that produced it, useful for bisecting a regression.org.opencontainers.image.*labels follow the OCI image spec's annotation keys, which most registry UIs and scanning tools already understand.- The digest returned by
imagetools inspectis what a deployment manifest should ultimately reference, not the tag.
A promotion pipeline, not a single build#
| Stage | What happens | Gate before promoting |
|---|---|---|
| Build | Compile, run unit tests, produce a candidate image with a commit-derived tag | Build and unit tests pass |
| Scan | Vulnerability scan and SBOM generation against the candidate digest | No unresolved critical findings |
| Stage | Deploy the candidate digest to a staging environment | Integration and smoke tests pass |
| Promote | Re-tag (not rebuild) the verified digest into the release tag and push | Approval recorded, digest unchanged since staging |
Re-tagging, not rebuilding, is the important discipline: the artifact that passed staging must be bit-for-bit the artifact that reaches production. Rebuilding "the same" Dockerfile a second time for the release tag reintroduces exactly the nondeterminism this chapter opened with — a floating base-image tag, a package mirror serving a newer patch version, or a timestamp baked into a layer can all produce a different digest the second time.
docker buildx imagetools create \
--tag registry.example.com/catalog/api:2.6.0-release \
registry.example.com/catalog/api@sha256:REPLACE_WITH_VERIFIED_DIGESTbuildx imagetools create copies an existing manifest under a new tag without rebuilding anything, which is exactly the operation a promotion step needs.
Supply-Chain Controls: SBOM, Provenance, Signing#
An image that reaches production should be able to answer three questions on demand: what software is inside it, how was it built, and can its authenticity be verified without trusting whoever is asking.
docker buildx build \
--sbom=true \
--provenance=mode=max \
--tag registry.example.com/catalog/api:2.6.0 \
--push .
docker buildx imagetools inspect registry.example.com/catalog/api:2.6.0 --format '{{ json .SBOM }}'
docker buildx imagetools inspect registry.example.com/catalog/api:2.6.0 --format '{{ json .Provenance }}'- SBOM (
--sbom=true) attaches a software bill of materials — every OS package and, depending on the generator, application dependency — as an attestation on the image index, inspectable without pulling the full image. - Provenance (
--provenance=mode=max) records how the image was built: the Dockerfile, build arguments, source materials, and builder identity.mode=minis the current BuildKit default when provenance is not explicitly disabled;mode=maxis the level a compliance-driven pipeline should request explicitly. - Attestations require an image index — the classic Docker image store cannot hold them. A
docker-container(or remote/Kubernetes) buildx driver supports attestations unconditionally; the defaultdockerdriver needs the containerd image store enabled first.
Signing what you built#
An SBOM and provenance record describe the artifact; a signature proves it has not been substituted since. Signing an image (with cosign or an equivalent) and verifying that signature as an admission-control gate closes the loop between "we built this and scanned it" and "this exact digest is what's running."
cosign sign --yes registry.example.com/catalog/api@sha256:REPLACE_WITH_BUILT_DIGEST
cosign verify --certificate-identity-regexp '.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
registry.example.com/catalog/api@sha256:REPLACE_WITH_BUILT_DIGEST| Control | Answers | Where it's checked |
|---|---|---|
| SBOM | What's inside this image? | Vulnerability scanning, license audit, incident triage |
| Provenance attestation | How and from what source was this built? | Build-integrity audits, "did this come from our CI" checks |
| Signature verification | Is this exact digest authentic and unmodified? | Admission control before a workload is scheduled |
From the Trenches: During a CVE response, a security team needed to know within the hour whether any running workload contained a vulnerable library version. Because every production image had an SBOM attestation generated at build time, the answer came from a registry query against stored SBOMs — no emergency re-scan of live containers, no guessing based on a base-image tag. Teams without that attestation had to pull and re-scan every candidate image live, under incident pressure, to answer the same question.
Multi-Architecture Builds with buildx#
Fleets increasingly mix linux/amd64 and linux/arm64 (Graviton, Apple Silicon CI runners, edge devices). buildx can produce a single manifest list that resolves to the correct architecture-specific image automatically at pull time.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag registry.example.com/catalog/api:2.6.0 \
--push .This requires the docker-container driver (or an equivalent that isn't the plain daemon-backed docker driver) and, for architectures that can't run natively on the build host, QEMU emulation registered via binfmt. Emulated builds are correct but noticeably slower than native cross-compilation; for compiled languages, cross-compiling inside the Dockerfile (as the Go example above already does with CGO_ENABLED=0) and copying architecture-specific binaries into per-platform stages is often faster than emulating the whole build.
From the Trenches: A team added Apple Silicon developer laptops to their build fleet and started seeing "exec format error" on containers deployed to
amd64production nodes, because a laptop had built and pushed anarm64-only image under a shared tag. Standardizing CI as the only path allowed to push release tags — with multi-platformbuildxbuilds — removed architecture mismatch as a class of incident.
A CI Build Pipeline End to End#
Putting the chapter's pieces together, a realistic pipeline stage looks like this:
docker buildx create --name ci --driver docker-container --use
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag registry.example.com/catalog/api:git-${CI_COMMIT_SHA:0:7} \
--tag registry.example.com/catalog/api:${CI_TAG} \
--label org.opencontainers.image.revision=${CI_COMMIT_SHA} \
--sbom=true \
--provenance=mode=max \
--cache-from type=registry,ref=registry.example.com/catalog/api:buildcache \
--cache-to type=registry,ref=registry.example.com/catalog/api:buildcache,mode=max \
--secret id=npmrc,src=$HOME/.npmrc \
--push .--cache-from/--cache-to type=registryshare the build cache across ephemeral CI runners — without it, every fresh runner starts with a cold cache and none of this chapter's cache-mount work helps in CI the way it helps locally.- The pipeline pushes once, with both a traceable commit tag and the release tag, and attaches SBOM and provenance in the same build.
- A downstream scan stage inspects the pushed digest's SBOM before the deployment stage is allowed to promote it.
Cache export modes#
| Mode | Behavior | Trade-off |
|---|---|---|
mode=min | Exports cache only for the final image layers | Smaller cache, misses intermediate build-stage cache |
mode=max | Exports cache for every layer BuildKit executed, including intermediate stages | Larger cache artifact, much better multi-stage CI cache hits |
mode=max is usually correct for a multi-stage Dockerfile built repeatedly in CI; mode=min is acceptable for a single-stage build where there is nothing intermediate worth caching.
Choosing a Base Image#
The final FROM in a multi-stage Dockerfile is a security and operability decision, not a style preference. Base images sit on a spectrum from "full OS with a shell and package manager" to "nothing but the application's own bytes."
| Base image family | What's inside | Best for | Trade-off |
|---|---|---|---|
debian:bookworm / ubuntu:24.04 | Full OS, shell, package manager, broad glibc compatibility | Applications needing native extensions, debugging convenience, unusual system dependencies | Largest surface area and CVE count of the options here |
-slim variants (e.g. python:3.13-slim) | Trimmed Debian with a shell and minimal packages | A middle ground when full OS compatibility is needed but image size matters | Still has a shell and package manager an attacker can use post-compromise |
Alpine (alpine:3.21) | musl libc, BusyBox, apk package manager, very small | Static or well-tested workloads where musl compatibility is confirmed | musl differs from glibc in subtle ways (DNS resolution, locale behavior) that can surface as hard-to-reproduce bugs |
Distroless (gcr.io/distroless/*) | Language runtime and certs only, no shell, no package manager | Compiled or JIT runtimes (Go, Java, Node with a distroless variant) where debugging happens externally | No shell for interactive troubleshooting inside the container |
| Chainguard / Wolfi-based images | Minimal glibc-compatible images, SBOM and signature attached by the vendor, rebuilt within hours of upstream CVE fixes | Security-sensitive production workloads wanting near-zero CVE base images with vendor-maintained patch velocity | Smaller long-tail package availability than Debian; may require adopting the vendor's build tooling for custom images |
scratch | Nothing — not even libc unless statically linked in | A fully static binary (Go with CGO_ENABLED=0, Rust with musl target) | Zero debugging surface; any missing runtime dependency (CA certs, timezone data) must be copied in explicitly |
From the Trenches: A service quietly worked on
alpinein every environment except one customer's air-gapped deployment, where DNS resolution to an internal resolver failed intermittently. The root cause was musl libc's stricter, non-glibc-compatible resolver behavior against a resolver configuration that glibc tolerated. Switching to a distroless glibc-based image resolved it without changing a line of application code — the lesson wasn't "avoid Alpine," it was "test DNS behavior against your actual resolver topology before standardizing on musl."
Base image choice also interacts directly with the vulnerability-scanning and SBOM work from earlier in this chapter: a base with 280 average CVEs makes every scan noisy and slows down triage, while a near-zero-CVE base keeps scan output meaningful enough that a new finding is actually worth investigating instead of being lost in a wall of pre-existing base-image noise.
Rebuilding on a schedule, not just on code change#
A pinned base-image digest is reproducible, but reproducibility has a downside if nothing ever revisits the pin: a security fix released upstream never reaches a production image until something triggers a rebuild, and "a developer happens to touch that Dockerfile" is not a schedule. Automated dependency-update tooling (Renovate, Dependabot) can open a pull request whenever a pinned base image's digest changes upstream, which turns "someone remembers to bump the base image" into a reviewable, recurring pipeline event instead of a manual chore that quietly stops happening.
{
"docker": {
"enabled": true
},
"packageRules": [
{ "matchDatasources": ["docker"], "schedule": ["before 6am on monday"] }
]
}A weekly base-image bump PR, gated by the same build-scan-sign pipeline as any other change, keeps the CVE count in the earlier comparison table meaningfully low over time instead of accurate only on the day the image was first built.
From the Trenches: A service pinned its base image digest for reproducibility and then never revisited it for over a year, treating the pin as a one-time decision rather than a maintained dependency. A routine audit found it was running a base image with dozens of unpatched high-severity CVEs, all fixed upstream months earlier — the pin had done exactly what it was designed to do, faithfully reproducing a stale, vulnerable artifact every single build. Reproducibility and staying current are not in tension; they just require an explicit, scheduled process to move the pin forward deliberately instead of leaving it frozen indefinitely.
Dockerfile Instruction Reference Notes#
A few instructions are consistently misused in ways that matter operationally, beyond what a syntax reference documents.
ARG vs ENV#
ARG values exist only at build time and are not present in the running container unless explicitly re-declared as ENV. ENV values persist into the container and are visible via docker inspect and printenv at runtime.
ARG BUILD_VERSION=dev
ENV APP_VERSION=${BUILD_VERSION}An ARG declared before the first FROM is available to use in FROM lines themselves (for parameterizing a base image tag) but does not automatically carry into any stage — each stage that needs it must redeclare ARG after its own FROM.
ARG NODE_VERSION=20.17
FROM node:${NODE_VERSION}-bookworm-slim AS base
ARG NODE_VERSION
RUN echo "built on Node ${NODE_VERSION}"USER, WORKDIR, and SHELL#
USER should appear before the container's main process starts, ideally as early as the runtime stage allows once file ownership is set correctly with COPY --chown. Running as root inside the final stage and only "dropping privileges" at docker run time via --user is weaker than baking a non-root user into the image, because any tooling that inspects the image directly (a scanner, an admission policy checking USER metadata) sees the image's own declared identity.
RUN addgroup --system app && adduser --system --ingroup app app
COPY --chown=app:app --from=build /out/api /usr/local/bin/api
USER appSHELL changes which shell RUN instructions with shell form use — useful for switching to ["/bin/bash", "-o", "pipefail", "-c"] so a piped command's failure (curl ... | tar ...) is not silently swallowed by the default shell form, which only checks the last command's exit code.
ONBUILD — usually the wrong tool now#
ONBUILD registers instructions that run only when another Dockerfile uses this image as its FROM. It made sense for framework base images distributed years ago; multi-stage builds solve the same "parameterize a common build pattern" problem more transparently, because the actual instructions that run are visible in the consuming Dockerfile instead of hidden in a base image's metadata. Avoid introducing new ONBUILD usage; treat existing ONBUILD base images as a legacy pattern to plan away from.
STOPSIGNAL and graceful shutdown#
Part 1 covered SIGTERM/SIGKILL at the runtime level. STOPSIGNAL lets an image declare a different default signal for applications that don't handle SIGTERM for graceful shutdown (some JVM configurations expect SIGINT, for example).
STOPSIGNAL SIGINTDeclaring this in the image is more reliable than expecting every docker run or orchestrator manifest to remember to override the stop signal per service.
Vulnerability Scanning in the Pipeline#
An SBOM (covered earlier in this chapter) is an inventory; a scanner is what turns that inventory into an actionable finding by matching it against known-vulnerability databases.
| Tool | Scope | Strength | Watch out for |
|---|---|---|---|
| Trivy | Images, filesystems, Git repos, IaC, Kubernetes clusters, SBOM, secrets | Broadest single-tool coverage; offline local vulnerability database updated daily | Broader scope adds modest overhead versus a vulnerability-only tool |
| Grype | Images and filesystems, vulnerability matching only | Fast, focused, pairs naturally with Syft-generated SBOMs | No IaC, secret, or cluster scanning — needs complementary tooling for those |
| Docker Scout | Images, native Docker Hub and Docker Desktop integration | Zero extra setup for teams already in the Docker ecosystem; base-image upgrade recommendations | Narrower scope than Trivy — no IaC, cluster, or repo scanning |
trivy image --severity CRITICAL,HIGH --exit-code 1 registry.example.com/catalog/api:2.6.0
grype registry.example.com/catalog/api:2.6.0 --fail-on critical
docker scout cves registry.example.com/catalog/api:2.6.0A practical default: run Trivy (or an equivalent broad scanner) as the primary CI gate with --exit-code 1 on critical/high findings, and treat a second scanner as a periodic cross-check on production-facing images rather than doubling scan time on every build. Feed scan results the same SBOM generated at build time where the tool supports it (trivy sbom accepts a CycloneDX or SPDX document directly) instead of re-deriving the dependency tree from the image a second time.
From the Trenches: A team's CI gate scanned images but only failed the build on findings with a CVSS score above a fixed threshold, with no allowlist mechanism. The first time a base-image CVE had no available fix yet, every single build across every service failed simultaneously, and the team disabled the gate under deadline pressure rather than triage it properly — after which it stayed disabled for months. A workable gate needs a time-boxed exception/allowlist path (a tracked, expiring waiver) for exactly this situation, not just a hard pass/fail threshold.
Linting and Build Reproducibility#
hadolint statically analyzes a Dockerfile against a rule set derived from real-world Dockerfile mistakes — unpinned base image tags, apt-get upgrade inside a build (nondeterministic and usually unintended), missing --no-install-recommends, and instruction ordering that defeats caching.
hadolint DockerfileRunning it as a pre-commit hook or an early CI stage catches most of this chapter's caching and security mistakes before a build even starts, at effectively zero cost.
Reproducibility beyond caching#
Cache-friendly ordering (covered earlier) makes rebuilds fast; reproducibility is a stronger property — that a rebuild from the same inputs produces an identical digest. Two practical levers:
- Pin base image digests, not just tags, in a pipeline where reproducibility is audited:
FROM node:20.17-bookworm-slim@sha256:...rather than the floating tag alone. - Avoid instructions that embed the current time or non-deterministic ordering — a build script that writes
dateoutput into a file that gets copied into the image, or afindinvocation whose output ordering depends on filesystem state, both break bit-for-bit reproducibility even when every declared input is pinned.
Full bit-for-bit reproducibility (matching upstream projects like Debian's "reproducible builds" effort) is a deep rabbit hole that few application teams need to fully solve; the pragmatic middle ground most teams should reach is "pinned inputs, deterministic instructions, and a promotion workflow that never rebuilds a promoted artifact" — the last part already covered under tagging and promotion above.
Registry Choices and Image Retention#
The registry is not just storage — it's the enforcement point for retention, access control, and (per the earlier section) attestation storage.
| Registry | Notable strength | Consideration |
|---|---|---|
| Amazon ECR | Deep IAM integration, native lifecycle policies, regional replication | Cross-account access requires explicit repository policy configuration |
| GitHub Container Registry (GHCR) | Tight integration with GitHub Actions and repository permissions | Retention/cleanup historically leaned on third-party Actions rather than a first-class policy UI |
| Google Artifact Registry | Native multi-format (container, Maven, npm) with fine-grained IAM | Regional pricing and quota behavior needs deliberate capacity planning at scale |
| Harbor (self-hosted) | Full control over retention, replication, vulnerability scanning integration, air-gapped operation | Operating the registry itself becomes the team's responsibility |
Every option needs an explicit retention policy — an unmanaged registry grows without bound as CI pushes a new tag on every commit. A typical policy: keep all digests referenced by a currently deployed manifest indefinitely, keep the last N commit-tagged builds per branch for a bounded window (for bisecting), and expire untagged/dangling manifests aggressively since nothing can reference them by tag.
aws ecr put-lifecycle-policy --repository-name catalog/api --lifecycle-policy-text file://ecr-lifecycle.jsonFrom the Trenches: A registry with no lifecycle policy grew to tens of thousands of tags over two years of daily CI builds, and a routine "list all images" API call in a deployment tool started timing out. The fix wasn't just adding a retention policy going forward — it required an audited one-time cleanup to confirm no active deployment manifest referenced a digest about to be pruned, since a naive "delete anything older than 90 days" policy can silently break a long-lived environment still pinned to an old digest.
A Complete GitHub Actions Build Pipeline#
Putting every control from this chapter into one concrete, runnable pipeline definition makes the abstract guidance checkable against a real file.
name: build-and-promote
on:
push:
branches: [main]
tags: ["v*"]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # required for keyless cosign signing
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build, scan-ready, sign-ready image
uses: docker/build-push-action@v6
id: build
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
registry.example.com/catalog/api:git-${{ github.sha }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
sbom: true
provenance: mode=max
cache-from: type=registry,ref=registry.example.com/catalog/api:buildcache
cache-to: type=registry,ref=registry.example.com/catalog/api:buildcache,mode=max
- name: Scan the pushed digest
run: |
trivy image --severity CRITICAL,HIGH --exit-code 1 \
registry.example.com/catalog/api@${{ steps.build.outputs.digest }}
- name: Sign the pushed digest
uses: sigstore/cosign-installer@v3
- run: |
cosign sign --yes \
registry.example.com/catalog/api@${{ steps.build.outputs.digest }}
promote:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@v3
with:
registry: registry.example.com
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Re-tag the verified digest as the release
run: |
docker buildx imagetools create \
--tag registry.example.com/catalog/api:${{ github.ref_name }} \
registry.example.com/catalog/api@${{ needs.build.outputs.digest }}Every control introduced earlier in the chapter appears exactly once, at the stage where it belongs: multi-platform build and cache export in the build job, SBOM/provenance attached at build time, scanning and signing gated on the digest the build actually produced, and promotion as a re-tag operation that never rebuilds.
Runner sizing for multi-platform builds#
Emulated cross-architecture builds (the linux/arm64 leg on a standard amd64 GitHub-hosted runner, via QEMU) are meaningfully more CPU- and memory-hungry than a native build of the same Dockerfile, and a runner sized correctly for a single-platform build can silently thrash or time out once a second emulated platform is added.
| Runner characteristic | Single-platform build | Multi-platform build (with emulation) |
|---|---|---|
| CPU | Standard hosted runner is usually sufficient | Budget for 2-4x single-platform build time under QEMU emulation |
| Memory | Default hosted runner memory is typically enough | Cache mounts and emulated compilation can push memory pressure noticeably higher |
| Disk | Layer cache plus one platform's build context | Layer cache multiplies per platform; monitor for runner disk exhaustion on long-lived cache |
A team hitting inexplicable timeouts only on the arm64 leg of a multi-platform build should suspect emulation overhead before suspecting the Dockerfile — cross-compiling natively where the toolchain supports it (as this chapter's Go example already does) sidesteps the emulation cost entirely for compiled languages.
Rootless and Daemonless Build Alternatives#
Not every environment should or can run a privileged Docker daemon — a shared CI runner, a Kubernetes-native build pipeline, or a security policy that forbids daemon access are all common reasons to look past docker build itself.
| Tool | Model | Choose it when | Trade-off |
|---|---|---|---|
docker build / buildx with docker-container driver | Talks to a Docker daemon (local or remote) | Standard local development and most CI runners with Docker available | Requires daemon access, which is itself a privileged interface |
Rootless BuildKit (buildkitd in rootless mode) | Runs the build engine without root or daemon socket privileges | Multi-tenant build infrastructure where no single build should have host-root reach | More setup complexity; some low-level features (certain mount types, cgroup-based limits) are constrained under user namespaces |
| Kaniko | Builds Dockerfile-compatible images inside an unprivileged container, no daemon at all | Building container images from within a Kubernetes pod with no Docker socket exposed | No BuildKit-specific features (cache/secret mount syntax) — it targets classic Dockerfile compatibility |
Cloud-native buildpacks (pack build) | Infers a build from source without a Dockerfile at all | Teams standardizing on convention-driven builds across many similar services | Less control over exact layer structure; a genuinely custom build still needs a Dockerfile-based path |
From the Trenches: A platform team exposing
/var/run/docker.sockto every CI job to allow container builds discovered, during a security review, that any job — including jobs running untrusted third-party pull-request code — could use that socket to launch a privileged container and read arbitrary host files. Moving CI builds to Kaniko (no daemon socket needed at all) closed the finding without giving up Dockerfile-based builds.
Cache backends beyond the registry#
--cache-from/--cache-to type=registry (used in this chapter's CI pipeline) is portable across any CI system, but GitHub Actions also exposes a native cache backend that avoids pushing cache layers to the image registry at all:
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha stores cache in GitHub's own Actions cache service, scoped to the repository, with its own eviction policy (typically a rolling size- and age-based limit). It avoids cluttering the image registry with cache-only manifests, at the cost of being tied to one CI platform — a team running builds across GitHub Actions and a second CI system would still need type=registry for the portable case.
Layer Count, Squashing, and Image Flattening#
Each RUN, COPY, and ADD instruction typically produces its own layer. This is usually good — it's what makes caching and incremental pulls work — but an excessive number of small layers (or one enormous layer from an unbatched sequence of package operations) has real costs: registry push/pull overhead per layer, and, in the unbatched case, a layer that can't be partially cached at all.
# Less efficient: three layers, and an update layer that can go stale
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
# More efficient: one layer, cache-safe, and cleanup happens in the same layer
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*The second form matters for a subtle reason beyond layer count: if rm -rf /var/lib/apt/lists/* runs in a later layer than apt-get install, the apt cache still exists in the earlier layer's content and still contributes to image size — removing a file in a later layer does not shrink an earlier layer, the same lesson from this chapter's secrets section applied to disk space instead of credentials.
--squash and post-build flattening#
BuildKit and some registries support flattening an image's layers into fewer (or one) final layer after the build. This can reduce the layer count exposed downstream, but it destroys the cache-locality benefit the earlier sections of this chapter deliberately engineered — a squashed image cannot share a partial-layer cache hit with a future build the way a well-ordered multi-layer image can. Reach for squashing only for a specific downstream constraint (a registry with a hard layer-count limit, an air-gapped transport format that benefits from fewer files), not as a default optimization.
| Choose this | When it is appropriate | Avoid it when |
|---|---|---|
| Many well-ordered layers (default) | Normal CI/CD where rebuild speed and cache reuse matter | A downstream system has a hard layer-count ceiling |
Batched instructions, no --squash | The common case — fewer, larger, sensibly grouped layers without losing cache boundaries | Rarely a bad choice; default to this |
--squash / full flatten | A one-off export for air-gapped transport or a registry with strict layer limits | Any pipeline that rebuilds and re-pushes this image regularly |
Measuring layer contribution with dive#
docker history shows per-instruction size but not which specific files live in which layer. dive opens an interactive view of every layer's actual file contents and flags wasted space — files added in one layer and duplicated or deleted in another.
dive registry.example.com/catalog/api:2.6.0
CI=true dive registry.example.com/catalog/api:2.6.0 --lowestEfficiency=0.95The CI=true mode returns a pass/fail exit code against an efficiency threshold, which makes it usable as a CI gate against image bloat the same way a vulnerability scanner gates security findings — a genuinely oversized layer (an accidentally copied node_modules cache, a build tool left in the final stage) fails the build instead of quietly shipping.
Offline and Air-Gapped Image Delivery#
Some environments — regulated on-premises deployments, disconnected edge sites, classified networks — cannot pull directly from an internet-facing registry. docker save/docker load (or buildx build --output type=oci,dest=... for an OCI-layout tarball) move an image as a file instead of a registry pull.
docker buildx build --output type=oci,dest=catalog-api-2.6.0.tar --tag catalog-api:2.6.0 .
# transport catalog-api-2.6.0.tar across the air gap by an approved offline process
docker load --input catalog-api-2.6.0.tar
docker tag catalog-api:2.6.0 internal-registry.local/catalog/api:2.6.0
docker push internal-registry.local/catalog/api:2.6.0The attestations and signature discussed earlier in this chapter should travel with the tarball, not be regenerated on the disconnected side — provenance and SBOM data describe the original build, and re-signing on the air-gapped side would only prove the disconnected environment repackaged the file, not that the original build was trustworthy. Verify the signature and SBOM before the tarball crosses the air gap, and carry the verification record across with it.
From the Trenches: An air-gapped deployment process re-built images from source inside the disconnected network "to be safe," reasoning that transporting a tarball felt less auditable than a fresh build. This actually removed the guarantee it was trying to add: the disconnected build used whatever base-image and package-mirror state happened to be available inside the air gap, which had already drifted from what was scanned and approved outside it. Transporting the exact verified artifact (with its attestations) and verifying the signature on arrival is the auditable path; rebuilding blind on the other side of the gap is not.
Build Context Sources and Private Dependencies#
The "build context" is whatever set of files BuildKit can see and COPY/ADD from — usually a local directory, but not always.
# Local directory context (the common case)
docker build -t catalog-api:dev .
# Remote Git repository as the context, no local checkout needed
docker buildx build -t catalog-api:dev https://github.com/example/catalog-api.git#main
# Dockerfile from stdin, context still local
docker build -t catalog-api:dev -f - . <<'EOF'
FROM alpine:3.21
RUN echo "example"
EOFA Git-URL context is genuinely useful for a build triggered by a webhook that only has a repository reference, not a checked-out working tree — BuildKit clones the reference itself. It has a real limitation worth knowing before relying on it: build secrets and .dockerignore behavior work against the cloned tree, so a private dependency fetched during the build (not present in the repository) still needs the type=secret mechanism covered earlier, not an assumption that the surrounding CI checkout's credentials are somehow available inside the isolated clone.
Private base images and private package registries#
A Dockerfile that pulls FROM registry.example.com/internal/base:1.4 or runs npm ci against a private npm registry needs authentication available to BuildKit itself, which runs isolated from the invoking shell's ambient credentials.
docker login registry.example.com
docker buildx build --tag catalog-api:dev .For a private base image, a prior docker login on the build host (or CI's registry-login step) is normally sufficient because BuildKit reuses the Docker credential store for FROM pulls. For a private package registry consumed inside a RUN instruction, that ambient login does not automatically propagate into the build's isolated environment — this is exactly the case the type=secret mount (covered earlier for npm ci against a private .npmrc) exists to solve, and it is a common point of confusion: "I'm already logged in, why can't the build reach my private registry?" The answer is that a daemon-level registry login only helps for pulling image layers via FROM, not for arbitrary network calls a RUN instruction makes.
| Credential needed for | Mechanism | Why |
|---|---|---|
Pulling a private base image (FROM) | docker login on the build host before building | BuildKit consults the same credential store the daemon uses for pulls |
A private package registry inside RUN npm ci / pip install | --mount=type=secret | The instruction's process has no automatic access to host-level registry logins |
| Pushing the built image | docker login on the host running --push, or CI's registry-login step | Push authenticates the same way a manual docker push would |
Debugging a Failed or Suspicious Build#
A build that fails deep inside a RUN instruction, or one that "succeeds" but produces a suspicious result, needs different tools than a running container does.
docker build --progress=plain --no-cache -t catalog-api:debug .
BUILDKIT_PROGRESS=plain docker buildx build -t catalog-api:debug .--progress=plain prints full, unbuffered instruction output instead of the default collapsed TTY view — essential when a RUN instruction's actual error is hidden behind a truncated progress bar. --no-cache rules out a stale or corrupted cache entry as the cause before assuming the Dockerfile itself is wrong; a cache hit against an earlier, subtly different build context is a common source of "it worked yesterday" confusion.
Stopping at an intermediate stage#
Multi-stage Dockerfiles let you target a specific stage instead of building the whole file, which turns a hard-to-reproduce final-stage failure into an inspectable intermediate image.
docker build --target build -t catalog-api:build-stage .
docker run --rm -it catalog-api:build-stage /bin/shThis drops into the exact filesystem state right after the build stage's instructions ran, before anything was copied into the final runtime stage — often the fastest way to answer "did the compiled artifact even end up where the final COPY --from=build expects it."
| Symptom | Likely cause | Where to look |
|---|---|---|
| Build succeeds locally, fails only in CI | Missing --cache-from, different builder driver, or a file excluded by .gitignore but needed and not committed | Compare .dockerignore against what CI actually checks out; confirm builder driver parity |
COPY fails with "file not found" for a file that clearly exists | The file is excluded by .dockerignore, or the build context root doesn't include it | docker build --progress=plain output showing the resolved context; check .dockerignore |
| Cache never hits despite unchanged Dockerfile | A copied file's content changed (even a timestamp-only change some tools introduce), or the build context includes an untracked file that varies between builds | Tighten .dockerignore; verify the exact files feeding each COPY |
| Final image runs but the application can't find a runtime dependency | A multi-stage COPY --from=build copied the binary but not a required shared library, cert bundle, or timezone data | docker run --target build ... ldd <binary> (where a shell is available) or compare against a working single-stage build |
--platform build fails only for one architecture | An architecture-specific dependency or an emulation limitation under QEMU | Build that platform natively if possible, or check the dependency's architecture support matrix |
| Build hangs indefinitely with no output | A cache mount using sharing=locked is held by a concurrent build on the same builder instance | Check for another in-flight build on the same builder; use a dedicated builder per concurrent pipeline if this recurs |
| Image builds and runs, but a config file's content is unexpectedly stale | A COPY matched a cached layer because .dockerignore or context hashing didn't detect the file as changed (e.g. a symlink or generated file with unstable content) | Confirm the exact file BuildKit hashed with --progress=plain; regenerate the file deterministically before the build |
From the Trenches: A build that succeeded for months began intermittently failing a
COPYstep with "no such file or directory" for a file that was clearly present in the repository. The cause was a.dockerignorepattern (*.env) that had been broadened during an unrelated cleanup and started unintentionally matching a same-extension configuration template the build genuinely needed.--progress=plaincombined with a deliberatedocker build --no-cacheisolated it to the.dockerignorechange within minutes, versus hours of suspecting the Dockerfile logic itself.
Running Tests as a Build Stage#
A dedicated test stage runs the same dependency-and-source layers already built for the application, so tests execute against exactly the artifact being shipped rather than against a separately maintained CI environment that can drift from the image.
FROM build AS test
RUN --mount=type=cache,target=/root/.npm \
npm run test -- --ci --coverage
FROM runtime AS final
COPY --from=build /app/dist /app/distdocker build --target test -t catalog-api:test .
docker buildx build --target test --output type=cacheonly .--target test builds only up to the test stage — useful for a CI step that should fail the pipeline on a test failure without needing the final runtime image at all. --output type=cacheonly builds a stage purely for its cache and pass/fail exit code, discarding the resulting filesystem entirely, which is the right choice when the stage's only purpose is "did this succeed," not "keep this image."
| Choose this | When it is appropriate | Avoid it when |
|---|---|---|
| Test stage inside the same Dockerfile | Tests need the exact built artifact and its exact dependency layer (native extensions, compiled assets) | The test suite needs infrastructure (a real database, a browser) that doesn't belong in a build container |
| Separate CI test job against a pre-built image | Integration/end-to-end tests needing real service dependencies via Compose (see Part 3) | Unit tests with no external dependencies — the overhead of a separate job is unnecessary |
From the Trenches: A team's unit tests passed reliably in a dedicated CI test job but a production regression shipped anyway, because the CI test environment installed dependencies via a slightly different Node version than the Dockerfile's base image used. Moving unit tests into a
testbuild stage sharing the exact same base image and lockfile-driven install as the shipped artifact eliminated the class of "passes in CI, fails in the image" discrepancy entirely, because there was no longer a second, independently drifting environment.
Common Mistakes and Interview Traps#
| Mistake or claim | Why it is wrong | Better answer |
|---|---|---|
"docker build and docker buildx build are basically the same." | They can differ in driver, feature availability (multi-platform, attestations), and cache export support. | Use buildx explicitly with a docker-container driver when the pipeline needs multi-platform output, remote cache, or attestations. |
"Removing a file in a later RUN removes it from the image." | Layer history is additive; removed files still exist in an earlier layer's content. | Never put a secret in ARG/ENV/COPY; use --mount=type=secret. |
| "A smaller base image is automatically more secure." | Size and vulnerability surface correlate but aren't identical; a small image can still run as root with excess capabilities. | Combine a minimal base with non-root users, dropped capabilities, and SBOM-driven scanning. |
| "Build caching is unreliable, so CI should always build clean." | A cold cache in CI is usually a missing --cache-from/--cache-to step, not an inherent BuildKit limitation. | Export and import cache via a registry or CI-native cache backend. |
| "Rebuilding the same Dockerfile twice produces the same image, so re-tagging is unnecessary." | Floating base tags, package mirrors, and timestamps can change a rebuild's digest. | Promote by re-tagging the exact verified digest, never by rebuilding for the release tag. |
| "Alpine is always the safest small base image." | musl libc compatibility gaps and inconsistently maintained Alpine-based third-party images can introduce real risk. | Evaluate distroless, Chainguard/Wolfi, or slim variants against the workload's actual compatibility and patch-velocity needs. |
| "A vulnerability scanner gate should just fail on any CVE above a severity threshold." | With no exception path, a single unfixed base-image CVE can block every build fleet-wide and pressure teams into disabling the gate entirely. | Pair the threshold with a time-boxed, tracked waiver mechanism. |
"A file removed in a later RUN no longer affects image size." | Layers are additive; the earlier layer's content, including the removed file, remains in the image's stored history. | Combine install and cleanup into the same RUN instruction. |
| "Unit tests belong only in a separate CI job, never in the Dockerfile." | A separately maintained test environment can drift from the exact dependency versions and base image the shipped artifact actually uses. | Run unit tests as a build stage sharing the same layers as the runtime artifact; keep integration/E2E tests in a separate job against real dependencies. |
"docker-container driver support is optional — the default driver is fine for any pipeline." | The default docker driver cannot export multi-platform manifests to a local image store and often can't store attestations without extra daemon configuration. | Standardize CI on an explicit docker-container (or remote/Kubernetes) builder for anything needing multi-platform output or attestations. |
| "Squashing an image is always a good size optimization." | Flattening destroys the layer-cache boundaries this chapter deliberately built for fast, incremental rebuilds. | Reserve squashing for a specific downstream constraint, not as a default habit. |
| "Pinning a base image digest means it's permanently secure." | A pin without a renewal process reproduces a stale, increasingly vulnerable artifact forever. | Pin for reproducibility, and pair it with a scheduled, reviewable update process. |
| "A build that hangs is always a network problem." | A locked cache mount held by a concurrent build on the same builder instance produces the same symptom with no network involved at all. | Check for a competing in-flight build on the shared builder before assuming a network stall. |
Worked Practice Problems#
1. A multi-stage Dockerfile's final image is much larger than expected. How do you find out why?#
Run docker history --no-trunc <image> to see each layer's size and originating instruction, and confirm the final FROM stage is actually a minimal runtime base rather than accidentally reusing a build stage. Check that COPY --from=<stage> only copies the specific artifact needed, not an entire directory that still contains build tooling or source archives. If the base image itself is unexpectedly large, compare it against a distroless or slim equivalent.
2. CI cache hit rate is near zero even though the Dockerfile orders instructions correctly. What's the likely cause?#
Each CI job almost certainly runs on a fresh, ephemeral runner with no local BuildKit cache directory carried over from the previous run. Correct instruction ordering only helps within a single build; across separate runners it does nothing without an explicit shared cache. Add --cache-from/--cache-to type=registry (or the CI platform's native BuildKit cache backend) so the cache persists in a location every runner can reach.
3. A compliance review asks whether a specific CVE-affected library version shipped in a production image built four months ago. How do you answer without pulling the image?#
If the image was built with --sbom=true, query the stored SBOM attestation against the image's digest directly from the registry with docker buildx imagetools inspect --format '{{ json .SBOM }}' and search it for the library and version. This avoids pulling and re-scanning the full image and gives an authoritative answer tied to the exact digest that was deployed, provided the deployment record still identifies that digest.
4. A team wants to switch a service's base image from debian:bookworm to a distroless image, but the on-call runbook relies on docker exec ... /bin/sh for live diagnosis. How do you reconcile this?#
Recognize that distroless images deliberately remove the shell as a security property, not an oversight — reintroducing a shell defeats the point. Replace shell-based interactive diagnosis with structured, externally captured signals: verbose application logging, docker inspect/docker stats for resource and state evidence, a dedicated ephemeral debug container (docker run --rm -it --pid=container:<target> --network=container:<target> <debug-image> sharing the target's namespaces) for cases that genuinely need process-level inspection, and updating the runbook to use those tools instead of an in-container shell.
5. A promoted release tag was rebuilt from source instead of re-tagged from the staging digest, and the two digests differ even though nothing in the Dockerfile changed. What's the most likely explanation, and what's the fix?#
The most likely cause is a non-pinned input: a floating base image tag that received an upstream update between the two builds, a package manager pulling a newer patch version with no version pin, or a build step that embeds a timestamp or non-deterministic file ordering. The fix is twofold — pin the specific inputs that drifted (base image digest, package versions) to restore reproducibility going forward, and change the promotion process itself to re-tag the exact verified digest via docker buildx imagetools create rather than rebuilding for the release tag, so this class of drift can never again separate what was tested from what ships.
6. A docker build --target build intermediate image shows the compiled binary is missing entirely, even though the source file that should produce it is present. Where do you look first?#
Check the exact build command inside the build stage — a common cause is a build command that silently succeeds with exit code 0 while writing its output to a path the Dockerfile doesn't expect, or a working directory mismatch between where WORKDIR places the build and where the compiler was configured to emit output. Run the same build command interactively inside a container from that intermediate stage (docker run --rm -it catalog-api:build-stage /bin/sh, then re-run the compile command manually) to see its actual output path before assuming the Dockerfile's COPY --from=build line is wrong — copying from the wrong path is a symptom, not the root cause, if the artifact was never produced where expected in the first place.
Summary and What's Next#
BuildKit turns a Dockerfile from a sequential script into a cacheable, parallelizable build graph. Ordering instructions around cache locality, isolating build tooling behind multi-stage boundaries, and using cache/bind/secret mounts for their distinct purposes produce faster, smaller, and more auditable images. Base image choice, linting, and vulnerability scanning turn "it built" into "it's safe to run"; tagging and promotion discipline — commit-traceable tags, digest-based promotion, SBOM and provenance attestations, and signature verification — turn the build output into something a security or incident review can trust without re-deriving it from scratch.
The throughline across every section is the same one from Part 1: an image is a declared, auditable artifact, and every practice here exists to keep that declaration trustworthy from the first RUN instruction through a production incident months later. A team that can answer "what's in this image, how was it built, and is this exact digest what's running" without guesswork has already solved most of what makes container delivery hard in practice, and can extend that same discipline confidently to the multi-service, multi-host workflows the rest of this series covers next.
Part 3 moves from building the image to running it as part of a real application: user-defined networks and DNS, published ports, volumes and bind mounts for persistent state, and Docker Compose for coordinating multiple services on one host.