Dockerfile Layer Cache: Why `COPY . .` Before `npm install` Costs You 4 Minutes Per Build
Docker · Intermediate · 6 min read · published
What this solves: Your Docker builds reinstall every dependency from scratch whenever you change a single source file, turning a 15-second rebuild into a multi-minute one. This is about the layer ordering (and cache mount) rules that decide whether the install step is reused.
The Problem
A Node service, 900 dependencies, 180 MB of node_modules. On your laptop, a fresh docker build takes 4m10s — fine, you expect that once. Then you fix a typo in src/routes/health.ts and rebuild: 4m05s again. Change a comment: 4m05s. Twenty rebuilds during an afternoon of debugging is 80 minutes of watching npm ci re-resolve dependencies that did not move a single version.
The Dockerfile looks completely reasonable:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]
Every build log says npm ci is running from scratch. Nothing is marked CACHED past WORKDIR.
Why the Obvious Fix Falls Short
The first instinct is "the cache is broken — add a .dockerignore." And you should: excluding node_modules, .git, and dist shrinks the build context and stops local artifacts from thrashing the checksum. It genuinely helps. But it does not fix this. Your source files did change; you changed them. No ignore file will make a modified .ts file look identical.
The second instinct is --no-cache=false, DOCKER_BUILDKIT=1, or bumping the Docker version. None of those matter either, because the problem isn't cache availability — it's cache position. Docker's layer cache is a chain, not a set. A layer's cache key is derived from its parent's key plus its own instruction (and, for COPY/ADD, a checksum of the copied files). Once any link breaks, every subsequent layer is rebuilt unconditionally — even if that later instruction's own inputs are byte-for-byte identical.
COPY . . sits above npm ci. So the single most volatile input in your repo — all your source code — is being used as the cache key for your most expensive step. That's the entire bug. It's an ordering problem masquerading as a caching problem.
How It Actually Works
BuildKit computes a content-addressed key per instruction, chained from the previous one. For RUN, the key is basically hash(parent_key + command_string) — BuildKit does not inspect what the command reads or writes. For COPY, it's hash(parent_key + file_contents_checksum). That asymmetry is the lever: RUN npm ci will happily hit cache forever, as long as nothing before it changed.
flowchart TD
A["FROM node:22-alpine<br/>key=k0"] --> B{"next instruction"}
B -->|"BAD: COPY . .<br/>key=hash(k0 + checksum of ALL src)"| C["src edited →<br/>checksum changes →<br/>MISS"]
C --> D["RUN npm ci<br/>parent key changed →<br/>forced MISS (4 min)"]
B -->|"GOOD: COPY package*.json<br/>key=hash(k0 + checksum of lockfile)"| E["lockfile unchanged →<br/>HIT"]
E --> F["RUN npm ci<br/>parent HIT + same cmd →<br/>CACHED (0s)"]
F --> G["COPY . .<br/>src changed → MISS"]
G --> H["RUN npm run build<br/>MISS (12s)"]
Mental model: each instruction is a gate that can only be as cacheable as the gate above it. So sort your Dockerfile by rate of change — base image, system packages, lockfile, dependency install, then finally application source. Volatile inputs go last.
Second lever: when the lockfile does legitimately change, you still eat a full re-download. BuildKit cache mounts fix that by giving the RUN step a persistent, non-layered directory:
RUN --mount=type=cache,target=/root/.npm npm ci
The mount isn't part of the image or the layer key — it's scratch space that survives across builds, so npm re-links from warm tarballs instead of hitting the registry. Same pattern works for ~/.cache/pip, /root/.m2, /go/pkg/mod, and ~/.cargo/registry.
Before and After
# BEFORE: source code is the cache key for dependency install
FROM node:22-alpine
WORKDIR /app
COPY . . # <-- any file edit invalidates everything below
RUN npm ci # 4 minutes, every single build
RUN npm run build
CMD ["node", "dist/server.js"]
# AFTER: lockfile-only copy above install + cache mount + prod-only final stage
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./ # only changes when deps change
RUN --mount=type=cache,target=/root/.npm npm ci # cached; warm even on a miss
FROM deps AS build
COPY . . # volatile input moved to the bottom
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=build /app/dist ./dist
ARG GIT_SHA # volatile ARG declared last, not first
LABEL org.opencontainers.image.revision=$GIT_SHA
CMD ["node", "dist/server.js"]
Code-only rebuild: 4m05s → ~12s.
When NOT to Use This
- Ephemeral CI with no cache export. Reordering buys nothing on a runner with an empty layer store. You need
--cache-from/--cache-to(type=gha,type=registry, or a persistent buildx builder) first; ordering only pays off once a cache exists. - Reproducible / security-audited builds. Aggressive caching can silently pin a stale
apt-get updateindex or an old transitive dependency. For release builds,--no-cacheor a pinned base digest plus periodic scheduled cache busting is the right call. - Monorepos where every service shares one lockfile. Any dependency change in any package invalidates all of them. There, per-package pruning (
turbo prune,nx, or Bazel) does more than Dockerfile ordering can. - Tiny dependency trees. If install takes 4 seconds, a three-stage Dockerfile is complexity you're paying for nothing.
Gotchas
COPY package*.json ./silently skips missing files. If a glob matches nothing, the layer still succeeds and your install runs against no lockfile. List files explicitly in CI-critical paths.ARGdeclared before an instruction invalidates everything after it. AARG BUILD_TIMEorARG GIT_SHAat the top of the file changes per commit and nukes the entire cache chain. Declare metadata ARGs immediately before theirLABEL.ADD https://...andgit clonedon't participate meaningfully in cache checks.ADDfrom a URL only compares metadata in some versions; aRUN git clonehas a fixed command string, so it caches forever and you'll ship a months-old commit. Pin refs and pass them as build args at the point of use.- Cache mounts are not shared across concurrent builds by default. Two parallel builds hitting the same
target=/root/.npmcan contend; usesharing=lockedfor tools that aren't concurrency-safe (--mount=type=cache,target=/root/.npm,sharing=locked). cache-to: type=ghawithoutmode=maxonly exports the final layer. You'll see partial hits and wonder why the install step still misses.- The image without
.dockerignoremay still be slow even with perfect ordering — sending a 2 GB context (including localnode_modulesand.git) to the daemon takes seconds before any instruction runs, and changes to ignored-but-not-ignored files perturb theCOPY . .checksum needlessly.
Key takeaway: Order Dockerfile steps from least-frequently-changed to most-frequently-changed, copy only the lockfile before installing, and use BuildKit cache mounts so even a real cache miss doesn't re-download the world.
Real-world challenge
Locally, rebuilds after a code change take 12 seconds. In GitHub Actions the same Dockerfile takes 3m40s on every single push, even when only a README changed. The Dockerfile already copies package.json before running npm ci, and the CI log shows `npm ci` running in full each time with no `CACHED` lines anywhere. Diagnose it.
Diagnosis: the Dockerfile is fine — the builder has no cache. Each CI runner is a fresh VM with an empty local layer store, so there is nothing to hit. Layer caching is a property of the daemon/builder, not of the Dockerfile.
Check for these three signals:
- Zero
CACHEDlines in the log → empty cache, not bad ordering. docker buildwithout--cache-from/ without a persistent BuildKit builder.ARG GIT_SHAorARG BUILD_TIMEdeclared before the install step — that alone invalidates everything on every commit even when cache exists.
Fix: give the builder an external cache and move volatile ARGs to the bottom.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: repo/app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max # mode=max also exports intermediate layers
# ARG for metadata declared AFTER install/build so it can't poison earlier layers
ARG GIT_SHA
LABEL org.opencontainers.image.revision=$GIT_SHA
Also add RUN --mount=type=cache,target=/root/.npm npm ci so a genuine lockfile change re-links packages from a warm tarball cache instead of re-downloading the registry.