Dash0 Raises $110M Series B at $1B Valuation

  • 13 min read

How to Cache Docker Images in GitHub Actions

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:

yaml
1234567891011121314151617181920212223242526272829
name: build
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: user/app:latest
cache-from: type=gha
cache-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:

text
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:

yaml
12
cache-from: type=gha,scope=api
cache-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:

yaml
12345678
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: user/app:latest
cache-from: type=registry,ref=user/app:buildcache
cache-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:

yaml
123456789101112131415161718
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: ${{ runner.temp }}/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build and push
uses: docker/build-push-action@v7
with:
cache-from: type=local,src=${{ runner.temp }}/.buildx-cache
cache-to: type=local,dest=${{ runner.temp }}/.buildx-cache-new,mode=max
- name: Move cache
run: |
rm -rf ${{ runner.temp }}/.buildx-cache
mv ${{ 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:

dockerfile
123
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
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:

yaml
123456789101112131415161718192021222324
- name: Go build cache
uses: actions/cache@v5
with:
path: |
go-mod-cache
go-build-cache
key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
- name: Inject cache into BuildKit
uses: reproducible-containers/buildkit-cache-dance@v3
with:
cache-map: |
{
"go-mod-cache": "/go/pkg/mod",
"go-build-cache": "/root/.cache/go-build"
}
- name: Build and push
uses: docker/build-push-action@v7
with:
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
tags: 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:

yaml
1234
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
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.