Articles

Docker Multi-Stage Builds Explained

Multi-stage builds let a Dockerfile use one stage to compile code and a separate, minimal stage to ship it — cutting image size and attack surface.

The Lycoris Team The Lycoris Team · · 4 min read
Docker whale logo on a dark background

A multi-stage build is a Dockerfile pattern that uses more than one FROM instruction, where each FROM starts a fresh build stage. Early stages compile, test, or bundle your code with a full toolchain; the final stage copies only the finished artifacts into a small runtime image and discards everything else. The result is a production image that ships your app, not your build tools.

The problem it solves

Building software inside a container usually needs things the running app never does: a compiler, a package manager’s full dependency tree, header files, test frameworks. If you install all of that in the same image you deploy, you end up shipping gigabytes of build-time baggage into production — and every one of those extra packages is also extra attack surface, per the guidance in the OWASP Top 10 around minimizing what’s exposed.

Before multi-stage builds existed, teams worked around this with two separate Dockerfiles, or a single script that built the app outside Docker and then COPY’d the result in — both awkward to keep in sync. Multi-stage builds fold the whole thing into one file.

How the syntax works

Each FROM line can be named with AS <name>, and later stages can COPY --from=<name> files out of an earlier stage:

# Stage 1: build
FROM node:20 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# 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 ./
CMD ["node", "dist/server.js"]

The build stage installs every dependency listed in the lockfile and compiles the app. The final stage starts over from a slimmer base image and copies in only dist/ and the production node_modules — the compiler, dev dependencies, and source maps from the build stage never make it into the image that gets deployed. Docker discards the intermediate stages’ layers from the final image automatically; you don’t clean anything up by hand.

You can have as many stages as you want, and a stage doesn’t have to use the same base image as any other — it’s common to build a Go binary in a full golang image and then copy just that binary into a scratch or distroless image with nothing else in it at all.

Why the resulting image is smaller and safer

A single-stage Dockerfile that installs a compiler, dev dependencies, and build caches can easily produce an image several times larger than the equivalent multi-stage output. That matters for more than disk space:

  • Faster deploys and pulls. Smaller images move faster through your registry and start faster on new nodes, which matters directly for readiness during a rolling deploy.
  • Smaller attack surface. A shell, a package manager, and a compiler in your runtime image are all things an attacker can use if they get code execution. A minimal runtime image with none of that is harder to pivot from.
  • Cleaner separation of concerns. The build stage can install anything it wants — test runners, linters, native build tools — without any of it leaking into the artifact you ship.

Layer caching still applies

Multi-stage builds don’t change Docker’s layer caching — each stage still caches its own layers independently, and ordering instructions from least to most frequently changing still matters within each stage. Copying package.json and running npm ci before copying the rest of the source, as in the example above, means Docker can reuse the cached dependency layer on rebuilds where only application code changed, not the dependency list.

You can also target a specific stage with docker build --target build, which is useful for running tests against the build stage in CI without building the final runtime image at all.

Multi-stage builds vs. a separate Dockerfile per stage

Multi-stage buildSeparate Dockerfiles
Files to maintainOne DockerfileTwo or more, kept in sync manually
Sharing build contextAutomatic via COPY --fromRequires an intermediate image push/pull or shared volume
CI complexityOne docker build commandMultiple build steps, often scripted
Risk of driftLow — stages live togetherHigher — Dockerfiles can diverge over time

The main reason to reach for separate images instead is when a build genuinely needs to run on different infrastructure than the runtime — for instance, a build that requires GPU access that the deployed service never will. For the common case of “compile here, run there,” one Dockerfile with multiple stages is simpler to reason about and to review.

Practical tips

  • Name your stages (AS build, AS test) rather than referring to them by index — it makes COPY --from lines self-documenting and survives reordering.
  • Put your test stage between build and runtime, and have CI target it explicitly, so a failing test stage fails the pipeline before you even attempt to build the deployable image.
  • Use a minimal base for the final stage — a -slim or -alpine variant, or a distroless image if your runtime has no need for a shell at all.
  • Don’t forget to copy over runtime-only files the compiled artifact still needs, like a package.json for Node’s module resolution or static assets a server serves directly.

The takeaway

Multi-stage builds let a single Dockerfile separate “what it takes to build this” from “what it takes to run this.” Early stages get the full toolchain; the last stage gets only the finished artifacts, copied across with COPY --from. The payoff is a smaller, faster-to-pull, harder-to-exploit image — with no extra files to maintain and no build tooling shipped to production by accident.

Chisato Chisato · · 4 min read

Docker vs Podman: Container Runtimes Compared

Docker runs containers through a persistent root daemon; Podman runs them daemonless and rootless by default. What that architectural split changes.

#Docker #DevOps #Containers