---
title: "Docker layer caching in GitHub Actions: measured cache hits"
description: "A tested Buildx workflow with the gha cache backend, five samples per case, and logs showing what survives source edits and dependency changes."
url: https://9apes.com/blog/docker-layer-caching-github-actions/
---

# Docker layer caching in GitHub Actions: measured cache hits

A cache import can succeed while your compiler still runs. The useful question is whether the expensive Dockerfile instruction shows `CACHED`, and whether restoring and exporting the cache takes less time than rebuilding.

We tested a small Go application on GitHub-hosted Linux runners with a new BuildKit builder for every build. The unchanged build took **2.179 seconds at the median**, compared with **22.908 seconds for the first build that populated the cache**. A source edit retained the dependency-download layer but reran compilation. A dependency update reran both.

Those are measurements for this fixture, not a promised speedup for your application. The [evidence download](/downloads/ci-lab-cache-evidence-2026-09-16.zip) contains the fixture, pinned workflow, all twenty timing records, hardware details, input hashes, and BuildKit progress logs. Tests ran on September 15 and 23, 2026 (UTC), with results shown separately.

## The workflow that makes reuse possible

Use a Buildx builder with the `docker-container` driver, import the previous cache, and export the new one. Keep the scope stable for one image and platform. This example expects the downloadable `fixture` directory at the repository root.

```yaml
name: Build the fixture
on:
  workflow_dispatch:
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
        with:
          version: v0.37.1
          driver: docker-container
          driver-opts: image=moby/buildkit:v0.33.0@sha256:6c2fa84a6b61ccd72899dde4239f8d5717f05f9a8ca6f3cad185fb1a95a94de3
      - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: ./fixture
          load: true
          tags: ci-lab:check
          cache-from: type=gha,version=2,scope=ci-lab-amd64
          cache-to: type=gha,version=2,scope=ci-lab-amd64,mode=max
      - run: docker run --rm ci-lab:check
```

Run it twice on the same branch. The first run creates the cache. The second should reuse the unchanged build. `load: true` imports the result into the runner's Docker daemon so the final step can execute it. A release workflow that pushes to a registry has a different output cost, so measure that workflow separately.

The Docker action supplies the cache service's runtime authentication automatically. No personal access token or AWS credential is needed. Explicit `version=2` selects the cache protocol. Docker currently labels the `gha` backend experimental and documents its driver and access requirements in the [backend reference](https://docs.docker.com/build/cache/backends/gha/).

## Put dependency inputs before application source

This is the Dockerfile used in all twenty builds:

```dockerfile
FROM golang:1.26-alpine@sha256:ce864e7223ac17b1775e6fd0b4c0db580c2eb50e7953a427916379e4b92a1628 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/ci-lab .
FROM scratch
COPY --from=build /out/ci-lab /ci-lab
USER 65532:65532
ENTRYPOINT ["/ci-lab"]
```

The module download depends on `go.mod` and `go.sum`. Application source enters afterward. Changing the printed message therefore leaves the dependency step's inputs intact. Copying all source before the download would couple that step to ordinary source edits. Docker's [cache optimization guide](https://docs.docker.com/build/cache/optimize/) describes this ordering and the role of a small build context.

The fixture imports `github.com/google/uuid` at `v1.5.0`, generates a deterministic UUID, and exits. The dependency variant updates both module files to `v1.6.0`. This is an actual dependency change, rather than a comment added to a lockfile. The source variant changes the output label without changing module files.

For this multi-stage image, `mode=max` preserves intermediate build-stage cache as well as the final result. The final scratch image contains the executable, not the compiler and downloaded modules. See Docker's [cache export modes](https://docs.docker.com/build/cache/backends/) when choosing how much intermediate data to retain.

## Twenty builds with isolated builders

Each of five samples contained the same four cases:

| Case | Input and cache behavior |
|---|---|
| Cold | Baseline inputs, no remote cache import, export a new seed |
| Warm | Identical inputs, import that sample's seed |
| Source | Change the printed label, import the unchanged seed |
| Dependency | Update UUID from v1.5.0 to v1.6.0, import the unchanged seed |

The seed scope included the workflow run, attempt, and sample number. Warm, source, and dependency builds exported to separate scopes. They never overwrote the seed used by another case.

Every case started a distinct BuildKit builder, and that builder was removed after its output was checked. This prevents the preceding build's local BuildKit state from impersonating a remote-cache hit. Every resulting image ran successfully and produced its expected message and UUID.

| Component | Tested configuration |
|---|---|
| Runner | GitHub-hosted `ubuntu-24.04`, 2 logical CPUs |
| CPU | Intel Xeon Platinum 8573C (sample 1), AMD EPYC 7763 (samples 2, 3, 5), AMD EPYC 9V74 (sample 4) |
| Runner image | `ubuntu24 20260907.300.1` |
| Buildx | v0.37.1 |
| BuildKit | v0.33.0, pinned by image digest |
| Go base | `golang:1.26-alpine`, pinned by the digest in the Dockerfile |
| Platform | `linux/amd64` |
| Source revision | `544f3395cdc3ca32c0b364945ac46039cfa4a0dd` |

The timer starts immediately before the build action and stops immediately afterward. It includes action startup, cache import, compilation when necessary, image export and Docker load, remote cache export, and small step-transition overhead. It excludes checkout, builder setup, executable verification, builder teardown, and artifact upload.

## Results, including every sample

All values below are seconds. Medians and ranges use the unrounded records in the download.

| Case | Sample 1 | Sample 2 | Sample 3 | Sample 4 | Sample 5 | Median | Range |
|---|---:|---:|---:|---:|---:|---:|---|
| Cold | 30.712 | 21.300 | 22.908 | 20.779 | 27.690 | 22.908 | 20.779 to 30.712 |
| Warm | 3.024 | 2.179 | 2.083 | 1.891 | 5.086 | 2.179 | 1.891 to 5.086 |
| Source | 24.853 | 22.106 | 23.379 | 21.159 | 31.583 | 23.379 | 21.159 to 31.583 |
| Dependency | 24.085 | 23.066 | 22.873 | 21.214 | 32.145 | 23.066 | 21.214 to 32.145 |

The warm median was about 90% lower than the cold median. Source and dependency changes were close to the cold result: their medians were 23.379 and 23.066 seconds. In this fixture, retaining the dependency snapshot did not produce a lower source-build median. Cache correctness and useful time savings are separate findings.

The cold result includes populating a remote cache for the first time. It is **not a cache-disabled build**. These measurements answer how the configured cache behaves across controlled changes. They do not establish that enabling cache beats disabling it for every build.

Each sample ran cold, warm, source, and dependency in that fixed order. The Docker daemon was shared within a sample, so its builder-image store could already be warm. Builder setup is outside the timer, but network conditions and execution order still limit the comparison. The five jobs also used three different host CPU models, so the pooled median includes hardware variation. Five samples from one small application cannot characterize a production workload's tail latency.

## Reading the cache logs

All five unchanged builds explicitly marked compilation as cached. The source-case logs need a closer reading:

| Instruction | Cold | Warm | Source edit | Dependency update |
|---|---|---|---|---|
| `RUN go mod download` | Executed | Explicit cache hit | Snapshot restored; 2 explicit cache markers | Executed |
| `RUN CGO_ENABLED=0 go build ...` | Executed | Cached | Executed | Executed |

For the source case, samples 1 and 5 contain an explicit `CACHED` marker for module download. Samples 2, 3, and 4 show the stored snapshot being downloaded and extracted under that instruction, without a separate marker. The evidence records these observations separately. Even sample 1 prints `CACHED` before spending 6.2 seconds materializing that snapshot. A cache hit still has a transfer cost.

In the downloaded logs, follow all lines for a numbered instruction, including later download and extraction lines. A message about importing a cache manifest says the builder found metadata. It does not establish a hit for your compilation step.

A dependency change should invalidate the module-copy step and downstream work. A source change should invalidate the source-copy step and compilation. Those misses are correct. Docker explains which instruction inputs participate in this decision in its [cache invalidation rules](https://docs.docker.com/build/cache/invalidation/).

## When an unchanged build still misses

Check the export from the previous run first. An import cannot restore a cache that was never written, failed to export, or was removed. Then compare the cache scope and branch between runs.

The backend's default scope is `buildkit`. Docker documents that separate image builds using that scope can overwrite each other's cache. Give each image and platform an intentional scope, such as `api-amd64`. A per-commit scope prevents ordinary successive commits from sharing the same cache. Our experiment used unique scopes to separate measurements; the everyday workflow above intentionally uses a stable one. Scope collisions were not tested here. [Docker's scope documentation](https://docs.docker.com/build/cache/backends/gha/#scope) is the source for that behavior.

Next, inspect the first expensive step that stops hitting. Check its preceding `COPY` inputs, generated files, changed build arguments, and the base-image digest. Adding a current timestamp to a build argument can turn each invocation into different input. Narrowing the build context and copying dependency manifests separately makes such changes easier to see.

## Layer cache and cache mounts are different

There are no cache mounts in this Dockerfile. The module download writes into a normal build layer, which the remote layer cache can restore. The successful compilation also exists as a layer.

A mount such as `RUN --mount=type=cache,target=/root/.cache/go-build` gives the compiler a mutable working cache when a build step executes again. It does not mean that a `gha` layer export automatically preserves that mount across disposable builders. Docker documents extra handling for [cache mounts in GitHub Actions](https://docs.docker.com/build/ci/github-actions/cache/#cache-mounts).

That distinction matters for source changes. Reusing the dependency-download layer does not make a changed compilation layer a cache hit. A compiler cache is a separate experiment, with its own persistence and transfer costs.

## Does the runner still matter once caching works?

On September 23 (UTC), we repeated the same fixture on GitHub's `ubuntu-24.04` runner and our `9apes-2vcpu-ubuntu-2404` runner in the same private GitHub repository. This is a new comparison, with a fresh GitHub baseline. The September 15 measurements above remain unchanged.

We ran five paired rounds. Each round dispatched one job per provider, seven or eight seconds apart, alternating which provider went first. Each job ran the same four cases with four fresh builders. Cache scopes included the provider, run, attempt, and sample, so neither provider imported the other's seed. All 40 builds and executable checks passed. The [comparison evidence](/downloads/ci-lab-cache-provider-evidence-2026-09-23.zip) includes every sample, environment records, progress logs, and an offline verifier.

Both offerings exposed two CPUs. Ubuntu 24.04, Docker 28.0.4, Buildx 0.37.1, and BuildKit 0.33.0 matched. The environments were not identical: GitHub used three processor models, while 9apes exposed only a generic AMD EPYC identifier. Reported memory and the containerd patch version also differed. The available cgroup files did not establish CPU quotas, and the exact 9apes host processor SKU was not exposed. This compares the offered runner tiers and their network paths, not CPU performance in isolation.

### Build-step results

The timer uses the same boundary as the original experiment: immediately before the build action through immediately after it, including cache transfer and image loading. Each median has five samples; ranges show the smallest and largest observations. Times are seconds.

| Case | GitHub median | GitHub range | 9apes median | 9apes range |
| --- | ---: | --- | ---: | --- |
| Cold seed | 24.817 | 22.137 to 26.915 | 16.639 | 8.606 to 21.475 |
| Unchanged | 2.839 | 2.181 to 5.031 | 2.583 | 1.424 to 2.848 |
| Source edit | 23.964 | 22.793 to 31.011 | 15.710 | 7.553 to 17.430 |
| Dependency update | 25.450 | 23.424 to 32.853 | 15.241 | 7.617 to 15.714 |

For this fixture, the 9apes source-edit median was about 34% lower and the dependency-update median about 40% lower. Those cases still had compilation work to do. The unchanged-build median differed by only 0.256 seconds: caching had already removed almost all of that work.

The 9apes ranges are important too. Source-edit builds ranged from 7.553 to 17.430 seconds despite the same exposed processor description. These five observations do not establish a general speedup or a production latency distribution.

### Include startup, whole-job time, and cost

Each experiment job contains four builds, plus checkout, builder setup, output verification, artifact upload, and cleanup. The whole-job figures below describe that harness, not a single production build. GitHub's API lifecycle timestamps have whole-second resolution.

| Measurement | GitHub | 9apes |
| --- | ---: | ---: |
| Workflow creation to job start, median (s) | 4 | 13 |
| Whole-job duration, median (s) | 108 | 84 |
| Workflow creation to completion, median (s) | 114 | 97 |
| Whole-job samples (s) | 100, 118, 131, 108, 98 | 48, 91, 85, 47, 84 |
| Sum of per-job rounded-up minutes | 11 | 8 |
| Modeled list-price total for five jobs | 6.60 US cents | 1.44 US cents |

9apes took longer to start in this sample. Its median creation-to-completion interval was still lower, but the difference was smaller than the build-step results alone suggest. The separate medians in the table are not additive because they can come from different samples.

The cost model rounds each job up to a whole minute and applies the September 23 two-vCPU list rate for each provider. It uses [GitHub's published billing rule](https://docs.github.com/en/billing/reference/actions-runner-pricing) and the [9apes rate card](/pricing/); exact dated inputs are in the download. These are modeled list-price totals before included minutes, discounts, storage, and taxes, not observed invoice charges. They do not establish the cost of a workflow containing only one unchanged build.

A GitHub API timeout interrupted collection after the first pair. Both completed runs were recovered without rerunning a sample; the next pair began about 41 minutes later. Fixed case order, changing host conditions, network paths, and a small fixture limit the comparison. All original observations are retained.

To try the same 9apes tier, follow the [quick start](/docs/quick-start/) to connect your repository, then change the runner selection:

```diff
- runs-on: ubuntu-24.04
+ runs-on: 9apes-2vcpu-ubuntu-2404
```

Keep the cache configuration and workload fixed, and measure your own end-to-end job. For this fixture, 9apes reduced the work left after a cache miss. An unchanged build was already short enough that runner startup deserves at least as much attention as build speed.

## Reproduce before changing your runner

Download the evidence bundle, follow `cache/README.md`, and run the five-sample workflow in a disposable repository. It needs no custom secrets, runs at most two jobs concurrently, and caps each of five jobs at twenty minutes. Check your repository's Actions allowance before dispatching it. The bundle includes the analyzer that checks all twenty outputs, verifies builder uniqueness, and checks explicit cache markers and source-snapshot materialization separately.

For the paired provider comparison, use `cache-provider/README.md` from the separate comparison bundle and a private disposable repository. It documents paired dispatch, evidence collection, and offline verification.

Then replace the fixture with your application and repeat the same cases. Keep image loading or pushing consistent with the real workflow, and compare the complete build step. If transferring a larger cache costs more than the work it avoids, that is a useful result too.

Caching is one part of [reducing a GitHub Actions bill](/blog/cut-your-github-actions-bill/). Establish what is actually reused before attributing a faster run to more CPU or a different runner provider.
