← Recipes

阅读中文版本

Share a Docker Build Cache Across CI Runs

Use the GitHub Actions cache backend for BuildKit so image rebuilds reuse layers instead of reinstalling dependencies every run.

~25 mindockercicaching

Task

Cut container build time in CI without publishing cache layers as normal image tags that anyone can pull.

When to use this

Every pull request rebuilds the same base image and installs the same dependencies, adding eleven minutes to each run and burning runner minutes.

Prerequisites

  • Docker Buildx 0.13 or newer in the workflow
  • BuildKit enabled with a named builder
  • A registry or the GitHub Actions cache available to the job

Steps

1. Build with the gha cache backend for both import and export.

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/acme/api:${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max

Expected output: The second run reports CACHED for dependency layers and finishes in a fraction of the time.

2. Scope the cache per branch so feature builds cannot evict the main branch cache.

cache-from: type=gha,scope=${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}

Expected output: Each branch writes its own cache scope and does not overwrite the default branch cache.

3. Order the Dockerfile so lockfiles are copied before source, keeping dependency layers stable.

COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

Expected output: Changing application source invalidates only the last layers, not the dependency install.

4. Confirm no secret material can enter cached layers.

.env
.env.*
node_modules
*.pem

Expected output: A build context that excludes secrets keeps them out of every cached layer.

Troubleshooting

Problem

The cache never hits even when nothing changed

Solution

The first changed instruction invalidates everything after it. Pin base images by digest and copy lockfiles before source.

Problem

Cache export fails with a size error

Solution

The Actions cache has a size limit per entry; use mode=min or split the build into fewer, smaller stages.

Limitations

  • The Actions cache is repository scoped and evicted after inactivity, so it is an optimization, not storage.
  • mode=max caches intermediate stages and can exhaust the repository cache quota quickly.

Cache layers, not images

Publishing cache layers as :cache tags works, but it puts build internals in the same namespace users pull from and makes garbage collection ambiguous. The type=gha backend stores the cache in the GitHub Actions cache service instead, scoped to the repository. BuildKit imports it with cache-from, exports with cache-to, and the registry only receives the final image. Enable it once in the build step and every subsequent run benefits; no extra infrastructure is required.

- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/acme/api:${{ github.sha }}
    cache-from: type=gha,scope=${{ github.ref_name }}
    cache-to: type=gha,mode=max,scope=${{ github.ref_name }}

Make hits likely

Cache hits depend on instruction order and input stability. Copy package.json and the lockfile, install, then copy source. Pin the base image by digest so an upstream tag change does not invalidate the first layer silently. If the Dockerfile runs apt-get update, combine update and install in one layer, because a cached package index goes stale and produces confusing failures.

Scope and size

Use a per-branch scope so a large feature build does not evict the main branch cache, and let the default branch own the canonical scope. mode=max caches intermediate stages and speeds up multi-stage builds, but each entry counts against the repository cache quota; on large images prefer mode=min. Treat all cache content as untrusted when it returns: BuildKit verifies layer content, but you should still scan the final image rather than assuming a cached layer is safe.