Docker Builds: Getting ccache in with BuildKit Cache Mounts

2026/02/20

BUILDcachingdocker🍞

You’re building C++/CUDA code inside Docker. Compilation takes forever. You want ccache. But Docker builds are hermetic — there’s no persistent /root/.cache/ccache between builds.

Turns out there’s a neat two-part trick to solve this.

The Problem

Docker’s --mount=type=cache gives you persistent caches within BuildKit. So you can do:

RUN --mount=type=cache,target=/root/.cache/ccache \
    --mount=type=cache,target=/root/.cache/uv \
    uv sync --package rocket --frozen

This works great locally — BuildKit keeps the cache volume around between builds. But in CI, your BuildKit instance is ephemeral. Fresh runner, fresh BuildKit, empty cache. Every time.

The Fix: Cache Dance

The trick is bridging GitHub Actions cache into BuildKit’s cache mounts. You need two things:

1. A GitHub Actions cache step — stores ccache data between CI runs:

- name: Cache ccache data
  uses: actions/cache@v4
  with:
    path: ccache-cache
    key: ccache-v3-${{ hashFiles('mai_kernels/**/*.cu', 'mai_kernels/**/*.cpp', 'mai_kernels/**/*.h') }}
    restore-keys: |
      ccache-v3-

2. The buildkit-cache-dance action — injects that cached directory into BuildKit’s cache mounts:

- name: Inject caches into Docker
  uses: reproducible-containers/buildkit-cache-dance@v3
  with:
    builder: ${{ steps.setup-buildx.outputs.name }}
    cache-map: |
      {
        "ccache-cache": "/root/.cache/ccache"
      }

That cache-map is the key part — it maps your Actions cache path (ccache-cache on the runner) to the BuildKit cache mount path (/root/.cache/ccache inside the build). The action handles the plumbing of getting data in and out.

Why It’s Two Layers

BuildKit cache mounts and GitHub Actions cache are completely separate systems. BuildKit doesn’t know about actions/cache, and actions/cache doesn’t know about BuildKit volumes.

The buildkit-cache-dance action bridges them — it pre-populates BuildKit’s cache mount from the Actions cache before the build, and extracts the updated cache after the build so Actions can persist it.

Without it, you’d need to COPY the cache directory into the image (bloating it) or give up on ccache in CI entirely.

The Gotcha

Don’t forget the id on your setup-buildx step:

- name: Set up Docker Buildx
  id: setup-buildx  # <-- need this
  uses: docker/setup-buildx-action@v3

The cache-dance action needs ${{ steps.setup-buildx.outputs.name }} to know which builder to inject into. Miss the id and it silently does nothing.

Also — if you have multiple workflows building the same Dockerfile, you need the cache-dance in all of them. Adding --mount=type=cache,target=/root/.cache/ccache to your Dockerfile but only wiring up the cache in one workflow means the other workflows just see an empty cache mount. Ask me how I know.