A clean checkout on a laptop or a fresh CI runner rebuilds every Dockerfile layer from zero — apt-get, npm ci, pip install, and go build all run again even though an identical build finished on another machine minutes ago. This how-to belongs to the wider work of Docker build cache optimization, and it fixes the specific waste of a cache that lives only on the machine that produced it. The remedy is a registry-backed BuildKit cache: CI exports warm layers to an image tag with --cache-to, and every subsequent build — on the next pipeline run or on any developer's machine — imports them with --cache-from. The cache becomes a shared artifact instead of a per-host accident.

Diagnostic

First confirm the symptom: a build that should hit cache is rebuilding expensive layers. Run the same build twice on a machine that has never built this image and time the second run.

#!/usr/bin/env bash
set -euo pipefail

# Simulate a fresh CI runner: no local layer cache at all.
docker buildx prune --all --force >/dev/null 2>&1 || true

time docker build -t app:diag .

On a cold host the npm ci and pip install steps run in full. The telltale sign in the log is that no step reports CACHED:

 => [deps 3/5] RUN npm ci                              48.2s
 => [deps 4/5] RUN pip install -r requirements.txt     31.7s
 => exporting to image                                  4.1s
------
real    1m38.204s

Now inspect whether your builder can even import an external cache. The default docker driver cannot export a registry cache; you need the docker-container driver.

#!/usr/bin/env bash
set -euo pipefail

docker buildx ls
# NAME/NODE          DRIVER/ENDPOINT   STATUS
# default *          docker            running   <-- cannot export registry cache

If the only builder listed uses the docker driver, any --cache-to type=registry flag is silently downgraded or rejected, which is why CI-produced layers never appear on the laptop. That mismatch — a cache that is written nowhere reachable — is the root of the repeated rebuilds.

One more diagnostic distinguishes a missing cache from a busted one. Ask the registry directly whether a cache tag exists and what platform it carries:

#!/usr/bin/env bash
set -euo pipefail

docker buildx imagetools inspect "${REGISTRY:?}/app:buildcache" \
  || echo "no cache manifest published yet"

If this prints no cache manifest published yet, CI has never successfully exported — usually the auth or driver problem above. If it prints a manifest but your local build still rebuilds, the cache exists but its keys do not match your inputs, which points at Dockerfile layer ordering rather than transport.

Registry-backed cache shared between CI and laptops CI writes warm layers to a registry cache tag, and both the next CI run and a developer laptop read those layers back from the same tag. Cache as a Shared Registry Artifact CI runner cache-to=registry mode=max Registry cache tag app:buildcache layer manifest Developer laptop cache-from=registry warm import Either side can be the writer; both sides read the same manifest.
The registry holds the cache manifest so any builder — CI or laptop — reads back layers it never built locally.

Root cause

BuildKit tracks each Dockerfile instruction as a content-addressed layer keyed by the instruction text plus the digests of its inputs. That cache is stored by the builder backend, and by default the backend is a per-machine store: the classic docker driver keeps layers inside the local Docker Engine, and even the docker-container driver keeps them in a container-local volume. Nothing about that store is shared, so a second machine — the next ephemeral CI runner or a colleague's laptop — starts with an empty index and recomputes every key from scratch. The layers are perfectly reusable in principle; they are simply not reachable. Registry-backed cache export solves reachability by serialising the cache index and the referenced blobs into an OCI artifact pushed to a registry, where any authenticated builder can pull them back before it starts work.

It matters that the cache key is content-addressed rather than tag-addressed. BuildKit does not ask "is there a newer image?"; it computes a digest for each step and checks whether a layer with that exact digest is present in the imported cache. This is why a shared cache is safe to read from many machines at once and why a stale cache never produces a wrong build — a changed input simply misses and rebuilds. It is also why the cache tag should be dedicated and long-lived: overwriting :buildcache on every CI run is correct, because each run republishes the union of still-valid layers plus any new ones, and old digests that no machine references are harmlessly garbage-collected by the registry.

Resolution

Follow these steps to publish a shared cache from CI and consume it everywhere.

  1. Create a container-driver builder on both CI and local machines. This driver supports type=registry export, which the default builder does not.

    #!/usr/bin/env bash
    set -euo pipefail
    
    docker buildx create \
      --name shared-cache \
      --driver docker-container \
      --use
    docker buildx inspect --bootstrap
  2. Authenticate to the registry that will hold the cache tag. Use a token scoped to push, and never echo it into the log. In CI this comes from a masked secret.

    #!/usr/bin/env bash
    set -euo pipefail
    
    : "${REGISTRY:?set REGISTRY, e.g. ghcr.io/acme}"
    : "${REGISTRY_USER:?}"
    : "${REGISTRY_TOKEN:?}"
    printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin
  3. Build in CI with --cache-to and --cache-from pointed at a dedicated cache tag, distinct from the runtime image tag. Use mode=max so intermediate stage layers (not just the final ones) are exported — this is what makes multi-stage deps layers reusable.

    #!/usr/bin/env bash
    set -euo pipefail
    
    IMAGE="${REGISTRY}/app"
    docker buildx build \
      --tag "${IMAGE}:${GIT_SHA}" \
      --cache-from "type=registry,ref=${IMAGE}:buildcache" \
      --cache-to   "type=registry,ref=${IMAGE}:buildcache,mode=max" \
      --push \
      .
  4. Consume the cache locally with the same --cache-from reference. A laptop only reads the cache, so it omits --cache-to (and does not need push rights). Load the result into the local engine instead of pushing it.

    #!/usr/bin/env bash
    set -euo pipefail
    
    IMAGE="${REGISTRY:?}/app"
    docker buildx build \
      --tag app:local \
      --cache-from "type=registry,ref=${IMAGE}:buildcache" \
      --load \
      .
  5. Wire the same references into Compose so docker compose build participates without anyone remembering flags. Compose v2 exposes cache_from and cache_to under each service's build block.

    services:
      app:
        image: ${REGISTRY}/app:dev
        build:
          context: .
          cache_from:
            - type=registry,ref=${REGISTRY}/app:buildcache
          cache_to:
            - type=registry,ref=${REGISTRY}/app:buildcache,mode=max

    Developers who lack push rights simply drop the cache_to line (or override it to type=inline); the cache_from line alone gives them warm imports. Building through Compose still requires the container-driver builder, so set COMPOSE_BAKE=true to route the build through buildx bake.

Cache export and import lifecycle Four ordered stages: create a container builder, authenticate, export cache from CI, then import it locally. Export and Import Lifecycle 1 — docker-container builder 2 — docker login registry 3 — CI: cache-to mode=max 4 — local: cache-from --load
Set the builder and auth once; the export and import flags then travel with every build invocation.

For a full pipeline, the GitHub Actions equivalent uses the official buildx and build-push actions, which handle the container driver and registry login for you. Note that this is deliberately the same ref a laptop reads, so the first push after merging this workflow warms the cache for every developer on the team at once — nobody has to build the slow path a second time.

name: build
on: [push]
jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}/app:${{ github.sha }}
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}/app:buildcache
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}/app:buildcache,mode=max

Expected output

Once the cache tag exists, a build on a cold machine reports CACHED for every layer whose inputs are unchanged, and the expensive dependency steps are skipped entirely.

 => importing cache manifest from ghcr.io/acme/app:buildcache   1.3s
 => [deps 3/5] RUN npm ci                              CACHED
 => [deps 4/5] RUN pip install -r requirements.txt     CACHED
 => [build 5/6] RUN go build -o /out/app ./...         CACHED
 => exporting to image                                  2.0s
------
real    0m11.482s

The importing cache manifest line confirms the builder found the shared cache; the CACHED markers confirm it was actually used. A build that prints the manifest line but still rebuilds means the cache key changed — usually because a file copied before the expensive step was modified, or because mode=min omitted the intermediate stage.

Cold build time by cache strategy Bar chart comparing cold-machine build seconds for no cache, inline cache, and registry cache with mode max. Cold-Machine Build Time (seconds) no shared cache 98s inline (mode=min) 51s registry (mode=max) 11s
Registry cache with mode=max exports intermediate stages, so the dependency layers other strategies miss also land as CACHED.

Prevention

Keep the shared cache reliable rather than letting it silently rot:

  1. Pin the cache reference in one place — a .env value or a Makefile variable read by both the CI job and the local docker buildx build wrapper — so CI and laptops never drift onto different cache tags. A dedicated Makefile target that both sides invoke removes the chance of a mistyped ref.
  2. Order the Dockerfile so cheap-to-invalidate files copy last. Copy package.json/package-lock.json and run npm ci before COPY . .; otherwise every source edit busts the dependency layer and the shared cache never helps. This ordering discipline is the subject of ordering Dockerfile layers for cache hits.
  3. Verify the cache in CI as a gate. Add a step that greps the build log for CACHED on the dependency stage and fails if it is absent on a no-op change, so a regression in cache-key stability is caught in the pipeline rather than felt as slow laptops weeks later. Pair this with the checks in CI/CD pipeline parity checks to keep runner and workstation builds aligned.

Platform caveats

macOS (Docker Desktop): The docker-container driver runs inside the Docker Desktop VM, so its cache import competes with the VM's memory limit. Give the VM at least 6 GB or large mode=max imports fail with no space left on device during importing cache manifest. VirtioFS does not affect registry cache, which travels over the network rather than the bind-mount path.

WSL2: Registry cache works normally, but WSL2's clock can drift after the laptop sleeps, and a skewed clock makes the registry reject the pushed cache with a token-expiry error. Run hwclock -s (or restart the WSL distro) if cache-to starts failing auth after resume.

Apple Silicon (ARM64): A cache produced on an amd64 CI runner will not satisfy an arm64 local build — BuildKit keys cache per target platform, so the manifest lists an amd64-only variant and the laptop rebuilds. Export a multi-arch cache from CI with --platform linux/amd64,linux/arm64 (still mode=max) so both architectures pull warm layers.

Rollback

To disable shared caching and fall back to local-only builds, drop the flags and remove the custom builder:

#!/usr/bin/env bash
set -euo pipefail
docker buildx rm shared-cache || true
docker buildx use default

Builds then run on the default engine driver with only its local layer cache — slower on cold machines, but with no registry dependency. The pushed :buildcache tag can be left in place or deleted from the registry; it has no effect once no build references it.

Frequently Asked Questions

Why does --cache-to type=registry fail with "cache export feature is currently not supported"?

You are building on the default docker driver, which cannot export a registry cache. Create a builder with docker buildx create --driver docker-container --use and rerun the build. The docker-container driver runs BuildKit in a helper container that supports registry export; the classic driver embedded in the engine does not.

What is the difference between mode=min and mode=max?

mode=min exports only the layers of the final image, so intermediate build stages — where npm ci and pip install usually run — are not cached across machines. mode=max exports every layer of every stage, which is larger to push and store but is what actually makes multi-stage dependency layers reusable on a cold machine. Use mode=max for the shared CI cache and accept the extra registry storage.

Can I use inline cache instead of a separate :buildcache tag?

Yes, type=inline embeds the cache metadata inside the pushed image itself, so there is no second tag to manage. The trade-off is that inline cache only supports mode=min — intermediate stages are not exported — so cold-build hit rates are lower. Use inline for simple single-stage images and a dedicated registry cache tag with mode=max for multi-stage builds with expensive dependency layers.

Do developers need push access to the registry to benefit?

No. Reading the cache only needs --cache-from, which requires pull access. Only the writer — typically CI — needs --cache-to and push rights. Give laptops a pull-only token, drop the cache_to line from their Compose override, and they still import CI's warm layers.