Why Your Docker Build Rebuilds Everything Every Time
A note before the explanation: I did not run the build in this article. Docker is not installed on the machine I wrote it on, so there is no build log to show you. What follows is a walkthrough of a real Dockerfile, with the layer behaviour reasoned from how the Docker builder works. Nothing here is presented as output I observed.
The slow build
A typical Node app Dockerfile looks innocent enough:
FROM node:24-slim
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/index.js"]
Change one line in src/index.js, run docker build again, and watch it reinstall every dependency from scratch. On a real project that is two minutes of downloading packages for a one-line change. The build is not slow because npm is slow. It is slow because the Dockerfile throws away the cache at the earliest possible moment.
How the cache actually works
A Docker image is a stack of layers, one per instruction. When you build, the builder checks each instruction against the cache, top to bottom. For a COPY, it hashes the files being copied. If the hash matches a previous build, the layer is reused. If it differs, that layer rebuilds, and every layer below it rebuilds too, whether it changed or not. There is no way to skip a layer in the middle. One miss invalidates everything beneath it.
Now look at the slow Dockerfile again. COPY . . copies the entire project, including source code, into the image before anything else happens. Any edit to any source file changes the hash of that copy. The copy layer rebuilds, and npm ci beneath it rebuilds with it, because its cache is now unreachable. The dependency install is chained to the most frequently changing thing in the project. That is the bug, and it is a one-line ordering mistake.
The fix: copy the stable stuff first
FROM node:24-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
The only change is the order. package.json and package-lock.json are copied first, on their own, and npm ci runs against them. A source edit does not touch those two files, so their hash is unchanged, the copy layer hits the cache, and npm ci hits the cache too. The build skips straight to COPY . . and rebuilds only the application layer and the build step. The two-minute dependency install runs only when dependencies actually change, which is how often it should run.
Two details make or break this. First, copy the lockfile alongside package.json. Without it, npm ci refuses to run at all, and a plain npm install resolves fresh versions each build, which defeats the cache in a quieter way. Second, add a .dockerignore with node_modules, .git, and any local build output. Without it, COPY . . drags your local node_modules into the build context, which slows the copy and can leak your machine's binaries into the image.
The pattern underneath
The rule is one sentence: order your Dockerfile from the least frequently changing layer to the most frequently changing. Base image, system packages, dependency manifests, dependency install, source code, build. Each layer should change less often than the one below it. When you get the order right, the common build, a source edit, reuses everything above it and finishes in seconds.
There is one more step worth knowing, though it is beyond this fix. A multi-stage build compiles the app in one stage and copies only the built output into a slim final image, so the final image carries no compiler, no source, and no dev dependencies. That is a separate optimisation. Get the layer order right first. On most projects it is the difference between a build you run without thinking and a build you avoid running at all.
Comments
Join the discussionNo comments yet. Be the first to comment.
