A client's Node.js API image had grown to 1.2GB, which made deploys slow and meant every CI run was shipping a gigabyte of layers nobody needed at runtime. None of this required exotic tooling — just separating "what I need to build the app" from "what I need to run it," which is exactly what multi-stage builds are for.
The starting point
The original Dockerfile was a single stage based on the full node:20 image, installing dependencies, build tools, and dev dependencies all in one layer that shipped to production untouched:
Dockerfile — before
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]
Every dev dependency, every build tool, the full npm cache, and the entire node:20 base image (which itself bundles a lot that a running server never touches) all shipped together.
Stage one: build dependencies
The fix splits the Dockerfile into named stages. The first stage gets everything needed to build the application, and nothing from it survives into the final image unless explicitly copied forward:
Dockerfile — build stage
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production
Copying package*.json before the rest of the source matters for layer caching — as long as dependencies don't change, Docker reuses the cached npm ci layer on rebuilds, even when application code changes constantly.
Stage two: the runtime image
The second stage starts fresh from a minimal base and copies across only the compiled output and production dependencies — none of the build tools, dev dependencies, or source TypeScript that the first stage needed:
Dockerfile — runtime stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Switching the base from node:20 to node:20-alpine for the final stage alone accounts for a large share of the size reduction — Alpine's musl-based image is a fraction of the size of the Debian-based default, and for a typical Node.js app the compatibility gap rarely matters.
USER node in the final stage also means the container runs as a non-root user by default — a small line that removes an entire category of "what if this container gets compromised" concerns.
Before and after
| Before | After | |
|---|---|---|
| Image size | 1.2 GB | 83 MB |
| Layers shipped | single stage, everything | 2 stages, runtime only |
| Cold pull time (CI) | ~38s | ~6s |
What didn't help
It's worth saying what didn't move the needle, since multi-stage builds get credited with wins that actually came from elsewhere. Squashing layers manually with --squash made no measurable difference once the multi-stage split was already in place — most of the weight was already gone by that point. And switching package managers (npm to pnpm) saved disk space in the build stage, but since none of that stage ships to the final image anyway, it had zero effect on the final image size.
The size reduction came almost entirely from two changes: not shipping build tools and dev dependencies, and switching the final base image to Alpine. Everything else was optimizing a stage that never reaches production in the first place.