pastebin.richardson.dev

Docker Caching in GitHub Actions

Posted Mar 7, 20264.6 KB • Markdown Print Raw

Traditional Docker caching with actions/cache + docker save/docker load works, but it is usually slower than modern Buildx-native caching:

  • It creates large tarballs.
  • Upload/download time grows quickly.
  • You can burn through GitHub’s 10 GB cache quota.

For most projects, use Docker Buildx cache exporters/importers directly.


1) GitHub Actions Cache Backend (type=gha)

type=gha is usually a good default.

Why it is better than tarball caching

  • Layer-aware: only changed layers are uploaded.
  • Faster: no docker save/docker load plumbing.
  • Simple: fewer workflow steps to maintain.
  • Managed by GitHub: old entries are evicted automatically.

Basic workflow example

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

- name: Build and push
  uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

Without a scope, different branches may fight over the same cache.

cache-from: type=gha,scope=${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}

A common pattern is:

  • per-branch scope for feature branches
  • shared scope (for example main) for your default branch to maximize reuse

2) Registry Cache Backend (type=registry)

Use this when:

  • images are very large,
  • you need cache persistence beyond GitHub cache eviction,
  • or you want to bypass repository-level cache limits.

Why teams choose registry cache

  • No GitHub cache quota pressure (cache lives in your registry).
  • Portable across runners and systems as long as they can access the registry.
  • Durable until you delete or expire the cache tag.

GHCR example

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

- name: Build and push
  uses: docker/build-push-action@v6
  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

Keep cache in a dedicated tag (like :buildcache) so release tags stay clean.


3) mode=min vs mode=max

  • mode=min caches only layers used by the final exported image.
  • mode=max caches intermediate layers too (great for multi-stage Dockerfiles).

If your Dockerfile has build stages for dependencies, compilation, or tests, prefer mode=max.


4) Practical Patterns

A) PR builds + mainline builds

  • PRs: use type=gha with branch scope.
  • Main: use type=gha with stable scope or type=registry for long-term reuse.

B) Multi-arch builds

With linux/amd64,linux/arm64, registry cache often gives better long-term behavior for larger artifacts.

C) Keep Dockerfiles cache-friendly

  • Copy lockfiles first, install dependencies, then copy app source.
  • Use .dockerignore aggressively.
  • Avoid invalidating early layers with frequently changing files.

5) Quick Comparison

Featureactions/cache + tarballtype=ghatype=registry
SpeedSlowestFastFast
GranularityWhole tarballLayer-basedLayer-based
Storage locationGH Actions cacheGH Actions cacheContainer registry
10GB repo cache limitYesYesNo (registry policy applies)
Setup complexityHighestLowLow–Medium (auth required)

6) Troubleshooting Checklist

  1. Cache misses every run
    • Check you are using the same scope/ref as intended.
    • Verify Dockerfile step ordering is stable.
  2. Cache exports but no speedup
    • Ensure expensive layers happen before frequently changing COPY steps.
    • Switch to mode=max.
  3. Registry cache grows too much
    • Add retention/cleanup policy for :buildcache tags.
  4. Permission errors with GHCR
    • Confirm workflow token/package permissions allow write access.

Suggested Default

For most repos:

  • Start with type=gha + mode=max.
  • Add branch scoping.
  • Move to type=registry when scale, retention, or cache size requires it.

This keeps workflows simple while improving build times in most repos.