Skip to content
docktionary

Docker

Multi-stage builds

A single-stage Dockerfile ships whatever it took to build your app, not just the app. Multi-stage builds are how you separate the two without leaving Docker.

The problem

A Dockerfile runs top to bottom in one filesystem. If building your app needs a compiler, a full node_modules tree with dev dependencies, or a copy of the source code to compile from, all of that is sitting in the image's layers right alongside your app — whether or not the running container ever needs it.

Dockerfile — before
FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]

This works. It also ships the full node:22 base (compilers, headers, a package manager), every dev dependency npm run build needed, and your raw source — none of which the running server touches. A production image built this way is commonly several times larger than it needs to be, and every extra tool in it is one more thing a vulnerability scanner will flag, and one more thing an attacker could use if they get a shell.

What a stage actually is

Every FROM in a Dockerfile starts a new, independent build stage with its own filesystem. Nothing carries over between stages automatically — a file created in one stage simply doesn't exist in the next one, unless you explicitly copy it across. A Dockerfile with three FROM lines is really three separate mini-builds, stitched together by whatever files you choose to pass from one to the next.

dockerfile
FROM node:22 AS build
# this stage's filesystem starts empty, based on node:22

FROM node:22-alpine
# this stage's filesystem starts empty too, based on node:22-alpine --
# it knows nothing about what happened in "build" above

Pulling files across with COPY --from

COPY --from=<stage> reaches into a stage's filesystem — at the point that stage finished — and copies files out of it into the current stage. It's the only thing that moves data between stages, and it only moves exactly what you tell it to.

dockerfile
FROM node:22 AS build
WORKDIR /app
RUN npm run build     # produces /app/dist

FROM node:22-alpine
COPY --from=build /app/dist ./dist   # only /app/dist crosses over

Everything else that existed in build — the compiler, the dev dependencies, the raw source, every intermediate file npm run build left behind — stays behind in that stage and never reaches the final image.

Only the last stage ships

docker build produces exactly one image: whatever the last stage in the Dockerfile ends up as (or whichever stage you point --target at). Every stage before it is real — BuildKit builds it, runs its commands, caches its layers — but none of those layers are part of the final image unless a later stage explicitly copied something out of them.

Dockerfile
# --- stage 0: everything needed to build, nothing that ships ---
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# --- stage 1: the actual image docker build produces ---
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

The result: a final image built from the small node:22-alpine base, holding only the compiled output and the production dependencies it needs at runtime — no compiler, no dev dependencies, no source tree. In practice this kind of rewrite commonly takes a Node image from well over a gigabyte down to a couple hundred megabytes, with nothing removed by hand and nothing to remember to clean up.

Before multi-stage builds existed

This problem predates the feature. Before Docker 17.05 (2017), the usual workaround was two separate Dockerfiles and a shell script: build an image, run a container from it, docker cp the compiled output out to the host, then build a second, minimal image COPY-ing that output in — with the build logic living outside Docker entirely, in whatever script glued the steps together. Multi-stage builds fold that whole pipeline into one Dockerfile, so the build recipe for an image travels with the image instead of living in a separate script someone has to keep in sync.

Where to go next

For the exact syntax — referencing a stage by number, copying from an image you never build, branching multiple final images off a shared base, and using --target — see the multi-stage section of the Dockerfile reference. Or skip straight to building one: the Dockerfile builder has a one-click multi-stage preset, with an --from picker for every COPY once you add a second stage.