You fix a one-line bug in server.js, run docker build -t app ., and settle in for the wait. Step 4/9 : RUN npm install starts from scratch — all 1,400 packages, 90 seconds, every single build. Multiply by the eight builds your CI pipeline does per day and the team is paying an hour of compute daily to reinstall left-pad.
The mirror-image version is worse. Your build "completes" suspiciously fast, you docker run the container, hit the endpoint, and the bug is still there. You rebuild with --no-cache, still there. Twenty minutes later you realize COPY . . is grabbing a stale node_modules from your laptop into the build context, and the code you're editing isn't the code in the image at all. The cache didn't ignore your change — your Dockerfile handed it the wrong inputs.
Putting COPY . . (or COPY src/) at the top of your Dockerfile. Every build then invalidates everything below it because the layer's input (the whole source tree) has a new hash.
The mental model is seductive because Dockerfile looks like a shell script, and in a shell script order is just "top to bottom". So you write the Dockerfile in reading order: get my code in first, then do things with it. It even works — for the week when you're only touching Dockerfile-adjacent stuff. It feels like a build tool problem ("Docker is slow"), not a your-instruction-order problem, so teams tune CI parallelism and shard caches for months before anyone looks at step 3. The fix was a two-line reorder the whole time.
Copy only the manifest files first, install dependencies, then copy the source:
# 1. Manifests first — changes only when dependencies change
COPY package.json package-lock.json ./
RUN npm ci
# 2. Source code — changes whenever you edit a file
COPY . .
# 3. Build
RUN npm run build
Walk through it:
COPY package.json package-lock.json ./ copies only the two files that describe your dependencies. This layer's cache checksum only changes when you actually add, remove, or upgrade a package.RUN npm ci installs exactly what the lockfile pins (that's what ci means — clean, reproducible, lockfile-driven). It re-runs only when the manifest layer beneath it changed.COPY . . brings in the source — changing constantly, but now it sits after the install step, so it can only invalidate the build step, never the install.RUN npm run build still re-runs on every code change, which is correct: it depends on your source.Keep the build context honest too — add a .dockerignore at the repo root:
.git
node_modules
dist
Without it, COPY . . ships your local node_modules (hundreds of MB, machine-specific binaries) and your .git history into the context. That bloats context uploads and — worse — a locally modified node_modules can end up shadowing what npm ci installed, which is exactly how "my change isn't in the container" happens.
Now npm ci only re-runs when package.json or package-lock.json actually changes. Code edits reuse the cached layer.
Docker builds each instruction into a layer. The layer's cache key comes from the instruction itself plus the hash of its inputs: for COPY/ADD, the builder computes a checksum from the metadata of the copied files (and not their mtime alone — touching a file without changing it doesn't invalidate); for RUN, the command string plus the parent layer's state is the key. If any input changes, the layer is rebuilt — and every layer after it must also rebuild, because each layer's diff is applied on top of the previous one. Cache hits are only possible in an unbroken chain from the base image down.
So the ordering rule falls out mechanically: COPY . . re-checksums your entire source tree on every build, and its position in the file determines how many layers its invalidation cascades through. By separating "rarely changes" (base image, manifests) from "always changes" (your source), you maximize the length of the unbroken cached chain above the churn point.
# Multi-stage pattern (Node example)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]
# Pair with a .dockerignore containing: .git, node_modules, dist
.dockerignore to keep .git, node_modules, and tests/ out of the build context — they invalidate the cache for no reason.docker build --progress=plain once and read which steps say CACHED — if npm ci isn't, your ordering is still wrong.docker buildx build --cache-from (with --cache-to) in CI to share layers across builds.Get a fresh guide every Monday. £12/mo, £100/yr, or £250 lifetime.