Docker Multi-Stage Builds: Smaller, Safer Images

By Updated

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 build need schema, asset, or workspace files not shown here?
  • Does the application read package.json at 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 baseGood fitOperational cost
Distribution slim imageNative packages, familiar debuggingMore OS packages to patch
AlpineWorkloads tested against muslNative dependency compatibility
DistrolessFixed runtime with external debuggingNo package manager or ordinary shell
scratchSelf-contained static binaryMust 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.
  • .dockerignore excludes secrets and local build output.
  • Build credentials use secret or SSH mounts, never ARG or ENV.
  • 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

Multi-stage builds are successful when the final image contains a known runtime, known application artifacts, and nothing that entered only to produce them.

Frequently Asked Questions

Should every production image use Alpine?
No. Alpine uses musl libc, while many prebuilt native dependencies target glibc. A Debian slim, distroless, or language-specific runtime may be a safer choice. Compare compatibility, security updates, and measured image size instead of assuming the smallest base is best.
Does deleting a file in a later Docker layer reduce image size?
Not necessarily. Files created in an earlier layer remain in that layer even if a later instruction removes them. Delete temporary package lists in the same RUN instruction or keep the entire build operation in a stage that is not copied into the final image.
Can I copy node_modules from an Alpine builder into a Debian runtime?
Do not assume it is safe. Native modules may be compiled for a different libc or architecture. Use compatible builder and runtime platforms, or install production dependencies in a stage based on the same runtime image.