Skip to content
compiler.dev

Docker Layer Caching in CI With Buildx

CI runners start empty, so every docker build begins with no layers and rebuilds everything. On a typical app image that means reinstalling all dependencies on every push. Layer caching fixes that by saving the build cache between runs. This guide shows the two backends that matter in GitHub Actions and how to write a Dockerfile that actually hits the cache.

Fix the Dockerfile first

A cache is only useful if layers stay identical between builds. Docker invalidates a layer, and every layer after it, when the instruction or the files it copies change. Put things that change rarely at the top:

FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]

The npm ci layer is reused until the lockfile changes. If you wrote COPY . . before the install, every source edit would reinstall everything. Add a .dockerignore with .git, node_modules and build output, so unrelated files do not bust the cache.

For package managers, BuildKit cache mounts keep the download cache even when the layer is rebuilt:

RUN --mount=type=cache,target=/root/.npm npm ci

Note that cache mounts live in the BuildKit builder, not in the layer cache, so they need separate handling in CI (below).

Backend 1: the GitHub Actions cache (type=gha)

The simplest setup uses Docker's build-push-action and the GitHub cache service:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    tags: myapp:ci
    cache-from: type=gha
    cache-to: type=gha,mode=max

mode=max exports all layers, including those of intermediate stages in a multi-stage build. The default, mode=min, exports only the layers of the final image, which is a poor fit for multi-stage Dockerfiles. Use max and accept the larger cache.

Trade-offs, using the limits in GitHub's docs: the cache counts toward the repository's 10 GB cache storage, entries unused for 7 days are evicted, and branch scoping applies as for any Actions cache. Large images can fill the quota quickly and evict your other caches. Use a scope per image if a repository builds several:

cache-from: type=gha,scope=api
cache-to: type=gha,scope=api,mode=max

Backend 2: a registry cache (type=registry)

Store the cache as an image in your registry (GHCR, ECR, Docker Hub). It does not count against the Actions cache quota, survives longer, and can be shared with developer machines and other CI systems:

- uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
    cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
    cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max

The job needs permissions: packages: write to push to GHCR. For ECR, use image-manifest=true,oci-mediatypes=true in cache-to, because ECR requires OCI media types for cache manifests. Check the AWS and Docker docs for your registry; support varies.

Which backend to pick

type=ghatype=registrytype=local + actions/cache
SetupTwo linesLogin and a cache tagExtra move step
Storage limitShares 10 GB repo cacheYour registry quotaShares 10 GB repo cache
Shared with laptopsNoYesNo
Pull costCache serviceRegistry egress, often in-regionCache service

For most teams: start with type=gha, and move to type=registry when images are large or when you build outside GitHub too.

Cache mounts in CI

RUN --mount=type=cache data is not exported by cache-to. If you rely on cache mounts for package downloads, you can persist them with a helper such as reproducible-containers/buildkit-cache-dance, or skip mounts in CI and rely on layer caching of the install step. For most apps the second option is simpler.

Check that it works

In the build log, cached steps are marked CACHED. If your install layer runs every time, look for these causes:

  • A COPY . . before the install step.
  • A build argument or timestamp that changes per run (ARG BUILD_DATE before the install).
  • .dockerignore missing, so the context hash changes with unrelated files.
  • A mode=min cache with multi-stage builds.
  • A base image tag such as node:22-slim that moved. This is correct behaviour, but it invalidates everything after it. Pin by digest if you want predictable cache hits, and update it with Dependabot.

Other speed-ups for image builds

  • Build one platform in CI unless you ship multi-arch images. Emulated arm64 builds under QEMU are many times slower than native; use a native arm64 runner for that job when you need both.
  • Use a slim or distroless base to shrink pushes and pulls.
  • Run tests against the built image in a later job with load: true, rather than rebuilding it.

FAQ

Does GitHub offer built-in Docker layer caching?

GitHub-hosted runners do not keep Docker layers between jobs. You export and import the cache with buildx as above.

Can I use layer caching without buildx?

The classic builder cannot import a cache from a remote store in the same way. Use BuildKit through docker buildx or build-push-action.

Why is my cache larger than my image?

mode=max stores every stage and intermediate layer. That is expected.

Is the registry cache safe for forks?

Pull requests from forks do not get write access to your registry or secrets. Use cache-from only for those runs, and write the cache from main.

Next steps

See how to speed up GitHub Actions for the rest of the pipeline, and the cost calculator to turn saved minutes into dollars. If your image builds are CPU-bound, compiler.dev's comparison mode runs the same job on a larger machine so you can compare time and price per run.

Made by compiler.dev. Free tools · Pricing