skip to content

In a Dockerfile, why do teams copy the dependency manifest (for example package.json or pom.xml) and run the dependency install before copying the rest of the application source? What goes wrong if the whole source tree is copied first?

level: juniorimportance: must knowfreq 80%

answer

  1. Layers cached in order — one miss, all misses below
  2. COPY key = content checksum
  3. Manifest + lockfile first, install, then COPY . .
  4. Least-changing at top, most-changing at bottom
  5. .dockerignore keeps the context (and checksum) stable

basics

~20 s

Each build instruction becomes a cached layer, and once one instruction misses the cache every later one is rebuilt. Copying source first means any code edit invalidates the dependency install. Copy the manifest, install, then copy source, so installs stay cached until dependencies change.

solid answer

~50 s

An image is a stack of layers, one per build instruction. The builder reuses a cached layer only if that instruction's inputs are unchanged **and** every preceding instruction also hit the cache — invalidation cascades forward and never recovers. A `COPY` is keyed on a checksum of the copied content, so `COPY . .` misses on any source edit. If the dependency install (`npm ci`, `mvn dependency:go-offline`, `pip install -r requirements.txt`) sits after that copy, it re-runs on every commit: the slowest, most network-heavy step in the build, invalidated by a one-character change. The fix is to order instructions from least- to most-frequently changing: copy only the manifest and lockfile, run the install, then copy the source and build. The install layer is now reused until the lockfile itself changes. Pair it with a `.dockerignore` so `.git`, `node_modules` and build output never enter the build context and never churn the checksum.

code

dockerfile · 6 lines
dockerfile
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]

go deeper

for a junior

Recall the rule: instructions become layers, a miss cascades downward, so copy the manifest and install dependencies before copying source. Be able to write the corrected Dockerfile.

for a middle

Explain why COPY misses — a content checksum — and connect it to .dockerignore and lockfile determinism. Mention that the same ordering applies inside each stage of a multi-stage build.

for a senior

Frame it as ordering by rate of change, quantify the saving (network-bound install per commit versus per lockfile change), and note the correctness angle: deterministic installs so a cached layer matches the lockfile you tested.

for a principal

Treat it as a policy question — a repo template or lint rule so every service gets the layout by default, plus measurement of cache hit rate and build time to show the ordering actually pays off across the fleet.

## What the build cache is A container image is a stack of read-only filesystem layers. Most Dockerfile instructions produce one layer (`RUN`, `COPY`, `ADD`); others only change image configuration (`ENV`, `WORKDIR`, `LABEL`, `EXPOSE`). When you build, the builder walks the instructions top to bottom and, for each one, computes a cache key from the instruction and the state it is applied to. If a layer built earlier from the same key is available, the builder reuses it and prints `CACHED` instead of executing anything. ## The cascade rule The cache key of an instruction includes the identity of the layer it is applied to. That single fact drives everything else: if instruction #4 misses, then instruction #5 is being applied to a *different* parent, so its key differs too, so it must miss as well — and so on to the end of the stage. There is no such thing as a cache hit after a miss. The practical consequence is that the position of an instruction matters as much as its content: an instruction that changes often poisons every step below it. ## Why `COPY . .` is the expensive mistake For `COPY` and `ADD` from the build context, the cache key is derived from the *contents* of the files being copied (BuildKit hashes file content plus path and mode/ownership metadata), not from a timestamp. So `COPY . .` produces a new key whenever any file in the context changes — which is every commit, by definition. Consider the naive ordering: ``` FROM node:22 WORKDIR /app COPY . . RUN npm ci RUN npm run build ``` Changing one line in a component invalidates `COPY . .`, which invalidates `npm ci`, which re-downloads the entire dependency tree from the registry. A build that could take 8 seconds takes 3 minutes, on every push, for every developer and every CI job. ## Ordering by change frequency The rule of thumb: **put the things that rarely change high in the file and the things that change every commit low**. Dependencies change on the cadence of a lockfile edit — perhaps weekly. Source changes hourly. So split the copy: ``` FROM node:22 WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build ``` Now `npm ci` sits above the volatile copy. Editing source invalidates only `COPY . .` and the build step; the install layer is reused. Editing `package-lock.json` correctly invalidates the install — which is exactly what you want, because the dependency set genuinely changed. The cache is not being cheated; it is being told the truth about what each step depends on. The same shape applies in every ecosystem: `pom.xml` before `src/`, `requirements.txt` before the app package, `go.mod`/`go.sum` before the Go sources, `Cargo.toml` before `src/`. ## Supporting habits **`.dockerignore`.** The build context is everything sent to the builder. If `.git`, `node_modules`, `target/`, `dist/` and local `.env` files are included, the `COPY . .` checksum changes for reasons unrelated to your code — a fresh `git fetch` alone can do it. A `.dockerignore` keeps the context small (faster to transfer) and the checksum stable. **Lockfiles.** Cache correctness depends on the manifest fully describing the dependency set. `npm ci` against a committed lockfile is deterministic; `npm install` without one can resolve differently on a cache hit versus a cold build, producing images that differ from what you tested. **Multi-stage builds.** The same ordering discipline applies inside each stage. A builder stage compiles with the deps-first layout; the final stage copies only the produced artifact, so the runtime image is small and its layers change only when the artifact does. ## How to talk about it The crisp framing for an interview is: *layers are cached in order, a miss cascades, and `COPY` misses on any content change — so put slow, stable work above fast, volatile work.* That one sentence explains the manifest-first pattern, why `.dockerignore` matters, and why installing build tools belongs near the top of the file.

  • You reordered the Dockerfile but the install still re-runs on every build. What would you check first?
    Check the build context and `.dockerignore`. If `.git`, `node_modules` or generated output are being copied, or if the manifest glob accidentally pulls in volatile files, the copy above the install keeps changing. Also confirm the copy above the install lists only the manifest and lockfile — a stray `COPY . ./` or `COPY src ./` before the install defeats the ordering.
  • Does this ordering change anything about the final image's size or contents?
    No. The layer set and total content are the same; only build time differs, because layers are reused instead of recomputed. Splitting the copy adds one extra tiny layer for the manifest files, which is negligible. Size is governed by what you install and which stage you ship, not by cache ordering.

Like prepping a kitchen: you stock the pantry once and reuse it all week. Putting the pantry restock after 'chop today's vegetables' means re-stocking for every single meal.

saying these in an interview costs you the question

  • Believing Docker re-checks whether a RUN would produce the same output and skips it — it does not inspect results, only cache keys
  • Thinking a later instruction can hit the cache after an earlier one missed
  • Claiming `.dockerignore` only affects image size, not cache behavior
  • Using `npm install` instead of `npm ci` and assuming the cached layer still matches the committed lockfile

context