← One-Page Dev Guides

Docker: why your image rebuild ignores your code change

📅 2026-09-08 📊 intermediate ["docker" "dockerfile" "build" "caching"]

The Problem

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.

The Mistake

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.

The Fix

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:

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.

Why It Works

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.

Copy-Paste

# 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

Next Steps

Download PDF (1 page)

Get a fresh guide every Monday. £12/mo, £100/yr, or £250 lifetime.

See pricing →