DEV Community

Amit Shukla
Amit Shukla

Posted on

Multi-stage Docker builds: ship the artifact, not the build shop

Most Node images I inherit are somewhere north of a gigabyte. The app inside is maybe 40 MB. The other gigabyte is the build shop: compilers, dev dependencies, the npm cache, a full copy of the source, and whatever the base image shipped with.

That gigabyte costs you every day. Slower pulls on every deploy and every autoscale event. More layers for your scanner to chew through. A bigger surface for a CVE to land on. And a longer gap between "push" and "running in production".

Multi-stage builds fix this without touching your application code. Here is how they work and how to get the details right.

What ends up in a single stage image

When you write a normal Dockerfile, every instruction adds a layer, and every layer stays in the final image. A typical Node build looks like this:

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

The final image now contains:

  • node:20, which is Debian plus the full Node toolchain, around 1.1 GB before you add anything
  • every dependency, including the dev ones you only need to compile
  • the npm cache left behind by npm ci
  • your entire source tree, not just the compiled output
  • any build artifacts you generated on the way

You run node dist/server.js. Everything else is dead weight that ships anyway.

The multi-stage pattern

A multi-stage Dockerfile has more than one FROM. Each FROM starts a new stage with its own filesystem. You do the messy work in an early stage, then start a clean final stage and copy in only what you need with COPY --from.

FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

Two things changed:

  1. The first stage is named build with AS build. It installs everything and compiles. None of its layers reach the final image.
  2. The second stage starts from node:20-slim, a much smaller base. It installs production dependencies only, then copies the compiled dist folder out of the build stage.

The compilers, the dev dependencies, and the source tree never make it into the image you ship. Same build, a fraction of the size.

Get these four details right

Name your stages

Use AS <name> on stages you copy from, then reference them in COPY --from=<name>. You can also copy from an external image directly, for example COPY --from=nginx:latest /etc/nginx/nginx.conf ./, which is handy for grabbing a single binary or config file.

Do not copy node_modules across stages

It is tempting to COPY --from=build /app/node_modules ./node_modules and skip a second install. Do not. The build stage has dev dependencies mixed in, and native modules may have compiled against a different base image. Run npm ci --omit=dev in the final stage so you get a clean production tree that matches the runtime.

Order instructions so the cache survives

Docker caches each layer and reuses it until something upstream changes. Copy your lockfiles and install before you copy the rest of the source:

COPY package*.json ./
RUN npm ci
COPY . .
Enter fullscreen mode Exit fullscreen mode

Now a change to your application code invalidates only the COPY . . layer and everything after it. The dependency install, which is the slow part, stays cached. Reverse those lines and every one line code change reinstalls everything.

Go smaller on the final base

node:20-slim drops most of the Debian extras. For a bigger cut, gcr.io/distroless/nodejs20 ships Node and nothing else, no shell, no package manager. It is stricter to debug but the attack surface is tiny. Whatever you pick, add a non-root user:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Enter fullscreen mode Exit fullscreen mode

The same feature, other uses

Multi-stage is not only about size.

One Dockerfile, several targets. Add a test stage and build just that stage in CI with docker build --target test .. Your test image and your production image come from the same file and the same base layers.

FROM node:20 AS build
# ... install and compile ...

FROM build AS test
RUN npm run lint && npm test

FROM node:20-slim AS production
# ... copy artifact ...
Enter fullscreen mode Exit fullscreen mode

Pulling a tool without installing it. Need dockerize or a specific CLI at runtime? Copy the single binary from its published image in a stage instead of running a package manager in your final image.

What this looks like in numbers

On a plain Express and TypeScript service:

  • Single stage on node:20: about 1.1 GB
  • Multi-stage with node:20-slim and --omit=dev: about 180 MB
  • Multi-stage with distroless: about 130 MB

Pull time on a cold node drops from tens of seconds to a few. Your scanner has less to report. And nothing in the image can compile code or run a shell that does not need to.

Takeaway

Build with the full toolchain in an early stage. Start the final stage from the smallest base you can debug, install production dependencies only, and copy in just the artifact. Name your stages, keep your install layer above your source copy, and run as a non-root user. It is a ten line change to your Dockerfile and it pays off on every deploy.

Top comments (0)