Multi-Stage Docker Builds: Smaller, Safer Images
By OpsAtlas · · 4 min read
Table of contents
A single-stage Dockerfile ships everything it needed to build the app: compilers, dev dependencies, source files, package caches and test fixtures. None of that is used at runtime, and all of it makes the image bigger and gives an attacker more tools to work with.
A multi-stage build splits the Dockerfile into stages. You build in one stage and copy only the result into a clean final stage. The final image is the only thing you ship; the build stages are thrown away.
How stages work
Every FROM starts a new stage. Name it with AS, then copy files out of it with COPY --from:
FROM golang:1.23 AS build
# … compile …
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
Only the last stage becomes the image, unless you pick another with docker build --target <stage>. BuildKit, the default builder since Docker 23, also skips stages the target does not depend on. An unused docs or lint stage costs nothing in a normal build.
Node.js: compile TypeScript, ship only what runs
# syntax=docker/dockerfile:1
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY . .
RUN npm run build
FROM node:22-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/dist ./dist
USER node
HEALTHCHECK CMD ["node", "dist/healthcheck.js"]
CMD ["node", "dist/server.js"]
What each stage is for:
depsinstalls every dependency, including TypeScript and the test tools. It only reruns when the lockfile changes.buildstarts fromdeps, adds the source and compiles it todist/.prod-depsinstalls runtime dependencies only. It doesn’t depend on the source, so it stays cached through code changes.- The final stage gets
node_modulesfromprod-depsanddistfrombuild. No compiler, nosrc/, no dev dependencies, no npm cache. It runs as the image’s built-innodeuser.
Compare the result yourself with docker image ls myapp. The gap between this image and a single-stage node:22 build is usually several hundred megabytes, because the single-stage build carries the full Debian toolchain and every dev dependency.
Go: from compiler to a static image
Go can compile to a single static binary, so the runtime image needs almost nothing:
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
CGO_ENABLED=0makes the binary fully static. Without it, Go may link against the C library, and the binary fails to start on an image that has none.distroless/staticcontains CA certificates, time zone data and a/etc/passwd, which a binary that calls HTTPS endpoints and logs timestamps needs. It has no shell and no package manager. The:nonroottag runs as user 65532.- No shell means no
HEALTHCHECKthat callscurl. On Kubernetes that’s fine, because the kubelet ignoresHEALTHCHECKand uses readiness and liveness probes instead. On plain Docker, add ahealthchecksubcommand to the binary itself.
Faster rebuilds with cache mounts
Layer caching skips a step only when nothing before it changed. When the lockfile does change, npm ci or go mod download downloads everything again. A BuildKit cache mount keeps the package manager’s cache between builds without writing it to a layer:
RUN --mount=type=cache,target=/root/.npm npm ci
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/app ./cmd/app
The cache lives on the build machine, not in the image. In CI it is empty on a fresh runner unless you export it, for example with docker buildx build --cache-to type=gha on GitHub Actions.
Run tests in a stage, not in the image
A test stage builds on top of build, so it reuses the same layers:
FROM build AS test
RUN npm test
docker build --target test . # CI: fails the build if tests fail
docker build -t myapp . # release: the test stage is skipped
The production image never contains test files or test dependencies, and CI tests exactly the code that ships.
Pitfalls
ARGis scoped to a stage. AnARGdeclared before the firstFROMcan be used inFROMlines. To use its value inside a stage, declare it again there withARG NAME, without a value.COPY --fromcopies from the stage’s filesystem, not from your project folder. Use paths as they exist in that stage:/app/dist, not./dist..dockerignorestill matters. Every stage receives the same build context. ACOPY . .in the build stage copies.git,.envand localnode_modulesunless you exclude them.- Check the final stage, not just the first. It is easy to harden the build stage and forget
USERin the one that ships. The Dockerfile linter checks the final stage for a non-root user, exec-formCMDand a health check, and reports each stage’s base image if it is unpinned. Both Dockerfiles in this post pass with a score of 100. The Go image only gets an informational note about the missingHEALTHCHECK.
For the mistakes that leak secrets or bloat even a multi-stage image, see Dockerfile mistakes that leak secrets and bloat images. To run the result next to a database, the docker run to Compose converter turns your docker run commands into a compose.yaml.
Try it yourself
Dockerfile linter and fixer
Lint a Dockerfile in your browser: find root users, unpinned images, leaked secrets and bloated layers, then apply safe fixes in one click.
DockerBetadocker run to Docker Compose converter
Convert docker run commands into a clean compose.yaml: ports, volumes, env, networks, healthchecks and limits. Runs in your browser.
Docker