Skip to content
BytePatterns

Docker Build Cache: Layers vs BuildKit Cache Mounts vs --cache-from

7 min readBytePatterns

Docker build cache at three scopes: the layer cache one changed step invalidates, BuildKit cache mounts that keep downloads, and --cache-from for fresh CI.

Docker build cache: layers vs BuildKit cache mounts vs --cache-from in one line: the layer cache skips steps whose inputs did not change, a cache mount keeps a package manager's downloads between builds even when a step reruns, and --cache-from hands the layer cache to a machine that has never built the image. Everything below comes from the Docker documentation pages listed at the end, as of October 2026.

Side by side:

CacheLives inWhat it saves
Layer cachethe builder's local storagea whole step, if its inputs and every step before it match
Cache mount, RUN --mount=type=cachethe builder's local storage, outside the imagedownloads, when the step reruns anyway
--cache-to / --cache-froma registry, a directory, or a CI cache servicethe layer cache, for a builder that starts empty

The problem it solves

Three different builds are slow for three different reasons:

  • On a laptop, after a code edit, a badly ordered Dockerfile reinstalls every dependency. The fix is ordering, covered in image layers and Dockerfile order.
  • On a laptop, after adding one dependency, the install step must rerun, and it downloads every package again, not just the new one.
  • On CI, every job may run on a fresh machine with no cache at all, so even an unchanged Dockerfile builds from scratch.

The first is solved by the layer cache, the second by a cache mount, the third by an exported cache.

The intuition

The layer cache is all or nothing per step. If a layer changes, every layer after it is affected and must run again. For COPY and ADD, the builder computes a checksum from file metadata, not counting the modification time; for RUN, just the command string is used to find a match. A cache hit skips the step entirely.

A cache mount is a directory that outlives the step. RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt mounts a persistent directory at pip's cache path while the step runs. When requirements.txt changes, the step still reruns, because the layer cache says so. But pip finds its earlier downloads in the mount, so only new or changed packages are fetched. Three properties matter:

  • Its contents never decide a cache hit. They persist between builds without invalidating the instruction cache.
  • It is a performance aid only. The documentation says the build should work with any contents, and garbage collection may clean the mount when space is needed.
  • It stays on the builder. Cache mounts are not included in an exported cache and are not restored by --cache-from.

An external cache moves the layer cache between machines. --cache-to exports it, --cache-from imports it. BuildKit supports several backends, among them inline, local, registry and gha. The registry backend stores the cache as a separate image. mode=min, the default, caches only the layers exported into the resulting image; mode=max caches all layers, including intermediate build stages, so it hits more often but is usually larger.

Watch it run

The animation builds a five-layer Dockerfile and keeps every layer in the build cache. Then it edits one source file: the COPY . . layer and the step after it rebuild, while the three above come from cache. Swapping the order to copy the source before npm ci makes the same edit reinstall every dependency. The last frames add a second FROM and show that only the final stage ships, which is exactly the line mode=min draws.

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

A two-stage Python image with a cache mount on the install step, illustrative:

# syntax=docker/dockerfile:1
FROM python:3.12 AS build
WORKDIR /app
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip wheel -r requirements.txt -w /wheels
COPY . /app

FROM python:3.12-slim
COPY --from=build /wheels /wheels
RUN pip install /wheels/*
COPY --from=build /app /app
CMD ["python", "/app/app.py"]

On CI, the same build with a registry cache, illustrative. The default docker driver supports the registry backend only with the containerd image store enabled; otherwise create a builder with another driver, such as docker-container:

docker buildx build \
  --cache-from type=registry,ref=registry.example.com/app:buildcache \
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max \
  -t registry.example.com/app:1.4.0 --push .

A toy model of the three caches, not BuildKit. It extends the lesson's layer model: each step's key chains its parent's key with its inputs, so one change invalidates everything after it in that stage. The cache mount is a separate set of downloaded packages. Each line prints the eight steps as C for cached and R for rebuilt, then the packages downloaded and reused from the mount:

import hashlib

STEPS = [  # (stage, instruction, path it writes); the last stage ships
    ("build", "FROM python:3.12", None),
    ("build", "COPY requirements.txt ./", None),
    ("build", "RUN --mount=type=cache,target=/root/.cache/pip "
              "pip wheel -r requirements.txt -w /wheels", "/wheels"),
    ("build", "COPY . /app", "/app"),
    ("final", "FROM python:3.12-slim", None),
    ("final", "COPY --from=build /wheels /wheels", None),
    ("final", "RUN pip install /wheels/*", None),
    ("final", "COPY --from=build /app /app", None),
]

def inputs(step, files, outputs):
    """What the toy cache check looks at: the instruction text, plus a
    checksum of copied files, or the producing step's key for COPY --from."""
    if step.startswith("COPY --from="):
        stage, path = step.split()[1].split("=")[1], step.split()[2]
        return step + "|" + outputs[(stage, path)]
    if step.startswith("COPY"):
        names = sorted(files) if step.split()[1] == "." else [step.split()[1]]
        return step + "|" + ",".join(f + ":" + files[f] for f in names)
    return step                                   # RUN: the command string

def build(files, layers, mounts):
    """Toy BuildKit run. layers: the layer cache, key -> stage.
    mounts: the builder's cache mount, the packages already downloaded."""
    parents, outputs, log, downloads, reused = {}, {}, [], 0, 0
    for stage, step, writes in STEPS:
        raw = parents.get(stage, "") + "|" + inputs(step, files, outputs)
        k = hashlib.sha256(raw.encode()).hexdigest()
        if k in layers:
            log.append("C")                       # CACHED
        else:
            log.append("R")                       # REBUILT
            if "--mount=type=cache" in step:
                for pkg in files["requirements.txt"].split(","):
                    if pkg in mounts:
                        reused += 1               # DOWNLOAD-REUSED
                    else:
                        downloads += 1
                        mounts.add(pkg)
            layers[k] = stage
        parents[stage] = k                        # a new parent, a new key
        if writes:
            outputs[(stage, writes)] = k
    return " ".join(log) + f"  downloads={downloads} reused={reused}"

files = {"requirements.txt": "flask,requests,gunicorn", "app.py": "v1"}
laptop, laptop_mount = {}, set()
print(build(files, laptop, laptop_mount))       # R R R R R R R R  downloads=3 reused=0
files["app.py"] = "v2"                          # edit the code
print(build(files, laptop, laptop_mount))       # C C C R C C C R  downloads=0 reused=0
files["requirements.txt"] += ",redis"           # add a dependency
print(build(files, laptop, laptop_mount))       # C R R R C R R R  downloads=1 reused=3

The code edit reran only the two steps that depend on the source. The new dependency reran the install, but three of four packages came from the mount. Now a CI runner that starts empty, with no import, with a mode=min import and with a mode=max import:

def export(layers, mode):
    """--cache-to: mode=min keeps the shipped stage's layers, mode=max all
    of them. The cache mount is not part of the export."""
    return {k: s for k, s in layers.items() if mode == "max" or s == "final"}

registry = {mode: export(laptop, mode) for mode in ("min", "max")}

def fresh_runner(cache_from=None):
    """A CI job on a new machine: empty layer cache, empty cache mount."""
    layers = dict(registry[cache_from]) if cache_from else {}
    return build(files, layers, set())

print(fresh_runner())                  # R R R R R R R R  downloads=4 reused=0
print(fresh_runner("min"))             # R R R R C C C C  downloads=4 reused=0
print(fresh_runner("max"))             # C C C C C C C C  downloads=0 reused=0
files["requirements.txt"] += ",celery" # the next commit adds a dependency
print(fresh_runner("max"))             # C R R R C R R R  downloads=5 reused=0

With mode=min, the build stage was never exported, so in this toy model the runner repeats it, downloads included. With mode=max, nothing runs. The last line is the trap: the imported layers are there, but the cache mount is not, so the changed install downloads all five packages again.

The complexity

Each cache has its own cost:

  • Layer cache: builder disk, and a Dockerfile ordered from rarely to often changed.
  • Cache mount: disk on the builder, plus concurrency care. The sharing option defaults to shared; locked makes a second writer wait, which the documentation uses for apt.
  • External cache: transfer time on every job, and registry storage. mode=max exports more, so it costs more to push and pull but hits more.

Where it goes wrong

  • Expecting a cache mount to make a step cached. It never does. The step reruns; only its downloads are faster.
  • Putting build output in a cache mount. The mount is not the image, other builds can overwrite it and garbage collection can clean it. Write what the image needs outside it.
  • Relying on cache mounts in CI. On a fresh runner they start empty, and --cache-from does not bring them back.
  • Using mode=min for a multi-stage build. The build stage, often the expensive part, is not exported.
  • Expecting RUN apt-get update to refresh. An unchanged command string is a cache hit. Use --no-cache or --no-cache-filter to force a rerun.

When it shows up in interviews

It comes up as "our CI image builds take ten minutes, how do you speed them up?" A strong answer separates the three scopes: order the Dockerfile for the layer cache, add a cache mount for package downloads, and export the layer cache with mode=max because CI runners start empty. Container basics are in containers vs virtual machines, and more questions of this kind in Docker and Kubernetes interview questions.

How to say it in an interview

"There are three caches. The layer cache skips a step when its inputs and everything before it match, so I put dependency manifests and the install before the source. A BuildKit cache mount keeps the package manager's download directory between builds, outside the image, so when the install does rerun it only fetches what changed. On CI the builder is usually fresh, so I export the layer cache to a registry with --cache-to type=registry,mode=max and import it with --cache-from. Cache mounts are not part of that export, so on CI they start empty."

The layer rule and multi-stage builds are in images and layers.

Frequently asked questions

What is the difference between a cache mount and the layer cache?

The layer cache decides whether a step runs at all: if the step's inputs and everything before it match, it is skipped. A cache mount only helps a step that does run, by keeping a directory, such as pip's download cache, between builds. Its contents never decide a cache hit and are not part of the image.

Does --cache-from restore BuildKit cache mounts?

No. Cache mounts live in the builder's local storage, are not included in a --cache-to export and are not restored by --cache-from. On a CI runner that starts on a fresh machine for every job, cache mounts start empty on every build.

When should I use mode=max instead of mode=min?

Use mode=max for multi-stage builds, where expensive steps happen in stages that do not ship. mode=min, the default, caches only the layers exported into the resulting image. mode=max caches all layers, so it hits more often, at the cost of a larger cache to push and pull.

Sources