What makes a Docker build slow?
Almost always: cache invalidation caused by instruction order, not the build work itself. A Dockerfile is a list of layers, each keyed by its instruction and its inputs. When one layer’s key changes, that layer and everything after it is rebuilt. Fix the order and most slow builds stop being slow.1
The one change that fixes most builds
This rebuilds every dependency on every source edit:
COPY . .
RUN npm ci
This does not:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
Dependencies change rarely and source changes constantly, so the manifest is
copied first and installed on its own. Now editing a source file invalidates
only the final COPY. The general rule: order instructions from least to most
frequently changed.1
Copying more than you meant to
COPY . . also sends everything in the directory to the daemon — node_modules,
.git, build output, local env files. That is slow, it invalidates the cache on
files that have nothing to do with the build, and it can leak secrets into a
layer. A .dockerignore is not an optimisation here; it is part of a correct
build.1
Multi-stage: build heavy, ship light
A build needs compilers, headers and dev dependencies. A runtime needs none of them. Multi-stage builds put both in one Dockerfile and copy only the finished artefact across:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
Nothing from the build stage reaches the final image except what you name. That is a size win, and more importantly a security one — no compiler, no source, no build-time credentials in the shipped layers.1
Deleting files does not shrink an image
Layers are additive. RUN rm -rf /var/cache/... in a later instruction adds a
layer recording the deletion; the bytes are still in the image, and still in
anything that pulls it. Clean up within the same RUN that created the
mess, or better, use a build stage that never ships.1
Cache mounts
BuildKit can mount a persistent cache directory that is not part of the image:
RUN --mount=type=cache,target=/root/.npm npm ci
The package cache survives between builds without ever becoming a layer. This is usually the difference between a cold-cache CI build taking four minutes and taking forty seconds.1
You might want to explore
Lumi's weekly note
A short email when we publish something useful. No spam, unsubscribe anytime.