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

Gotchas

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:

  1. Zero CACHED lines in the log → empty cache, not bad ordering.
  2. docker build without --cache-from / without a persistent BuildKit builder.
  3. ARG GIT_SHA or ARG BUILD_TIME declared 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.