Every push triggers a fresh docker build, and because GitHub's runners are ephemeral, the BuildKit layer cache that makes your local rebuilds instant is thrown away the moment the job ends. The next run starts from zero: reinstalling system packages, re-resolving dependencies, recompiling everything. The fix is to persist that layer cache somewhere outside the runner and feed it back in on the next build.
Which backend you use to do that depends on your image size and where your builds run, and there's one version requirement that broke a lot of pipelines in 2025 if you're on older tooling.
The fix: cache layers to the GitHub Actions cache backend
The cleanest approach is to export your build cache to GitHub's own Actions cache service using the type=gha backend. You wire it up through docker/build-push-action with two arguments: cache-from to import the previous cache, and cache-to to export the new one.
Here's a complete working workflow:
1234567891011121314151617181920212223242526272829name: buildon:push:jobs:docker:runs-on: ubuntu-lateststeps:- name: Checkoutuses: actions/checkout@v6- name: Set up Docker Buildxuses: docker/setup-buildx-action@v4- name: Login to Docker Hubuses: docker/login-action@v4with:username: ${{ vars.DOCKERHUB_USERNAME }}password: ${{ secrets.DOCKERHUB_TOKEN }}- name: Build and pushuses: docker/build-push-action@v7with:context: .push: truetags: user/app:latestcache-from: type=ghacache-to: type=gha,mode=max
Two parts of this are easy to get wrong.
The setup-buildx-action step is not optional. The type=gha backend only works with a BuildKit-backed builder, not the default docker driver that ships with the daemon. setup-buildx-action creates a docker-container builder for you, which is what makes layer cache export possible in the first place. Leave it out and you'll get a build that runs but never caches anything.
The mode=max on cache-to is the difference between a cache that helps and one that barely does. The default, mode=min, only exports the layers that end up in the final image. In a multi-stage build, that excludes every intermediate stage, which is usually where the expensive work lives: dependency installs, compilation, asset bundling. mode=max exports all of them, including intermediate stages, so the next build can reuse a cached npm install or go build layer instead of redoing it. Use mode=max unless you have a specific reason not to.
You'll know it's working from the build log. The first run populates the cache and is no faster than before. On the second run, cached steps show up as CACHED:
123=> CACHED [deps 2/5] RUN apk add --no-cache build-base 0.0s=> CACHED [deps 3/5] COPY package.json package-lock.json ./ 0.0s=> CACHED [deps 4/5] RUN npm ci 0.0s
If you build more than one image in the same workflow, give each one its own scope. Without it, every build writes to the default buildkit scope and overwrites the previous one, so only the last image's cache survives:
12cache-from: type=gha,scope=apicache-to: type=gha,mode=max,scope=api
When to use the registry or local backend instead
The gha backend has a hard ceiling: GitHub gives each repository 10 GB of Actions cache, shared across all your caches, not just Docker. Large images or repos with many cached layers will blow past that, and once you do, GitHub evicts older entries to make room, which drags your hit rate down.
When that happens, export the cache to your container registry instead. The registry backend stores the cache as a separate tag alongside your image, with no GitHub size limit:
12345678- name: Build and pushuses: docker/build-push-action@v7with:context: .push: truetags: user/app:latestcache-from: type=registry,ref=user/app:buildcachecache-to: type=registry,ref=user/app:buildcache,mode=max
The registry backend is also the right call when you want to share a cache across repositories or across self-hosted runners that don't share GitHub's cache, since the cache lives wherever your registry does. The tradeoff is that you're pushing and pulling cache blobs over the network to your registry on every run, which adds latency and registry storage cost.
There's a third option, the local backend with actions/cache, that predates the gha backend and still shows up in older guides. It writes the cache to a directory and persists that directory with actions/cache:
123456789101112131415161718- name: Cache Docker layersuses: actions/cache@v5with:path: ${{ runner.temp }}/.buildx-cachekey: ${{ runner.os }}-buildx-${{ github.sha }}restore-keys: |${{ runner.os }}-buildx-- name: Build and pushuses: docker/build-push-action@v7with:cache-from: type=local,src=${{ runner.temp }}/.buildx-cachecache-to: type=local,dest=${{ runner.temp }}/.buildx-cache-new,mode=max- name: Move cacherun: |rm -rf ${{ runner.temp }}/.buildx-cachemv ${{ runner.temp }}/.buildx-cache-new ${{ runner.temp }}/.buildx-cache
That Move cache step is not boilerplate you can drop. The local exporter never prunes old entries, so writing back to the same directory makes the cache grow without bound until you hit the 10 GB limit anyway. Writing to a new directory and swapping it in is the standard workaround. In practice there's little reason to choose the local backend over type=gha today; it's mostly here so you recognize it when you inherit a pipeline using it.
Persisting BuildKit cache mounts
This is the gotcha that catches people who've done everything else right. If your Dockerfile uses cache mounts to speed up dependency resolution, like a Go module cache or a Rust target directory:
123RUN\go build -o /bin/app ./...
those mounts are not saved by the gha, registry, or local backends. Layer caching and cache-mount caching are two separate mechanisms in BuildKit. The layer cache captures the result of each Dockerfile instruction; the cache mount is scratch space that exists only during the build and is deliberately excluded from the image. So even with cache-to: type=gha,mode=max, your go build layer gets reused only when nothing upstream changed, and the moment a single dependency shifts, you're recompiling everything from an empty mount.
The workaround is the buildkit-cache-dance action, which extracts the cache mount contents into a directory, persists that with actions/cache, and injects it back before the build:
123456789101112131415161718192021222324- name: Go build cacheuses: actions/cache@v5with:path: |go-mod-cachego-build-cachekey: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}- name: Inject cache into BuildKituses: reproducible-containers/buildkit-cache-dance@v3with:cache-map: |{"go-mod-cache": "/go/pkg/mod","go-build-cache": "/root/.cache/go-build"}- name: Build and pushuses: docker/build-push-action@v7with:cache-from: type=ghacache-to: type=gha,mode=maxpush: truetags: user/app:latest
The cache-map is the part to get right. It's a JSON object that maps each host directory persisted by actions/cache to the cache-mount target inside the build, so the keys must match the directories under path: and the values must match the --mount=type=cache,target=... paths in your Dockerfile. If you've seen older guides pin @v2.1.4 and pass cache-source/cache-target instead, that's the previous interface; v3 replaced those inputs with cache-map (it can also auto-discover mounts from your Dockerfile if you omit the map).
It's awkward, but for compiled languages it's often the single biggest win, because the compiler cache survives dependency changes that would otherwise invalidate the whole build layer.
Common pitfalls
The biggest one today is the legacy cache service being gone. On April 15, 2025, GitHub shut down the v1 Actions cache API, so any tooling that only speaks the old protocol now fails outright. If your builds error with This legacy service is shutting down, effective April 15, 2025, that's what you're hitting. The minimum versions that support the v2 API are Docker Buildx v0.21.0, BuildKit v0.20.0, Docker Compose v2.33.1, and, for the containerd image store path, Docker Engine v28.0.0. GitHub-hosted runners ship current versions, but self-hosted runners frequently lag, so pin the latest Buildx explicitly to be safe:
1234- name: Set up Docker Buildxuses: docker/setup-buildx-action@v4with:version: latest
The same lag bites actions/cache@v5, which runs on Node 24 and needs Actions Runner v2.327.1 or newer, so keep self-hosted runners updated to match. GitHub-hosted runners already meet this.
Branch scoping is the next thing that surprises people. GitHub Actions caches are isolated per branch, with two exceptions: a workflow can read caches created on the default branch, and a pull request build can also read caches from its base branch. So a feature branch can read the cache your main builds produced, and a pull request can read its target branch's, but neither can read an unrelated feature branch's cache, and neither can write back to main's. If pull request builds feel like they're starting cold, this isolation is usually why, and it's working as designed rather than broken.
The last pitfall is harder to catch because nothing errors out. When you hit GitHub's 10 GB limit, or let a local cache balloon, eviction never shows up in the build log; your hit rate just drops and builds get slower. If cached builds slow down with no Dockerfile change, check your cache size before assuming the config is broken.
Final thoughts
Caching is easy to set up and easy to forget about, right up until it stops helping. A dependency bump invalidates a layer, or an image creeps past the size limit, and builds that used to take a minute are back to five. Catching that early means watching how long your builds actually take, which is what you get by exporting your GitHub Actions runs as OpenTelemetry traces instead of guessing from total job time. The same visibility matters once the images are running in production, where a misbehaving container is much harder to find than a slow build.
Dash0's infrastructure monitoring tracks container resource usage alongside real-time logs and distributed traces, so you have full visibility into your containerized workloads from build to runtime in one place.
Start a free trial to see your containers, logs, and traces in a single view. No credit card required.