Docker 85 🐳 CI/CD Pipeline Caching: GitHub Actions Cache Layer Optimization for Docker
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
| Backend | Storage | Size Limit | Cost | Auth |
|---|---|---|---|---|
type=gha | GitHub Actions cache | 10 GB/repo | Free | Automatic |
type=registry | Container registry | No cap | Storage costs | Registry login |
Mode Settings
| Mode | Caches | Use When |
|---|---|---|
min (default) | Final image layers only | Single-stage, small image |
max | All layers, including builder stages | Multi-stage, compiled deps |
Cache Parameters
| Parameter | Effect |
|---|---|
scope | Isolates cache by name; prevents overwrite |
ignore-error=true | Prevents build failure if cache export fails |
ref | Registry cache tag or manifest reference |
Event Cache Access
| Event | Read | Write |
|---|---|---|
push | Yes | Yes |
pull_request (fork) | Yes | No |
issue_comment | Yes | No |
pull_request_target | Yes | No |
Dockerfile Ordering
| Order | Instruction | Cache Stability |
|---|---|---|
| 1 | COPY go.mod go.sum ./ | High |
| 2 | RUN go mod download | High |
| 3 | COPY . . | Low (changes often) |
| 4 | RUN go build | Low (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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Cache always cold | mode=min default, builder not cached | mode=max |
| “failed to reserve cache” | Pull request read-only access | Conditional cache-to |
| Cache evicted after few builds | 10 GB repository limit | type=registry |
| Multiple images overwrite cache | No scope set | Per-image scope |
| Cache hit but build still slow | Dockerfile copies source before deps | Reorder 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
| Item | Value |
|---|---|
type=gha | GitHub Actions cache backend |
mode=max | Caches all layers, including builder stages |
mode=min | Caches final image layers only (default) |
scope | Isolates cache by name |
| 10 GB limit | GitHub cache per-repository cap |
type=registry | Registry backend, no size cap |
| PR cache access | Read-only for forks; cache-to fails |
| Dockerfile order | Manifests before source |
| Buildx required | docker/setup-buildx-action before build |
Key takeaways:
type=ghawithmode=maxis the default for GitHub Actions. It caches builder stages, whichmode=minomits, and persists layers across ephemeral runners .scopeprevents cache overwrites. Multiple images in one repository share the defaultbuildkitscope 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-tofails with “failed to reserve cache”; conditional export based ongithub.event_nameprevents the error . - The 10 GB limit forces
type=registryfor large images. Amode=maxcache 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@v7generates 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.
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!