Docker Multi-Stage Builds: Smaller Images, Faster Deploys

5 min read

Docker Multi-Stage Builds: Smaller Images, Faster Deploys

When I first containerized a Node.js app, the image came out at 1.4 GB. It worked, but deploying it meant pushing a gigabyte of bits every time I changed a line of code. The culprit was simple: I was building inside

1node:20
and shipping the same fat image — dev dependencies, build toolchain, cached npm packages, and all.

Multi-stage builds fix this without any extra scripts or CI magic. The idea is to use multiple

1FROM
instructions in one Dockerfile. Each stage can copy artifacts from a previous stage, and only the last stage ships. Everything else — compilers, test runners, node_modules for build — gets discarded automatically.

A Minimal Node.js Example

Here is the pattern I now use for every Node service:

1# ── Stage 1: install & build ─────────────────────────────────────────────────
2FROM node:20-alpine AS builder
3
4WORKDIR /app
5
6COPY package*.json ./
7RUN npm ci --include=dev
8
9COPY . .
10RUN npm run build          # e.g. tsc, next build, vite build…
11
12
13# ── Stage 2: production runtime ──────────────────────────────────────────────
14FROM node:20-alpine AS runner
15
16ENV NODE_ENV=production
17WORKDIR /app
18
19# Only production dependencies
20COPY package*.json ./
21RUN npm ci --omit=dev && npm cache clean --force
22
23# Copy only the compiled output from the builder stage
24COPY --from=builder /app/dist ./dist
25
26EXPOSE 3000
27CMD ["node", "dist/index.js"]
28

The

1--from=builder
flag in the second stage is where the magic happens. Docker copies
1/app/dist
out of the builder image — nothing else. TypeScript, ts-node, jest, the full
1node_modules
for dev — gone. The resulting image is usually under 200 MB for a typical API server, compared to 1+ GB with a naive single-stage build.

Why Alpine?

1node:20-alpine
is the Alpine Linux base, which clocks in around 7 MB versus ~170 MB for the Debian-based
1node:20
image. I use Alpine for both stages because the build tools I need (npm, Node, shell) are already there. For stages that involve compiled native addons or glibc-specific binaries,
1node:20-slim
(Debian slim) is a safer fallback — Alpine uses musl libc, which occasionally causes issues with native modules.

A Next.js Standalone Example

Next.js has built-in support for a standalone output mode that bundles only the files needed to run the server. Paired with multi-stage builds, you get very lean images:

1FROM node:20-alpine AS deps
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci
5
6
7FROM node:20-alpine AS builder
8WORKDIR /app
9COPY --from=deps /app/node_modules ./node_modules
10COPY . .
11RUN npm run build
12
13
14FROM node:20-alpine AS runner
15ENV NODE_ENV=production
16WORKDIR /app
17
18# Next.js standalone output bundles its own node_modules subset
19COPY --from=builder /app/.next/standalone ./
20COPY --from=builder /app/.next/static ./.next/static
21COPY --from=builder /app/public ./public
22
23EXPOSE 3000
24CMD ["node", "server.js"]
25

Enable standalone mode in

1next.config.js
:

1/** @type {import('next').NextConfig} */
2const nextConfig = {
3  output: 'standalone',
4}
5
6module.exports = nextConfig
7

The standalone output copies only the minimal node_modules needed to run the server, which Next.js calculates with

1nft
(Node File Trace). A medium-sized Next.js app often ends up under 300 MB — compared to 2+ GB if you just
1COPY . .
into a standard image and ship it.

Caching Layers Effectively

Docker builds each instruction as a layer and caches the result. A layer is only rebuilt when its inputs change. The single most impactful caching trick is to copy

1package*.json
and run
1npm ci
before copying the rest of your source:

1# Good — npm ci only reruns when package.json or package-lock.json changes
2COPY package*.json ./
3RUN npm ci
4
5COPY . .
6RUN npm run build
7

If you

1COPY . .
first, changing any source file invalidates the npm install layer. On a team with frequent commits, that means your CI instance runs a full
1npm ci
on every push — even when no dependency changed.

Build Arguments and Secrets

Multi-stage builds work cleanly with

1ARG
and
1--secret
. A common pattern is passing a build-time token to pull from a private registry or private npm package:

1FROM node:20-alpine AS builder
2ARG NPM_TOKEN
3RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
4COPY package*.json ./
5RUN npm ci
6RUN rm -f .npmrc       # remove before the layer is cached to disk
7
8COPY . .
9RUN npm run build
10

Since

1.npmrc
is deleted in the same
1RUN
instruction that created it, it is never baked into a layer that ships. The final
1runner
stage never sees the token at all because it only
1COPY --from=builder
s the compiled output.

For more sensitive secrets (SSH keys, AWS credentials), use Docker's

1--secret
mount instead — it doesn't persist in any layer:

1RUN --mount=type=secret,id=npmrc,dst=/root/.npmrc npm ci
2

Checking Your Work

After building, check the final image size and layers:

1docker build -t myapp:latest .
2docker images myapp
3docker history myapp:latest
4

1docker history
shows every layer and its size. If a layer is unexpectedly large, it usually means a cache wasn't invalidated where you expected, or a temp file wasn't cleaned up in the same
1RUN
instruction.

For a detailed breakdown,

1docker scout cves myapp:latest
(or
1dive myapp:latest
if you have the
1dive
tool) shows exactly which files live in each layer and flags any unused content.


Multi-stage builds are one of those Docker features where once you start using them, you can't imagine going back. Smaller images mean faster pushes, faster pulls, a smaller attack surface, and lower storage costs on your registry. The Dockerfile gets a bit longer, but the extra

1FROM
line costs nothing at runtime — only the final stage ships.

If you're running containers in your homelab, on Fly.io, or anywhere that bills by storage or transfer, this is the first optimization worth making.