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
and shipping the same fat image — dev dependencies, build toolchain, cached npm packages, and all.1node:20
Multi-stage builds fix this without any extra scripts or CI magic. The idea is to use multiple
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.1FROM
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 /app/dist ./dist 25 26EXPOSE 3000 27CMD ["node", "dist/index.js"] 28
The
flag in the second stage is where the magic happens. Docker copies1--from=builder
out of the builder image — nothing else. TypeScript, ts-node, jest, the full1/app/dist
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.1node_modules
Why Alpine?
is the Alpine Linux base, which clocks in around 7 MB versus ~170 MB for the Debian-based1node:20-alpine
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
(Debian slim) is a safer fallback — Alpine uses musl libc, which occasionally causes issues with native modules.1node:20-slim
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 /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 /app/.next/standalone ./ 20COPY /app/.next/static ./.next/static 21COPY /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
(Node File Trace). A medium-sized Next.js app often ends up under 300 MB — compared to 2+ GB if you just1nft
into a standard image and ship it.1COPY . .
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
and run1package*.json
before copying the rest of your source:1npm ci
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
first, changing any source file invalidates the npm install layer. On a team with frequent commits, that means your CI instance runs a full1COPY . .
on every push — even when no dependency changed.1npm ci
Build Arguments and Secrets
Multi-stage builds work cleanly with
and1ARG
. A common pattern is passing a build-time token to pull from a private registry or private npm package:1--secret
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
is deleted in the same1.npmrc
instruction that created it, it is never baked into a layer that ships. The final1RUN
stage never sees the token at all because it only1runner
s the compiled output.1COPY --from=builder
For more sensitive secrets (SSH keys, AWS credentials), use Docker's
mount instead — it doesn't persist in any layer:1--secret
1RUN 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
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 same1docker history
instruction.1RUN
For a detailed breakdown,
(or1docker scout cves myapp:latest
if you have the1dive myapp:latest
tool) shows exactly which files live in each layer and flags any unused content.1dive
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
line costs nothing at runtime — only the final stage ships.1FROM
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.
