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=gha | type=registry | type=local + actions/cache | |
|---|---|---|---|
| Setup | Two lines | Login and a cache tag | Extra move step |
| Storage limit | Shares 10 GB repo cache | Your registry quota | Shares 10 GB repo cache |
| Shared with laptops | No | Yes | No |
| Pull cost | Cache service | Registry egress, often in-region | Cache 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_DATEbefore the install). .dockerignoremissing, so the context hash changes with unrelated files.- A
mode=mincache with multi-stage builds. - A base image tag such as
node:22-slimthat 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