Sharing a Build Cache Between CI and Local Machines
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.
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.
Create a container-driver builder on both CI and local machines. This driver supports
type=registryexport, 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 --bootstrapAuthenticate 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-stdinBuild in CI with
--cache-toand--cache-frompointed at a dedicated cache tag, distinct from the runtime image tag. Usemode=maxso intermediate stage layers (not just the final ones) are exported — this is what makes multi-stagedepslayers 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 \ .Consume the cache locally with the same
--cache-fromreference. 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 \ .Wire the same references into Compose so
docker compose buildparticipates without anyone remembering flags. Compose v2 exposescache_fromandcache_tounder each service'sbuildblock.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=maxDevelopers who lack push rights simply drop the
cache_toline (or override it totype=inline); thecache_fromline alone gives them warm imports. Building through Compose still requires the container-driver builder, so setCOMPOSE_BAKE=trueto route the build throughbuildx bake.
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.
Prevention
Keep the shared cache reliable rather than letting it silently rot:
- Pin the cache reference in one place — a
.envvalue or a Makefile variable read by both the CI job and the localdocker buildx buildwrapper — so CI and laptops never drift onto different cache tags. A dedicated Makefile target that both sides invoke removes the chance of a mistypedref. - Order the Dockerfile so cheap-to-invalidate files copy last. Copy
package.json/package-lock.jsonand runnpm cibeforeCOPY . .; 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. - Verify the cache in CI as a gate. Add a step that greps the build log for
CACHEDon 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-containerdriver 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 largemode=maximports fail withno space left on deviceduringimporting 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) ifcache-tostarts failing auth after resume.
Apple Silicon (ARM64): A cache produced on an
amd64CI runner will not satisfy anarm64local build — BuildKit keys cache per target platform, so the manifest lists anamd64-only variant and the laptop rebuilds. Export a multi-arch cache from CI with--platform linux/amd64,linux/arm64(stillmode=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.