DEV Community

Mubashir Taj
Mubashir Taj

Posted on

Docker Multi-Stage Builds: Small Images Without the Pain

Every team has shipped a Docker image that was three times bigger than it needed to be, usually because the build tools traveled with the application. Node modules, compilers, test frameworks, and cached layers all ride along into production, slowing deploys, widening the attack surface, and confusing anyone who inspects the image. Multi-stage builds fix this with one idea: build in one stage, copy only the result into the next, and ship only the final stage.

The problem in one Dockerfile

A typical single-stage Node image looks like this:

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

The final image contains the source, the dev dependencies, the build cache, and npm itself. None of that serves traffic. It only makes the image bigger and the deploy slower. A Go equivalent is worse: the compiler toolchain is hundreds of megabytes, and it has no business running in production.

The multi-stage fix

Split the Dockerfile into stages. The first stage builds. The second stage runs, and it copies only the build output:

# Stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev

# Stage 2: run
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

The final image contains the runtime, the built output, and production dependencies. Nothing else. The build stage is discarded after the copy, so its layers never ship. A Go version is even cleaner: build a static binary in a golang image, copy the single binary into a scratch or distroless image, and ship an artifact measured in megabytes.

# Stage 1: build a static binary
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 /app/server ./cmd/server

# Stage 2: ship only the binary
FROM scratch
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
Enter fullscreen mode Exit fullscreen mode

A scratch image has no shell, no package manager, and nothing to exploit. That is a security property as much as a size property: an attacker who compromises the process finds no tools inside the container.

Three habits that make it work

First, order your layers for cache. Copy dependency manifests before source code, so dependency installation is cached across source changes. A Dockerfile that copies everything before installing dependencies rebuilds the world on every commit, and multi-stage or not, your CI time will show it.

Second, name your stages and copy explicitly. COPY --from=build with a named stage is readable and refactorable. Copying from an unnamed stage index is a bug waiting for someone to insert a stage above it.

Third, run as a non-root user in the final stage whenever the base image allows it. Multi-stage builds shrink the image; a non-root user shrinks the blast radius. Both belong in the same Dockerfile review checklist.

What to watch out for

Multi-stage builds have two common traps. The first is debugging: a production image with no shell cannot be exec'd into for inspection. Keep a debug variant of the image, or ship debug tooling in a sidecar, rather than bloating the production image for the rare debugging session.

The second is secret leakage across stages. A build argument or secret used in the build stage can persist in that stage's layers, and while those layers do not ship in the final image, they do exist in the build cache and in the image history if you are careless. Use BuildKit secret mounts for credentials instead of build arguments, and never copy credential files into any stage.

The payoff is concrete: images that are a fraction of their former size, deploys that finish in seconds instead of minutes, and production containers that contain only what the application needs to run. Multi-stage builds are not an optimization for later. They are the default shape of a serious Dockerfile, and retrofitting them onto an existing service takes an afternoon. That afternoon pays for itself on the first deploy.

Distroless, slim, and scratch: how small should you go

Once the build stage is separated, the remaining choice is the final base image, and the spectrum runs from full distributions down to nothing. A slim image keeps a package manager and a shell, which makes debugging easy and the image moderately sized. A distroless image keeps the language runtime and its certificates but drops the shell and package manager, which removes most of the tools an attacker would reach for. A scratch image keeps literally nothing but your binary, which is the smallest and most secure option and the hardest to debug.

The right choice depends on the runtime. Static Go binaries are natural scratch citizens: one file, no dependencies, no shell needed. Dynamic runtimes like Node or Python need their interpreter and system libraries, so slim or distroless fits better. The rule is to go as small as the runtime honestly allows, and to stop shrinking at the point where debugging becomes impossible rather than merely inconvenient. A team that cannot inspect a failing container will eventually add the tooling back under incident pressure, and an emergency image change is worse than a slightly larger image chosen deliberately.

BuildKit features worth turning on

Two BuildKit features multiply the value of multi-stage builds. The first is layer caching across CI runs: cache the dependency-install layer in a registry cache so that a code-only change rebuilds in seconds instead of minutes. The second is the secret mount, which lets the build use credentials without baking them into any layer. Both are one-line changes in most CI configurations, and both address the two complaints teams raise about Docker builds: that they are slow, and that they leak secrets. A multi-stage Dockerfile without BuildKit caching is still correct, but it leaves the most visible win on the table.

Measure the result after you convert. Record the old image size, the new image size, the old build time, and the new build time, and put the numbers in the pull request. "Smaller and faster" is a claim; "from 1.2 gigabytes to 180 megabytes, build time unchanged" is evidence, and evidence is what gets the next service converted too. One converted service is an experiment. A second one is a pattern. A tenth one is a standard nobody has to argue for again.

The checklist, in one place

Converting an existing Dockerfile takes an afternoon, and the checklist fits on one page. Split build and run into separate stages. Copy dependency manifests first for cache. Copy only build output into the final stage. Choose the smallest base image the runtime honestly allows. Run as a non-root user. Mount secrets with BuildKit instead of passing them as arguments. Keep a debug variant for inspection. Measure image size and build time before and after, and record both numbers. Then convert the next service, because the second conversion takes half the time and the tenth takes minutes. The habit compounds faster than the images shrink.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

Multi-stage builds are a rare optimization that also improves the security story: the final image has fewer tools, fewer packages, and a smaller patch surface. A useful follow-up is to show how you verify the final stage contains only the runtime artifacts you expect.