Docker Multi-Stage Builds: Smaller, Safer Images
What is a Docker multi-stage build?
A Docker multi-stage build uses several FROM stages in one Dockerfile and copies only the required artifacts into the final runtime stage. Compilers, source files, test tools, and package caches can stay outside the shipped image.
TL;DR
- -Measure the image before setting a size target; base image, native libraries, and runtime assets determine the result
- -Use a build stage for compilation and a runtime stage containing only the files and dependencies required to start the service
- -Order COPY instructions around lockfiles and use BuildKit cache mounts so source changes do not force dependency downloads
- -Never pass build secrets through ARG or ENV; use BuildKit secret or SSH mounts
- -Test the built image as a non-root user, scan it, and publish SBOM and provenance with the release
The useful result of a Docker multi-stage build is not a dramatic percentage in a blog title. It is a final image whose contents you can explain.
The builder may need a compiler, headers, source maps, tests, and package caches. The running service usually does not. Separate those environments, copy a narrow artifact set into the last stage, and verify that the container still behaves like production.
Measure Before Rewriting
Build the current image from a clean checkout and record a baseline:
docker build --pull -t example-api:baseline .
docker image inspect example-api:baseline \
--format '{{.Size}} {{.Config.User}} {{json .Config.Entrypoint}} {{json .Config.Cmd}}'
docker history --human --no-trunc example-api:baseline
Also run the service and its smoke test. Image size alone is not enough. Record:
- compressed registry size and local unpacked size;
- cold pull time in the environment that deploys it;
- build time with an empty and warm cache;
- startup and health-check behavior;
- user, entrypoint, architecture, and required runtime files;
- vulnerability scan results for OS and application packages.
Do not copy someone else’s “under 100 MB” target. A service with a browser, media library, CA bundle, or native runtime has a different floor from a static Go binary.
What Multi-Stage Builds Actually Remove
Every FROM starts a stage. The final image contains the layers of the last stage
and only the files explicitly copied from earlier stages.
# syntax=docker/dockerfile:1
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
Use the language version your application supports and pin it through your normal dependency-update process. A moving tag improves neither reproducibility nor patch management by itself.
This example leaves TypeScript, source files, the npm cache, and build tooling in
the build stage. It also uses the same base family for build and runtime, which
reduces surprises with native Node modules.
Check the assumptions before copying it:
- Does
npm run buildneed schema, asset, or workspace files not shown here? - Does the application read
package.jsonat runtime? - Do post-install scripts compile native modules?
- Are migrations a deployment job rather than a container startup side effect?
- Does the service write anywhere outside a mounted volume or
/tmp?
An optimized Dockerfile that silently drops runtime assets is simply broken.
Add a Test Stage Instead of Shipping Tests
Multi-stage builds can keep verification close to the build without putting test tools in the final image:
FROM build AS test
COPY test ./test
RUN npm test
FROM runtime AS production
In CI, build the test target first and the production target only after it
passes:
docker build --target test -t example-api:test .
docker build --target production -t example-api:${GIT_SHA} .
Do not put credentials or production data in the test stage. Intermediate stages and build cache may be exported or inspected.
Make Cache Invalidation Deliberate
Docker reuses a layer when the instruction and the files it depends on have not changed. A common mistake puts the volatile source tree before the expensive dependency install:
COPY . .
RUN npm ci
Any source change can invalidate the install step. Copy manifests first, install, then copy the files required for compilation:
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
The cache mount preserves downloaded packages when the install layer must run again. It is a build cache, not part of the resulting image.
For CI workers that start clean, export cache to a registry or supported CI cache backend. Scope write access carefully: a poisoned shared cache is a supply-chain risk, not merely a slow build.
Keep the Build Context Narrow
.dockerignore controls which local files can enter the build context. Start with
known large or sensitive paths:
.git
node_modules
dist
coverage
.env
.env.*
*.log
Do not paste an aggressive ignore template blindly. Excluding tsconfig.json,
migration files, licenses, or static assets can make the build incomplete. Run a
clean build in CI so undeclared files on a developer machine cannot hide the
mistake.
Named contexts and bind mounts can narrow large monorepo builds further. The rule
is the same: every COPY should have a reason.
Choose the Runtime Base by Compatibility
Smaller is useful only after the application starts and remains supportable.
| Runtime base | Good fit | Operational cost |
|---|---|---|
| Distribution slim image | Native packages, familiar debugging | More OS packages to patch |
| Alpine | Workloads tested against musl | Native dependency compatibility |
| Distroless | Fixed runtime with external debugging | No package manager or ordinary shell |
scratch | Self-contained static binary | Must copy every required file explicitly |
Test DNS, TLS certificates, time zones, fonts, native extensions, process signals,
and architecture on the same platform used in production. Do not copy artifacts
built for glibc into a musl runtime—or amd64 binaries into arm64—and expect a
container boundary to repair them.
Use a separate debug target if incident tooling is necessary. Adding curl, a
shell, and a compiler to every production container for hypothetical debugging
undoes much of the runtime separation.
Run as a Non-Root User
Setting USER does not fix every container security issue, but it removes an
avoidable default.
The filesystem must match the runtime user. Prefer COPY --chown over a later
recursive chown, which creates another large layer. Test with a read-only root
filesystem where the platform supports it and mount only the paths the process
must write.
Container controls remain necessary outside the Dockerfile:
- drop unneeded Linux capabilities;
- prevent privilege escalation;
- set CPU and memory limits;
- use a read-only root filesystem;
- mount secrets at runtime rather than baking them into the image;
- apply network and admission policy.
Keep Secrets Out of Layers and Metadata
ARG and ENV are not safe channels for build credentials. Secrets can survive
in image metadata, history, or cache even if a later layer deletes a file.
Use BuildKit secret mounts:
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,required=true \
--mount=type=cache,target=/root/.npm \
npm ci
docker build --secret id=npmrc,src="$HOME/.npmrc" -t example-api .
For private Git dependencies, use an SSH mount rather than copying a private key.
Make the secret available to one RUN instruction and verify that the produced
artifact does not contain it.
Verify the Artifact, Not the Dockerfile
A clean-looking Dockerfile can still ship the wrong files. Inspect and test the actual image:
docker buildx build --check .
docker run --rm --read-only --tmpfs /tmp \
--cap-drop=ALL example-api:${GIT_SHA} node dist/healthcheck.js
docker scout cves example-api:${GIT_SHA}
Trivy, Grype, or your registry scanner are also valid choices. Define which severity, exploitability, fix availability, and exception age block a release. “Zero findings” is rarely a durable policy; a documented triage process is.
Generate supply-chain metadata when publishing:
docker buildx build \
--platform linux/amd64,linux/arm64 \
--provenance=mode=max \
--sbom=true \
--tag registry.example.com/example-api:${GIT_SHA} \
--push .
An SBOM lists the components associated with the image. Provenance records how it was built. Sign and verify releases according to your deployment platform’s policy; attestations are evidence, not enforcement by themselves.
Prevent Size and Contents From Drifting
CI should compare against an accepted baseline, not an arbitrary universal cap. Track:
- compressed image size and change from the previous release;
- added and removed packages;
- base image digest;
- architecture set;
- runtime user and entrypoint;
- vulnerability and license policy result;
- smoke-test result under production-like restrictions.
Fail on an unexplained regression, then update the baseline in a reviewed change when a larger image is justified. A new font pack may be legitimate. Accidentally shipping the build cache is not.
Review Checklist
- Build and runtime stages have separate responsibilities.
- Only required artifacts cross into the final stage.
- Builder and runtime are compatible for native dependencies.
- Lockfiles are copied before volatile source files.
- Build caches speed compilation but do not enter the image.
-
.dockerignoreexcludes secrets and local build output. - Build credentials use secret or SSH mounts, never
ARGorENV. - The process runs as a non-root user with writable paths declared.
- CI builds from a clean checkout and tests the produced image.
- The release has a scan result, SBOM, provenance, and reviewed exception path.
Primary References
- Docker: Multi-stage builds
- Docker: Optimize cache usage
- Docker: Build secrets
- Docker: Build checks
- Docker: Build attestations
Multi-stage builds are successful when the final image contains a known runtime, known application artifacts, and nothing that entered only to produce them.