Multi-stage Docker builds that actually cache

Most Node Dockerfiles reinstall the world on every code change. A two-line reorder fixes it.

12 min read Docker

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:

Dockerfile
# ---- 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:

Dockerfile
# ---- runtime ----
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /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:

multi-stage build
# a multi-stage Dockerfile, simulated. try 'docker build -t app .'
~/app $
Tip

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.