Skip to content
BytePatterns

Docker Image Layers and Build Cache: Why Dockerfile Order Matters

8 min readBytePatterns

How Docker image layers and the build cache work, why one edited file can reinstall every dependency, and how multi-stage builds ship only the build output.

You change one line of application code, run docker build, and watch it download every dependency again. Nothing is broken; the Dockerfile is simply in the wrong order. The fix takes two lines, and the reason behind it is the one rule the whole build cache runs on. Everything below comes from the Docker documentation pages listed at the end, as of September 2026.

The problem it solves

An image is built from a Dockerfile, one instruction at a time. Rebuilding everything on every change would be slow, so Docker keeps a build cache and reuses the result of any step it can prove has not changed. The questions an interviewer is really asking are:

  • What exactly gets reused, and when?
  • Why does a small edit sometimes throw the whole cache away?
  • How do you keep compilers and build tools out of the image you ship?

The intuition

The build cache page puts it plainly: each instruction in the Dockerfile translates to a layer, and the layers form a stack, each one built on top of the previous one. Then comes the rule that explains everything else: once a layer changes, all the layers after it must be rebuilt too, whether or not their own output would differ.

What counts as "changed" depends on the instruction:

  • COPY and ADD compare a checksum of the files being copied. The file's modification time is not part of it, so touching a file without changing it does not bust the cache.
  • RUN is not re-examined between builds. For RUN apt-get -y update, the builder compares the command string, not the files the command would fetch. A cached RUN stays cached until something before it changes or you build with --no-cache.

So the order of the Dockerfile is a bet on how often each line changes. The base image changes rarely, the dependency manifest occasionally, the source code constantly. Put them in that order and a code edit only rebuilds the tail of the stack.

The second idea is the multi-stage build. Each FROM starts a new stage, possibly on a different base. A later stage can COPY --from an earlier one and take only what it names. The final image is the last stage: its base plus whatever it copied in. The toolchain stays behind.

Watch it run

The animation builds a five-layer Dockerfile, then edits one source file. The COPY . . layer and the one after it rebuild; the three above come straight from cache. Then it swaps the order, copying the source before npm ci, and the same edit reinstalls every dependency. The last frames add a second FROM and show what actually ships.

Images, Layers & Multi-Stage Builds

Step 1 of 11

Each Dockerfile instruction adds a layer: an immutable set of file changes, stacked in order.

The same interactive animation as the lesson — step through it with the controls.

The code

The lesson's Dockerfile, in cache-friendly order and in two stages:

# illustrative
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

A toy model of the documented rules, not Docker itself. A step is reused only when its own inputs and everything before it match; chaining each key to its parent's key is what makes one change invalidate everything downstream:

import hashlib

def inputs(step, files):
    """What a step's cache check looks at, in this toy model: the instruction
    text, plus for COPY a checksum of the copied files (mtime not included)."""
    if step.startswith("COPY"):
        sources = step.split()[1:-1]
        picked = sorted(f for f in files if "." in sources or f in sources)
        return step + "|" + ",".join(f + ":" + files[f] for f in picked)
    return step                                  # RUN: the command text alone

def build(dockerfile, files, cache):
    """Returns the indexes of the steps that had to run."""
    parent, ran = "", []
    for i, step in enumerate(dockerfile):
        k = hashlib.sha256((parent + "|" + inputs(step, files)).encode()).hexdigest()
        if k not in cache:
            cache.add(k)
            ran.append(i)
        parent = k                               # a new parent means a new key
    return ran

good = ["FROM node:22",
        "COPY package.json package-lock.json ./",
        "RUN npm ci",
        "COPY . .",
        "RUN npm run build"]
files = {"package.json": "v1", "package-lock.json": "v1", "server.js": "v1"}

cache = set()
print(build(good, files, cache))             # [0, 1, 2, 3, 4]
files["server.js"] = "v2"                    # edit one source file
print(build(good, files, cache))             # [3, 4]  npm ci stays cached
files["package-lock.json"] = "v2"            # change a dependency
print(build(good, files, cache))             # [1, 2, 3, 4]

The same edit against the source-first order, and the RUN caveat: an unchanged command string is a cache hit even if the outside world moved on:

bad = ["FROM node:22", "COPY . .", "RUN npm ci", "RUN npm run build"]
cache = set()
build(bad, files, cache)
files["server.js"] = "v3"
print(build(bad, files, cache))              # [1, 2, 3]  npm ci runs again

apt = ["FROM debian", "RUN apt-get update", "RUN apt-get install -y curl"]
cache = set()
build(apt, {}, cache)
print(build(apt, {}, cache))                 # []  both RUN steps reused as-is

The model against a reference that skips hashing entirely: compare each step's inputs between two builds, and everything from the first difference onwards must rerun. Three thousand random Dockerfiles and edits:

import random

def reference(dockerfile, old_files, new_files):
    for i, step in enumerate(dockerfile):
        if inputs(step, old_files) != inputs(step, new_files):
            return list(range(i, len(dockerfile)))
    return []

random.seed(15)
pool = ["RUN make", "RUN test", "COPY a ./", "COPY b ./", "COPY a b ./", "COPY . ."]
ok = True
for _ in range(3000):
    df = ["FROM base"] + random.choices(pool, k=random.randint(0, 6))
    old = {f: "v1" for f in "abc"}
    new = {f: random.choice(["v1", "v2"]) for f in "abc"}
    cache = set()
    build(df, old, cache)
    ok &= build(df, new, cache) == reference(df, old, new)
print(ok)                                    # True

The complexity

Here the costs are build time and image size:

  • Rebuild time is the cost of the first changed step plus everything after it. Ordering by change frequency keeps the expensive install above the line that changes most.
  • Cache mounts, RUN --mount=type=cache,target=..., keep a package cache between builds, so even a rebuilt install step only downloads new or changed packages.
  • Build context is everything sent to the builder. A .dockerignore file keeps it small, in the same spirit as .gitignore.
  • Image size in a multi-stage build is the final stage only. BuildKit also builds just the stages the target depends on, and docker build --target build stops at a named stage.
  • Shared caches across machines use docker buildx build with --cache-to and --cache-from.

Where it goes wrong

  • Copying the whole repository before installing. Every code edit changes the COPY . . checksum, and the install below it reruns.
  • Splitting apt-get update from apt-get install. The update step stays cached while the install line changes, so it can install outdated packages. The documentation says to run both in one RUN.
  • Stages referenced by number. COPY --from=0 breaks when stages are reordered. Name them with AS and copy by name.
  • Trusting a tag. Tags are mutable; a publisher can move one to a new image. Pinning the base by digest guarantees the same image on every build.
  • Expecting RUN to notice the world changed. It compares command text. Use --no-cache, or --pull for a fresh base.

How to say it in an interview

"Each Dockerfile instruction is a layer, and the cache works top-down: once a layer changes, every layer after it rebuilds. COPY is checked against a checksum of the files, while RUN is matched on its command text. So I order by change frequency: base image, then the dependency manifest and lockfile, the install, and the source last, so a code edit reuses the dependency layer. For size I use a multi-stage build, compile in a named build stage and COPY --from only the output into a slim runtime stage. And I pin the base image by digest when builds must be reproducible."

These images then run as Pods, covered in Pods, ReplicaSets and Deployments, and the broader question set is in Docker and Kubernetes interview questions.

Sources