How to Cache Docker Layers in GitHub Actions
Every CI job starts with an empty Docker cache unless you give it one. That single fact explains most slow container builds, and it is fixable in about six lines.
A Docker build on a fresh runner has no layer cache, so every layer rebuilds regardless of whether anything changed. Locally you never see this because your daemon keeps layers between builds.
buildx supports external cache backends that persist layers between CI runs. The setup is short; the part worth understanding is which backend to use, because the obvious choice runs into the repository cache cap on any non-trivial image.
The minimal working setup
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
push: false
tags: app:ci
cache-from: type=gha
cache-to: type=gha,mode=maxChoosing a cache backend
| Backend | Stored in | Best for | Watch out for |
|---|---|---|---|
type=gha | Actions cache | Small and medium images | Counts against the 10 GB repository cap |
type=registry | A container registry | Large images, shared across repos | Registry storage cost and auth |
type=local | Runner disk | Self-hosted with persistent disk | Useless on ephemeral runners |
type=inline | The image itself | Simple single-stage builds | Final stage only, no intermediates |
Registry cache for anything substantial
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
permissions:
contents: read
packages: writeOrder your Dockerfile so the cache can work
No cache backend helps if every build invalidates the first layer. Copy dependency manifests and install before copying source, so a source change does not invalidate the install layer.
# cache-hostile: any source change reinstalls everything
COPY . .
RUN npm ci
# cache-friendly: install only re-runs when the lockfile changes
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Verify it is actually hitting
- run: docker buildx build --progress=plain . 2>&1 | grep -E "CACHED|DONE" | head -20
# CACHED on the install layer means it is working.
# All layers rebuilding means cache-from is not matching.Measure before you optimise
Pipeline optimisation usually targets the step people assume is slow. Get the real per-step timings first, because the answer is frequently dependency install or a cold cache rather than the build itself.
# per-job timings for the last 20 runs
gh run list --limit 20 --json databaseId,conclusion,createdAt,updatedAt \
--jq '.[] | "\(.conclusion)\t\(.createdAt)\t\(.updatedAt)"'
# per-step timing inside one run
gh run view <run-id> --log | grep -E "^\S+\s+.*Run |##\[group\]" | head -40Frequently asked questions
Why is my Docker build slow in GitHub Actions but fast locally?
What is the difference between mode=min and mode=max?
mode=min caches only the layers in the final image; mode=max also caches intermediate stages. For multi-stage builds mode=max is what makes caching worthwhile, at the cost of a larger cache.Does the GitHub Actions cache have a size limit?
mode=max Docker layer cache for a large image can consume it and evict your dependency caches, which shows up as unrelated jobs slowing down. Use a registry cache for large images.Should I use type=gha or type=registry?
type=gha for small and medium images where you will not approach the 10 GB cap. type=registry for large images, for sharing a cache across repositories, or when you are already pushing to a registry anyway.