skip to content

CI builds run on fresh, ephemeral runners, so every image build starts with an empty local build cache. How do you get cache hits anyway, and what are the tradeoffs of the approaches?

level: seniorimportance: must knowfreq 58%

answer

  1. Ephemeral runner = empty builder → import/export cache
  2. --cache-to / --cache-from: registry, gha, local, inline, s3
  3. mode=max exports intermediate stages; min = final image only
  4. Branch-scoped cache with main-branch fallback
  5. Cache mounts don't travel — need a persistent builder

basics

~20 s

Export the cache to a shared backend and import it next run: BuildKit's --cache-to/--cache-from with a registry, a CI-provider cache, or a local directory. Use mode=max to keep intermediate stage layers. Alternatively keep a persistent remote builder so its cache never disappears.

solid answer

~50 s

An ephemeral runner has no cache, so the cache has to live somewhere durable and be imported. With BuildKit/Buildx you write it explicitly: ``` docker buildx build \ --cache-from=type=registry,ref=repo/app:buildcache \ --cache-to=type=registry,ref=repo/app:buildcache,mode=max \ --push -t repo/app:$SHA . ``` Key choices: - **Backend**: `registry` (portable, works anywhere), `gha`/provider cache (fast inside one CI system), `local` dir (needs a cache action to persist), `inline` (metadata embedded in the pushed image — simplest, but final-stage layers only). - **`mode=max` vs `mode=min`**: `min` exports only the layers of the final image, so multi-stage builder work is not reusable. `max` exports intermediates too — far better hit rates, at the cost of more storage and push time. - **Scope**: cache per branch with a fallback to the main-branch cache, so feature branches start warm. Cost: cache import/export is network traffic. Beyond a certain size, pulling the cache can cost more than rebuilding — measure. Note `RUN --mount=type=cache` directories do **not** travel with registry cache; they need a persistent builder.

code

bash · 6 lines
bash
docker buildx build \
  --cache-from=type=registry,ref=registry.example.com/app:cache-$BRANCH \
  --cache-from=type=registry,ref=registry.example.com/app:cache-main \
  --cache-to=type=registry,ref=registry.example.com/app:cache-$BRANCH,mode=max \
  --tag registry.example.com/app:$GIT_SHA \
  --push .

go deeper

for a junior

Know that CI runners start with no cache and that BuildKit can import and export cache with --cache-from/--cache-to.

for a middle

Compare the backends (registry, provider cache, inline, local) and explain why mode=max matters for multi-stage builds.

for a senior

Own the tradeoffs: scoping with fallback, transfer cost versus rebuild cost, cache mounts requiring a persistent builder, and measuring hit rate against wall-clock time.

for a principal

Decide the platform shape — ephemeral runners with a shared cache plane versus operated persistent builders — and set the trust policy for who may write cache, treating cache entries as part of the supply chain.

## The problem The build cache is builder-local state. Spin up a fresh container or VM per pipeline job and the builder starts blank, so a Dockerfile carefully ordered for cache reuse still rebuilds everything, every time. Fixing it means either (a) making the cache portable so it can be re-imported, or (b) making the builder durable so it never loses it. ## Exporting and importing cache BuildKit separates the *cache exporter* (`--cache-to`) from the *cache importer* (`--cache-from`). Both take a type and options. **`type=registry`** stores the cache as its own artifact in a container registry, typically a dedicated tag such as `repo/app:buildcache`. It is the most portable option: any runner with registry credentials can import it, including across CI systems and developer machines. Cost: registry storage and pull/push time. **`type=inline`** embeds cache metadata into the image you are already pushing, so there is no second artifact. It is the simplest to adopt and costs nothing extra to store — but it only describes layers present in the final image, so it behaves like `mode=min` and gives no hits for multi-stage builder work. **`type=gha`** (and equivalents for other providers) uses the CI system's own cache service. Usually the fastest inside that provider, subject to that provider's quotas and eviction — GitHub's cache, for instance, evicts under total-size pressure and scopes entries by branch with fallback to the default branch. **`type=local`** writes to a directory; useful with a self-hosted runner or paired with a cache-restore step. Watch for unbounded growth — without pruning it keeps accumulating. **`type=s3` / `type=azblob`** put the cache in object storage, which suits organisations that want one cache plane independent of the CI vendor. ## `mode=min` versus `mode=max` This is the single highest-leverage flag. `mode=min` (the default) exports only the layers that ended up in the final image. In a multi-stage build — the normal shape for compiled languages — the expensive work (dependency resolution, compilation) happens in a builder stage whose layers are *discarded* from the final image. With `min`, none of that is exported, so the next run re-runs the compile even though it 'has cache'. `mode=max` exports intermediate stage layers too. Hit rates jump; storage and export time grow. For most real pipelines `max` is correct, and the extra push cost is repaid on the first hit. ## Persistent builders — the other answer Instead of shipping the cache around, keep the builder alive: `docker buildx create` with a remote or Kubernetes driver, or self-hosted runners with a long-lived BuildKit daemon. The cache then stays local and hot, with no import/export traffic, and `RUN --mount=type=cache` directories (a Maven repository, an npm cache, the Go build cache) survive too — those never travel with registry cache and are otherwise lost on every ephemeral job. The tradeoff is that you now operate a stateful component: it needs disk, a GC policy (keep-storage limits in `buildkitd.toml` or a scheduled `buildx prune`), monitoring, and a security story, because a shared builder is shared state across builds. ## Scoping and correctness Cache scope decides hit rate. A common pattern is: export cache per branch, import that branch's cache first with a fallback to the main-branch cache, so a new feature branch starts warm from the last good main build. Too coarse a scope and unrelated builds evict each other; too fine and nothing is ever reused. There is also a trust boundary. Cache entries are build results. Letting untrusted pull-request builds *write* to a cache that trusted release builds *read* lets a hostile contributor influence a released artifact. The usual policy: forks and PRs may import cache, only trusted branches may export it. ## When caching is the wrong lever Measure before believing. Importing a multi-gigabyte cache over the network can be slower than rebuilding steps that only take a minute. Watch cache hit rate and end-to-end build time together, and be willing to conclude that a smaller build context, a narrower copy, better instruction ordering, or a prebuilt base image with the heavy toolchain baked in beats any cache configuration. A base image rebuilt nightly and pinned by digest is often the cheapest 'cache' there is. ## What a strong answer covers Name the mechanism (`--cache-to`/`--cache-from`), pick a backend with a reason, call out `mode=max` for multi-stage, mention scope with fallback, note that cache mounts need a persistent builder, and finish with the honest caveat that cache transfer is not free.

  • Your pipeline uses registry cache but the compile step still re-runs every build. What is the most likely cause?
    The export is running in `mode=min` (or via `type=inline`), so only layers present in the final image were exported. The compile happens in a builder stage whose layers are discarded, so nothing about it is in the cache. Switch the exporter to `mode=max` and accept the larger cache artifact; also confirm the import reference actually matches the tag the previous job wrote.
  • Why should pull-request builds usually be allowed to read the shared cache but not write to it?
    Cache entries are build outputs that later builds trust and reuse. If an untrusted fork can write entries, it can influence what a release build produces — a supply-chain attack that leaves no trace in the Dockerfile. Reading is safe and still gives contributors fast builds, so the standard policy is import-for-all, export-only-from-trusted-branches.
  • When is adding a remote build cache not worth it?
    When the cache artifact is large relative to the work it saves — importing several gigabytes over the network can exceed the time to rebuild a few short steps. It is also pointless when builds legitimately invalidate early every run, such as a volatile build argument consumed near the top of the Dockerfile. Measure hit rate and total wall time before and after, and fix ordering first.

saying these in an interview costs you the question

  • Assuming `docker build` on a fresh runner reuses cache from the last pipeline run automatically
  • Leaving the exporter at the default `mode=min` for a multi-stage build and expecting builder-stage hits
  • Believing `RUN --mount=type=cache` directories are exported with registry cache
  • Letting untrusted fork builds write to the shared cache
  • Adding remote cache while never measuring whether total build time actually improved

context