| |

Docker 85 🐳 CI/CD Pipeline Caching: GitHub Actions Cache Layer Optimization for Docker

⏱️ Estimated read: 16 min | |

The difference between a Docker CI pipeline that finishes in thirty seconds and one that takes four minutes is almost never the code. It is whether the layer cache survived from the previous run. GitHub-hosted runners are ephemeral: every job starts with an empty filesystem, and BuildKit’s local cache—the default mechanism that makes local builds fast—does not persist across workflow runs. The type=gha cache backend solves this by exporting BuildKit layers to GitHub’s Actions cache service before the runner is discarded, and importing them at the start of the next run .

The mechanism is straightforward: cache-to: type=gha,mode=max uploads every intermediate layer, not just the final image, and cache-from: type=gha downloads them for reuse. The mode=max setting is critical. The default min mode caches only the layers present in the final image, which excludes intermediate build stages and multi-stage builder layers. For a Dockerfile that compiles dependencies in a builder stage and copies artifacts into a runtime stage, min mode caches nothing useful. max mode caches the builder stage, so dependency compilation is skipped when only application code changes .

There are three practical constraints that determine whether type=gha works. First, the backend is experimental and requires BuildKit; the default Docker driver does not support it, so docker/setup-buildx-action must precede the build . Second, cache write access depends on the workflow trigger. pull_request events from forks and issue_comment events receive read-only cache access, meaning cache-to fails silently or errors with “failed to reserve cache” even after a successful build . Third, GitHub’s cache storage has a 10 GB per-repository limit. A large multi-architecture image with mode=max can exceed this limit and cause cache eviction, turning every build back into a cold build .

The solution for the size limit is the type=registry backend. It stores cache layers in a container registry (GHCR or Docker Hub) instead of GitHub’s cache service, removing the 10 GB cap. A repository that hit the limit with a 6 GB image across two architectures switched to type=registry with a dedicated buildcache tag and recovered cache hits . The trade-off is that registry cache requires registry authentication and incurs storage costs, but for images above roughly 1–2 GB, the reliability outweighs the cost.

Cache scope is the final optimization. The scope parameter isolates cache entries by name. Without it, multiple images built in the same workflow overwrite each other’s cache under the default buildkit scope, leaving only the last build’s cache intact . For multi-platform builds, per-platform scopes (scope=amd64, scope=arm64) prevent one platform’s layers from evicting the other’s.

This chapter covers the type=gha and type=registry backends, the mode=max requirement, scope isolation, the pull-request cache write restriction, the 10 GB limit and its registry workaround, and the Dockerfile ordering that determines what can be cached at all.

Key point: type=gha persists BuildKit cache across GitHub Actions runs; mode=max caches builder stages; and the 10 GB repository limit means large images must use type=registry instead. A cold cache is almost always a configuration problem, not a BuildKit failure.


Why GitHub Actions caching requires explicit configuration

Ephemeral runners discard local cache. BuildKit’s default cache lives on the runner’s local filesystem. When the job ends, the runner is destroyed and the cache with it. The type=gha backend exports layers to GitHub’s cache service as a separate step after the build, and imports them at the start of the next run . Without this export/import cycle, every run compiles every dependency from scratch, even if nothing changed.

The min mode is the default and it is rarely what you want. The mode parameter controls which layers are exported. min exports only layers present in the final image. A multi-stage Dockerfile’s builder stage—where npm install, pip install, or cargo build runs—is not in the final image. With min mode, those layers are not cached, and the next build re-runs the entire dependency installation . mode=max exports every layer, including builder stages, which is the only setting that produces meaningful cache hits for compiled or dependency-heavy images .

Cache write access depends on the event. GitHub gives pull_request workflows from forks and pull_request_target workflows read-only access to the cache. The cache-from import works, but cache-to export fails. A workflow that runs on both push and pull_request must either disable cache-to for pull requests or accept that pull request builds will not populate the cache for subsequent runs . The Docker documentation describes the error as “failed to reserve cache” appearing after the image is built and pushed, which makes the failure easy to miss.

The 10 GB limit is a hard ceiling. GitHub Actions cache storage is capped at 10 GB per repository. A mode=max cache for a large image—especially a multi-architecture image—can exceed this. When the limit is reached, GitHub evicts the oldest entries, and subsequent builds find no cache to import. The result is a cycle: a few builds hit cache, then eviction resets everything . The type=registry backend avoids this entirely because it stores cache in a container registry, which has no equivalent per-repository cap.

The trade-off. type=gha is free for public repositories and requires no additional credentials beyond the automatic workflow token. type=registry requires registry authentication and incurs storage costs, but it is the only backend that scales beyond 10 GB. For small images, type=gha is the correct default. For large or multi-architecture images, type=registry is the correct escape hatch.


a. Configuring type=gha with mode=max and scope

The workflow requires three actions in sequence: docker/setup-buildx-action to enable BuildKit, docker/build-push-action with cache-from and cache-to, and optionally docker/login-action if the workflow also pushes the image. The cache configuration is two lines in the build step.

- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v4

- name: Build and push
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    tags: user/app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

The scope parameter isolates cache entries. Without it, every build in the repository writes to the same cache namespace, and the last build overwrites the previous one. For a repository with multiple images—an API, a worker, a frontend—each image needs its own scope .

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

For multi-platform builds, per-platform scopes prevent eviction between architectures. The amd64 and arm64 layers are different and do not share cache entries, but without scopes they compete for the same namespace.

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

The mode=max setting applies only to cache-to. cache-from does not need a mode; it imports whatever the exporter wrote. The ignore-error parameter is useful for pull request workflows where cache export may fail due to read-only access; setting ignore-error=true on cache-to prevents the build from failing when the cache write is denied .


b. The type=registry backend and the 10 GB workaround

When the image’s mode=max cache exceeds GitHub’s 10 GB limit, the registry backend is the fix. It requires a dedicated cache tag in a registry—GHCR or Docker Hub—and authentication with push access to that tag.

- name: Login to GHCR
  uses: docker/login-action@v4
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push with registry cache
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    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

The buildcache tag is not an image tag; it is a cache manifest stored in the registry. The actual image is pushed to the latest tag separately. The two tags are independent, and the cache tag can be pruned or overwritten without affecting the image .

A GitHub issue documenting the 10 GB eviction problem describes a devcontainer image that grew to over 6 GB across two architectures with mode=max. The cache exceeded the limit, was evicted, and every CI build took 30–45 minutes. Switching to type=registry removed the size cap and restored cache hits . The same solution applies to any image where mode=max cache exceeds a few gigabytes: use type=registry for the cache, and type=gha only for small images where the 10 GB limit is not a concern.

The registry backend has one additional cost: cache export and import consume network bandwidth. For a large image, uploading a multi-gigabyte cache on every build that misses is slower than GitHub’s internal cache service. The trade-off is between the 10 GB limit and network overhead. For images under 2 GB, type=gha is usually faster. For images above 4–5 GB, the limit forces the registry backend regardless of the network cost.


c. Dockerfile ordering and what can actually be cached

Caching is not a BuildKit setting alone. The Dockerfile determines which layers are stable enough to cache. A Dockerfile that copies the entire source tree before installing dependencies invalidates the dependency installation layer on every code change, even with mode=max and a perfect cache backend. The layer ordering must place dependency manifests before source code.

# Good: dependencies cached separately from source.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Bad: any source change invalidates npm ci.
COPY . .
RUN npm ci
RUN npm run build

The same principle applies to Go, Rust, and Python. Copy go.mod and go.sum first, then go mod download, then source. Copy requirements.txt first, then pip install, then source . The cache backend can only cache what the Dockerfile presents as stable layers. A poorly ordered Dockerfile defeats mode=max entirely.

BuildKit’s cache mounts (RUN --mount=type=cache) are a separate optimization that complements type=gha. Cache mounts persist package manager downloads across builds on the same runner, but they are local to the runner and do not survive the ephemeral job. The type=gha backend caches the resulting layer, which includes the downloaded dependencies if the RUN instruction produces a filesystem change. For package managers that write to a cache directory outside the layer (Go modules, Cargo registry), --mount=type=cache is necessary to capture the downloads, and the layer cache captures the compiled artifacts.


Complete Example Session

# ============================================
# PART 1: WORKFLOW TRIGGER
# ============================================
name: docker-cache-ci
on:
  push:
    branches: [main]
  pull_request:

# ============================================
# PART 2: BUILDX SETUP (REQUIRED FOR TYPE=GHA)
# ============================================
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v4

# ============================================
# PART 3: TYPE=GHA WITH MODE=MAX AND SCOPE
# ============================================
      - name: Build with GHA cache
        uses: docker/build-push-action@v7
        with:
          context: .
          push: false
          load: true
          tags: app:test
          cache-from: type=gha,scope=app
          cache-to: type=gha,mode=max,scope=app

# ============================================
# PART 4: CACHE WRITE RESTRICTION FOR PRS
# ============================================
      # On pull_request from forks, cache-to fails with
      # "failed to reserve cache" because GitHub grants
      # read-only access. The fix is conditional export.
      - name: Build with conditional cache export
        uses: docker/build-push-action@v7
        with:
          context: .
          push: false
          load: true
          cache-from: type=gha,scope=app
          # Only export cache on push events.
          cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max,scope=app' || '' }}

# ============================================
# PART 5: REGISTRY CACHE FOR LARGE IMAGES
# ============================================
      - name: Login to GHCR
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build with registry cache
        uses: docker/build-push-action@v7
        with:
          context: .
          push: true
          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

# ============================================
# PART 6: MULTI-PLATFORM WITH PER-PLATFORM SCOPE
# ============================================
      # Each platform gets its own scope to avoid eviction.
      - name: Build amd64
        uses: docker/build-push-action@v7
        with:
          platforms: linux/amd64
          cache-from: type=gha,scope=app-amd64
          cache-to: type=gha,mode=max,scope=app-amd64

      - name: Build arm64
        uses: docker/build-push-action@v7
        with:
          platforms: linux/arm64
          cache-from: type=gha,scope=app-arm64
          cache-to: type=gha,mode=max,scope=app-arm64

# ============================================
# PART 7: DOCKERFILE ORDERING FOR CACHE HITS
# ============================================
# COPY package.json package-lock.json ./
# RUN npm ci
# COPY . .
# RUN npm run build
# Dependencies cached independently of source.

# ============================================
# PART 8: CACHE MOUNTS FOR PACKAGE MANAGERS
# ============================================
# RUN --mount=type=cache,target=/go/pkg/mod \
#     go build -o /app .
# Downloads persist locally; layer cache captures artifact.

# ============================================
# PART 9: VERIFYING CACHE HITS
# ============================================
      - name: Check cache utilization
        run: |
          # build-push-action v7 generates a job summary
          # showing cache utilization per layer.
          # Look for "CACHED" in build logs.
          echo "Check GitHub job summary for cache hits."

# ============================================
# PART 10: THE COMPLETE FLOW
# ============================================
# 1. Buildx setup (required for type=gha).
# 2. cache-from type=gha,scope=app.
# 3. cache-to type=gha,mode=max,scope=app.
# 4. Conditional export for pull_request.
# 5. type=registry if cache exceeds 10 GB.
# 6. Per-platform scope for multi-arch.
# 7. Dockerfile ordering: manifests before source.

Quick Reference

Cache Backend Comparison

BackendStorageSize LimitCostAuth
type=ghaGitHub Actions cache10 GB/repoFreeAutomatic
type=registryContainer registryNo capStorage costsRegistry login

Mode Settings

ModeCachesUse When
min (default)Final image layers onlySingle-stage, small image
maxAll layers, including builder stagesMulti-stage, compiled deps

Cache Parameters

ParameterEffect
scopeIsolates cache by name; prevents overwrite
ignore-error=truePrevents build failure if cache export fails
refRegistry cache tag or manifest reference

Event Cache Access

EventReadWrite
pushYesYes
pull_request (fork)YesNo
issue_commentYesNo
pull_request_targetYesNo

Dockerfile Ordering

OrderInstructionCache Stability
1COPY go.mod go.sum ./High
2RUN go mod downloadHigh
3COPY . .Low (changes often)
4RUN go buildLow (but fast with cached deps)

Best Practices

✅ Do This:

# Use mode=max for multi-stage builds.                        // ✅
cache-to: type=gha,mode=max

# Set a scope for each image.                                 // ✅
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api

# Disable cache export on pull requests.                      // ✅
cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max' || '' }}

# Use type=registry when cache exceeds 10 GB.                 // ✅
cache-from: type=registry,ref=ghcr.io/repo/cache

❌ Don’t Do This:

# Use default mode (min) for multi-stage builds.              // ❌
cache-to: type=gha # Builder stage not cached.

# Export cache on fork PRs.                                   // ❌
cache-to: type=gha # Fails with "failed to reserve cache".

# Share default scope across multiple images.                 // ❌
# Only the last build's cache survives.

# Use type=gha with a 6 GB image and mode=max.                // ❌
# Eviction causes cold builds.

Common Pitfalls

PitfallWhy It HappensFix
Cache always coldmode=min default, builder not cachedmode=max
“failed to reserve cache”Pull request read-only accessConditional cache-to
Cache evicted after few builds10 GB repository limittype=registry
Multiple images overwrite cacheNo scope setPer-image scope
Cache hit but build still slowDockerfile copies source before depsReorder COPY instructions

Real-World Examples

1. Basic GHA Cache

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

2. Scoped Cache for Multiple Images

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

3. Registry Cache for Large Image

cache-from: type=registry,ref=ghcr.io/repo/buildcache
cache-to: type=registry,ref=ghcr.io/repo/buildcache,mode=max

4. Conditional Export for PRs

cache-to: ${{ github.event_name == 'push' && 'type=gha,mode=max' || '' }}

5. Ignore Cache Export Errors

cache-to: type=gha,mode=max,ignore-error=true

6. Per-Platform Scope

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

7. Dockerfile Dependency Ordering

COPY go.mod go.sum ./
RUN go mod download
COPY . .

8. Cache Mount for Go

RUN --mount=type=cache,target=/go/pkg/mod go build -o /app .

9. Buildx Setup

- uses: docker/setup-buildx-action@v4

10. Verify Cache in Build Summary

# build-push-action v7 generates a job summary
# showing cache utilization per layer.

Visual

Cache Export/Import Cycle

┌──────────────────────────────────────────────┐
│  RUN 1                    RUN 2              │
│                                              │
│  BuildKit builds          BuildKit imports   │
│       │                   cache-from gha     │
│       ▼                        │             │
│  cache-to gha,mode=max         ▼             │
│       │                   Layers reused      │
│       ▼                        │             │
│  GitHub Cache Service     Only changed       │
│  (10 GB limit)            layers built       │
│       │                        │             │
│       └────────────────────────┘             │
│                                              │
│  Without cache-to: RUN 2 rebuilds everything.│
│                                              │
└──────────────────────────────────────────────┘

Mode=Max vs Mode=Min

┌──────────────────────────────────────────────┐
│  MULTI-STAGE DOCKERFILE                      │
│                                              │
│  Stage 1: builder                            │
│    FROM node:18                              │
│    RUN npm ci          ← mode=max caches    │
│    RUN npm build       ← mode=max caches    │
│                                              │
│  Stage 2: runtime                            │
│    FROM node:18-slim                         │
│    COPY --from=builder /app/dist             │
│                                              │
│  mode=min: caches only Stage 2 layers.       │
│  Builder stage not in final image.            │
│  Next build re-runs npm ci from scratch.     │
│                                              │
│  mode=max: caches both stages.               │
│  Next build skips npm ci entirely.           │
│                                              │
└──────────────────────────────────────────────┘

10 GB Limit and Registry Escape

┌──────────────────────────────────────────────┐
│  CACHE STORAGE LIMITS                        │
│                                              │
│  type=gha:                                   │
│    Storage: GitHub Actions cache             │
│    Limit: 10 GB per repository               │
│    Behavior when exceeded: eviction          │
│    Result: cold builds, no warning           │
│                                              │
│  type=registry:                              │
│    Storage: GHCR / Docker Hub                │
│    Limit: no per-repo cap                    │
│    Behavior: storage costs apply             │
│    Result: consistent cache hits             │
│                                              │
│  Rule: image cache > 2 GB → use registry.    │
│                                              │
└──────────────────────────────────────────────┘

PR Cache Access Restriction

┌──────────────────────────────────────────────┐
│  EVENT CACHE PERMISSIONS                     │
│                                              │
│  push (main):                                │
│    cache-from: works                         │
│    cache-to: works                           │
│                                              │
│  pull_request (fork):                        │
│    cache-from: works (reads main cache)      │
│    cache-to: FAILS (read-only)               │
│    Error: "failed to reserve cache"          │
│                                              │
│  Fix: conditional export                     │
│    cache-to: ${{ push && 'type=gha' || '' }} │
│                                              │
│  PR builds read cache but do not write it.   │
│  Main branch builds write cache.             │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
type=ghaGitHub Actions cache backend
mode=maxCaches all layers, including builder stages
mode=minCaches final image layers only (default)
scopeIsolates cache by name
10 GB limitGitHub cache per-repository cap
type=registryRegistry backend, no size cap
PR cache accessRead-only for forks; cache-to fails
Dockerfile orderManifests before source
Buildx requireddocker/setup-buildx-action before build

Key takeaways:

  • type=gha with mode=max is the default for GitHub Actions. It caches builder stages, which mode=min omits, and persists layers across ephemeral runners .
  • scope prevents cache overwrites. Multiple images in one repository share the default buildkit scope unless isolated by name. Per-image and per-platform scopes are necessary for multi-image and multi-architecture builds .
  • Pull request workflows cannot write cache. GitHub grants read-only access to forks and some PR events. cache-to fails with “failed to reserve cache”; conditional export based on github.event_name prevents the error .
  • The 10 GB limit forces type=registry for large images. A mode=max cache for a multi-gigabyte image exceeds the limit, gets evicted, and causes cold builds. Registry cache removes the cap at the cost of storage and bandwidth .
  • Dockerfile ordering determines cache usefulness. Copying manifests before source allows dependency layers to survive code changes. A poorly ordered Dockerfile defeats the best cache backend.
  • The job summary shows cache utilization. build-push-action@v7 generates a summary with cache hit information. “CACHED” in the build log confirms the backend is working .

Remember: GitHub Actions caching is not a single setting; it is a chain of decisions. The backend (gha or registry), the mode (max or min), the scope (per-image or shared), the event trigger (push or pull request), and the Dockerfile layer order all determine whether the cache hits. The most common failure is mode=min with a multi-stage Dockerfile: the builder stage is not cached, and every build re-downloads dependencies. The second most common is the 10 GB limit: a large image evicts its own cache, and the workflow silently reverts to cold builds. Configure mode=max, set scopes, conditionally export on push, and switch to type=registry when the image outgrows GitHub’s cache. The thirty-second build is the result of getting every link in the chain right.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!