Dockerfile layer caching: order matters more than you think
Order your instructions so dependency layers stay cached and only your code layer rebuilds — seconds, not minutes.
Docker does not rebuild from scratch every time — it reuses a layer cache until an instruction or its inputs change. Put COPY . . before RUN npm ci and every commit busts the dependency layer, reinstalling the internet for forty-five seconds of vanity. Put the lockfile first and only your code layer rebuilds; CI minutes and developer patience come back the same afternoon.
You will spot cache-busting order, rewrite a Node Dockerfile the fast way, and add a BuildKit cache mount so package downloads survive even when the RUN layer invalidates. For how layers are stored and squashed, see Docker in depth — caching is where that theory pays rent.
Each instruction creates a layer hash from its command plus the checksum of copied files. Change anything upstream and every downstream layer rebuilds.
The slow pattern everyone writes first
Copying the entire build context early means any README tweak, test fixture, or .git object changes the checksum Docker uses for that layer. Everything below it — dependency install, asset compilation, security scans wired as RUN steps — runs again. In CI this shows up as identical npm ci logs on every push.
FROM node:20-slimWORKDIR /appCOPY . . # any file change busts cache belowRUN npm ci # reinstalls on every commitRUN npm run buildCMD ["node", "dist/server.js"]
Lockfiles first, source last
Copy only manifest files that declare dependencies, install, then copy the rest. Python gets the same treatment with requirements.txt or pyproject.toml plus lock; Go uses go mod download after go.mod/go.sum. The volatile layer should be as late as possible.
# syntax=docker/dockerfile:1FROM node:20-slimWORKDIR /appCOPY package.json package-lock.json ./RUN --mount=type=cache,target=/root/.npm \npm ciCOPY . .RUN npm run buildCMD ["node", "dist/server.js"]
DOCKER_BUILDKIT=1 docker build -t app . => CACHED [3/6] COPY package.json package-lock.json ./ => CACHED [4/6] RUN npm ci => exporting to image — finished in 3.1s (was 48s)only COPY . . and npm run build ran freshCache mounts when the RUN layer must rebuild
Sometimes you must bust the RUN layer — a new env var, a changed build flag — but you still do not want to re-download npm's cache from npmjs.org. BuildKit's RUN --mount=type=cache keeps /root/.npm, /var/cache/apt, or /go/pkg/mod on the builder between builds even when the layer hash changes. Enable BuildKit in CI; it is default in recent Docker engines.
Add a .dockerignore that excludes .git, local node_modules, and test artifacts from the build context. Smaller contexts hash faster and prevent accidental cache busts when someone adds a large fixture file that the Dockerfile never needed. Treat context size as part of cache strategy, not an afterthought.
Where this goes next
Layer order is the free win; multi-stage and distroless shrink what you push after the build is fast. Wire BuildKit secrets for npm tokens, export SBOMs from the same pipeline, and treat cache keys in GitHub Actions or GitLab the same way — hash the lockfile, not the commit SHA alone.
Go deeper in a courseDocker in depthImages, layers, BuildKit, storage drivers, and engine internals.View course