Multi-stage Docker builds that actually cache
Most Node Dockerfiles reinstall the world on every code change. A two-line reorder fixes it.
The first Dockerfile everyone writes copies the whole project in, runs npm install, and builds. It works — and it’s agonizingly slow, because changing a single line of source code busts the cache on the dependency install. You wait three minutes to rebuild what didn’t change.
The whole game with Docker layer caching is ordering things from least to most frequently changed. Your dependencies change weekly; your code changes every save. So they must not live in the same layer.
Copy the manifest before the source
This is the entire trick. Copy package*.json, install, then copy the rest:
# ---- builder ----
FROM node:22-alpine AS builder
WORKDIR /app
# changes weekly → cached across most builds
COPY package*.json ./
RUN npm ci
# changes every commit → only this layer rebuilds
COPY . .
RUN npm run build Because COPY package*.json happens before COPY . ., editing a source file leaves the npm ci layer untouched. Docker reuses it. Installs drop from minutes to zero on the common path.
Ship the dist, not the toolchain
The second stage starts from a clean image and copies only the built output — no dev dependencies, no source, no compiler:
# ---- runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY /app/dist ./dist
USER node
CMD ["node", "dist/index.js"] The final image carries production deps and compiled JS — nothing else. Watch the layers resolve:
Add a .dockerignore with node_modules and dist. Otherwise COPY . .
drags your host’s node_modules into the image and silently busts the cache
you just worked to build.
The payoff
A clean dependency layer plus a slim runtime stage gets you fast incremental builds and a small, attack-surface-minimal image. The Docker output above is illustrative — but the layer ordering is the real, load-bearing idea.