Caching Docker layer builds in GitHub Actions
Speed up Docker image builds in GitHub Actions by caching layers with Buildx. Compare the gha, registry, inline, and local cache backends with copy-paste YAML examples.
In the dependency caching article we promised a follow-up on caching Docker layers — here it is. Caching your ~/.npm or ~/.cache/pip directory speeds up the steps that install your project’s dependencies, but it does nothing for docker build. Image layers live in the Docker engine’s own storage, and that storage starts empty on every fresh runner. So unless you tell Buildx where to stash and restore its layers, every RUN apt-get install ... and every COPY . . is recomputed from scratch on every single run.
This article shows you how to fix that, backend by backend, with runnable YAML.
Why Docker builds are slow in CI
On your laptop, the second docker build is fast because the engine still has the layers from the first build sitting in its local cache. CI runners are different: each job runs on a clean, ephemeral machine, so the layer cache is always cold. Buildx rebuilds the full Dockerfile from layer zero, redownloading base images and re-running every instruction.
To carry layers from one run to the next you need an external cache that survives the runner being torn down. Buildx supports several such backends, and you wire them in through two parameters: cache-from (where to import layers) and cache-to (where to export them).
Setting up Buildx and the build-push-action
The starting point for all the examples below is the same: set up Buildx (a Docker CLI plugin built on BuildKit) with docker/setup-buildx-action ↗, then build with docker/build-push-action ↗. If you build images for an architecture other than the runner’s own (for example linux/arm64 on an amd64 runner), also add docker/setup-qemu-action ↗ for emulation.
name: Docker build
on: push: branches: [main]
jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6
- name: Set up QEMU uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v4
- name: Build uses: docker/build-push-action@v7 with: context: . push: false tags: my-app:latestThat builds the image but caches nothing across runs. Everything that follows is about filling in cache-from and cache-to.
Backend 1: type=gha (the GitHub Actions cache)
The most convenient backend exports layers into the same cache service that actions/cache uses. No extra credentials, no registry — Buildx talks to the Actions cache API directly.
- name: Build with GHA cache uses: docker/build-push-action@v7 with: context: . push: false tags: my-app:latest cache-from: type=gha cache-to: type=gha,mode=maxThe interesting part is mode:
mode=min(the default) caches only the layers that end up in the final image.mode=maxcaches all layers, including intermediate stages in a multi-stage build. That uses more storage but gives you far more cache hits, since a change late in your Dockerfile can still reuse everything before it. For CI,mode=maxis almost always what you want.
Two things to keep in mind:
- This backend draws from the same ~10 GB Actions cache budget as your dependency caches. A
mode=maxcache for a big image can eat a large chunk of that, and once you exceed the limit GitHub evicts entries least-recently-used first — which can quietly cost you your dependency caches too. - The backend has a v1 and a v2 protocol. The action picks the right one automatically based on your runner’s cache service, so you normally don’t set it by hand.
Backend 2: type=registry
Instead of the Actions cache, you can push the cache to a container registry as a dedicated image tag. This survives independently of the Actions cache budget and — crucially — can be shared across jobs, workflows, and even repositories that have pull access to the registry.
It needs a registry login first, via docker/login-action ↗:
- name: Log in to registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build with registry cache uses: docker/build-push-action@v7 with: context: . push: true tags: ghcr.io/my-org/my-app:latest cache-from: type=registry,ref=ghcr.io/my-org/my-app:buildcache cache-to: type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=maxThe ref points at a separate tag (:buildcache) so the cache manifest doesn’t clobber your real image tags. mode=max works here too. The trade-off versus type=gha is that you pay for the round-trip to the registry and for the storage there, but you gain a cache that any job in your organization can reuse.
Backend 3: type=inline
The simplest possible option: embed the cache metadata directly inside the image you’re already pushing. There’s no separate cache artifact to manage at all.
- name: Build with inline cache uses: docker/build-push-action@v7 with: context: . push: true tags: ghcr.io/my-org/my-app:latest cache-from: type=registry,ref=ghcr.io/my-org/my-app:latest cache-to: type=inlineThe catch: inline cache is mode=min only — it can’t store intermediate layers, because they aren’t part of the pushed image. That makes it a poor fit for multi-stage builds where the expensive work happens in a stage that gets discarded. Reach for it when your build is single-stage and you want zero extra moving parts.
Backend 4: type=local (with the move workaround)
The local backend writes the cache to a directory on disk. On its own that’s useless in CI, because the disk disappears with the runner — so you pair it with actions/cache to persist that directory between runs.
There’s a well-known wrinkle, though. BuildKit’s local exporter never deletes old blobs; it only appends, so the cache directory grows without bound and your actions/cache entry balloons run after run. The standard fix is the “move cache” dance: export to a fresh -new directory, then atomically swap it in place of the old one before the cache is saved.
- name: Cache Docker layers uses: actions/cache@v5 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx-
- name: Build with local cache uses: docker/build-push-action@v7 with: context: . push: false tags: my-app:latest cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Move cache run: | rm -rf /tmp/.buildx-cache mv /tmp/.buildx-cache-new /tmp/.buildx-cacheThis is the fiddliest option — three coordinated steps and a manual workaround — and it still rides on the same 10 GB Actions cache budget as type=gha, just with more ceremony. Most teams are better served by type=gha or type=registry. It’s worth knowing because you’ll see it all over older workflows and blog posts.
Which backend should you use?
A quick decision guide:
type=gha— your default. Zero setup, works out of the box, great for a single repo’s CI. Watch the 10 GB budget.type=registry— when you want to share layers across multiple jobs, workflows, or repositories, or your cache is too big for the Actions budget. Needs a registry login.type=inline— only for simple single-stage builds where you want the absolute minimum configuration and don’t need intermediate layers.type=local— rarely the best choice today; useful if you specifically need cache on disk, but expect to maintain the move-cache workaround.
RunsOn tip: Docker layer caching is where self-hosted runners really pull ahead. On RunsOn,
type=ghais transparently backed by an unlimited S3 Magic Cache in your own AWS account (no 10 GB ceiling), and you also get a nativetype=s3BuildKit backend plus an ephemeral ECR registry fortype=registry— often the fastest of the three. For very large layer sets, sticky disks (EBS) skip cache export and compression entirely by reusing the disk between runs. See the Docker caching guide for the full setup.
Conclusion
Docker builds are slow in CI for one reason: the layer cache is cold on every run. Dependency caching won’t help — you need to point Buildx at an external cache with cache-from and cache-to. Start with type=gha,mode=max for its near-zero setup, graduate to type=registry when you need to share layers or outgrow the Actions cache budget, keep type=inline in your back pocket for the simplest builds, and reach for type=local only when you have a specific reason to. Pick the backend that matches how your images are built and shared, and your CI pipeline will spend its time shipping instead of rebuilding the same layers over and over.