Skip to content
Blog

Docker CI Build Caching: GHA Cache vs Registry Cache

Speed up Docker builds in CI with BuildKit caching. Compare --cache-to/--cache-from type=gha and type=registry, and pick the right strategy for GitHub Actions.

Published on • October 28, 2026

AI Assistant

A Docker build that takes 15 minutes in CI and 90 seconds locally is a CI problem, not a Docker problem. The gap is caching: your laptop’s BuildKit daemon remembers every layer from last week’s build; your CI runner is a ephemeral VM that forgets everything each job. The fix is to make the build export its cache somewhere durable and the next build import it — and BuildKit gives you two first-party mechanisms for that: type=gha (GitHub Actions cache backend) and type=registry (your container registry). They look interchangeable. They aren’t.

The core mechanics

Both mechanisms ride the same interface:

docker buildx build \
  --cache-from type=gha \
  --cache-to type=gha,mode=max \
  -t ghcr.io/me/app:latest .

--cache-from tells the builder to pull cached layers before building; --cache-to tells it to push layers after. The important flag is mode=max: by default, only layers final image uses are exported (mode=min); mode=max exports every layer in the build graph — including intermediate compile and test stages that don’t ship. For multi-stage Dockerfiles, mode=max is usually where most of the time hides.

type=gha: the GitHub-native option

The GHA backend stores cache entries in GitHub’s Actions cache storage, authenticated via the standard ACTIONS_CACHE_URL and ACTIONS_RUNTIME_TOKEN environment variables that GitHub injects into workflows.

Pros: zero infrastructure — no registry, no buckets; scoped automatically to your repo; integrates with GitHub’s existing cache management and its per-repo storage limits.

Cons and gotchas: the storage limits are real (10 GB per repo by default, LRU-evicted), and a docker buildx cache is churny — each build with mode=max can push gigabytes, evicting the cache you just wrote two builds ago. Worse, GitHub’s Actions cache is never available on pull-request workflows from forks — and PRs are exactly where you want fast builds. There’s also cache-isolation semantics around branch scoping to be aware of for concurrent builds.

type=registry: storage you control

The registry backend pushes cache manifests to any OCI registry — often the same ghcr.io or ECR repository as your image:

docker buildx build \
  --cache-from type=registry,ref=ghcr.io/me/app:buildcache \
  --cache-to type=registry,ref=ghcr.io/me/app:buildcache,mode=max \
  -t ghcr.io/me/app:latest .

Pros: no size quota beyond your registry’s; works identically across GitHub, GitLab, ECR, and self-hosted registries; accessible from any CI system, including fork PRs (with read credentials); and cache lifecycle is yours — you can set expiration policies per tag.

Cons: you manage it. Cache tags accumulate (garbage-collect or use expiration), pushes consume registry bandwidth and storage, and a public repo needs care — a buildcache tag in a public repository exposes your intermediate layers. Use a private cache repository.

Choosing

For most GitHub Actions pipelines, start with type=gha — it’s frictionless and fast, and if your builds are moderate (a few layers of npm ci/go build), the quota is plenty. Move to type=registry when you hit any of these: cache eviction churn (check with docker buildx du across builds), fork-PR builds that need cache, multiple CI systems sharing one cache, or images whose intermediate layers are large but frequent. Many mature setups use registry cache for the heavy base layers and let GitHub’s cache handle the rest — the two mechanisms can coexist in one build.

Wiring it into GitHub Actions

- uses: docker/setup-buildx-action@v3

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/me/app:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

The build-push-action handles login, buildx builder creation, and the cache backend wiring — no manual docker buildx build needed. Note that the first build after any cache bust (base image bump, lockfile change) will still be slow; that’s correct behavior, not a misconfiguration.

The bottom line

CI Docker caching is a storage-lifetime problem: your layers are expensive to compute and cheap to keep, so keep them somewhere that outlives the runner. type=gha gives you zero-config caching inside GitHub’s own limits; type=registry gives you durable, quota-controlled, CI-agnostic caching in the registry you already have. Pick based on scale and workflow topology — and either way, turn on mode=max, because the intermediate stages are where the minutes live.