Choosing a Buildx Cache Backend on GitHub Actions

Inline cache, registry cache, and a remote builder profile fail in different ways on GitHub Actions. How to pick a buildx cache backend, with rates.

Last verified:

A buildx cache backend decides where Docker layers live between GitHub Actions runs, and three shapes cover almost every workflow: inline cache written into the pushed image, a registry cache written to a separate cache ref, and a remote builder that keeps layers on its own disk. The first two serialize layers and move them across the network on every build, while a builder profile reuses the layers in place because they never left the machine that wrote them.

This guide covers what each backend actually stores, the failure mode each one produces in a job log, the configuration to move from a registry cache to a builder profile, and the arithmetic that tells you which one is cheaper at your build volume.

Diagnosis

BuildKit holds its layer cache in the local state of the builder that ran the build. A runner-local builder is created when the job starts and destroyed when the runner terminates, so that state dies with the machine unless a backend copies it somewhere that outlives the job. Each backend picks a different somewhere, and each one has a signature in the log.

Inline cache

Inline cache embeds cache metadata in the image manifest that you push, so the image doubles as the cache.

cache-from: type=registry,ref=ghcr.io/acme/api:latest
cache-to: type=inline

Two failure modes. First, inline records metadata only for the layers that end up in the final image, so the intermediate stages of a multi-stage build are invisible to the next run. A Dockerfile that compiles in a builder stage and copies one binary into a slim runtime gets cache hits on the runtime layers while the compile stage runs from scratch every time. Second, cache reads point at a floating tag such as :latest, which is whatever the default branch pushed most recently, so a pull request that touched an early layer misses everything downstream of it.

The log signature is a run where the last few steps say CACHED and the dependency install or compile step runs full length.

Registry cache

Registry cache writes a separate cache manifest to a registry ref, and mode=max records every stage rather than the final image.

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

This fixes the multi-stage gap and introduces four operational problems.

  1. The export runs at the end of the build. A build that fails or is cancelled writes nothing, so the next run is still cold. A flaky test step placed before the push can keep a cache permanently empty.
  2. The import is a download that has to finish before the first cache hit can be evaluated, and mode=max makes it larger. Both the import and the export are runner minutes you pay for on every build.
  3. Concurrent builds that write to the same ref race each other. A matrix of five variants exporting to one ref ends with a cache describing whichever build finished last.
  4. Nothing expires by default. The cache ref accumulates blobs until somebody garbage collects them, and the registry bills that storage plus the pull bandwidth on every import.

The GitHub Actions cache backend, type=gha, is the same shape with a different store. Scopes are keyed by branch, so a pull request reads its own empty scope before falling back, and the store has its own size and eviction limits.

Remote builder with a persistent local layer cache

A builder profile corresponds to one dedicated builder VM with caching attached to it. The build runs on that VM, and the layers stay on its local disk between jobs, so there is no export step and no import step anywhere in the workflow.

The costs move rather than disappear. The builder is billed per session, separately from the runner. The shared profile cache is eventually consistent across concurrent builds. A profile that goes unused for more than 10 days is reset. And if a later step needs the built image on the runner filesystem, that image has to travel back over the network before the step can start.

BackendWhere layers livePer-build overheadMain failure mode
Inline cacheInside the pushed image manifestSlightly larger pushFinal-stage layers only, multi-stage builds miss
Registry cache, mode=maxA separate cache ref in a registryImport before the build, export after itCancelled builds write nothing, concurrent writers race
GitHub Actions cache backendThe workflow cache storeImport before the build, export after itBranch-scoped reads, store size and eviction limits
Remote builder profileLocal disk of a dedicated builder VMA builder session billed per minuteEventually consistent across concurrent builds, 10-day reset

Fix

Pick the backend from the shape of your job log rather than from the Dockerfile.

Keep inline cache when the image is a single stage, the push is short, and the build step is already a small share of wall clock. It needs no extra registry repository and no cleanup job.

Keep a registry cache when you want the build to stay on the runner and the Dockerfile has a heavy build stage. Use mode=max, a dedicated cache ref that is separate from your image tags, and a per-branch ref with a fallback to the default branch ref so pull requests read a warm cache.

Move to a builder profile when any of three things is true: the import and export lines are a top-three cost in the job, several jobs build the same image and each one pays its own import, or you build for linux/amd64 and linux/arm64 and the emulated leg dominates.

The move is a drop-in replacement for the action. Warpbuilds/build-push-action@v6 sets up the remote builder for you, and the cache flags come out.

-      - name: Set up Buildx
-        uses: docker/setup-buildx-action@v3
-
-      - name: Build and push
-        uses: docker/build-push-action@v6
+      - name: Build and push
+        uses: Warpbuilds/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
+          profile-name: "api-images"

With Docker Builders the cache-to and cache-from options are not required, because a cached builder keeps the layers on its own disk and reuses them for later builds. Warpbuilds/bake-action@v6 is the equivalent replacement for bake files and takes the same profile-name input. The remote Docker builders setup walkthrough covers profile creation and authentication end to end, and the WarpBuild Docker Builders documentation is the reference for every action input.

Configuration

Registry cache, tuned

If you stay runner-local, this is the version worth running: a per-branch cache ref with a fallback to the default branch ref, so pull requests start warm.

name: build-image

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

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

      - name: Log in to the registry
        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 }}:${{ github.sha }}
          cache-from: |
            type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }}
            type=registry,ref=ghcr.io/${{ github.repository }}:cache-main
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache-${{ github.ref_name }},mode=max

Builder profile

The same workflow on a builder profile drops the buildx setup step and both cache flags. The runner label shrinks too, because the build no longer needs the runner's cores.

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

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

      - name: Build and push
        uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          profile-name: "api-images"

For a bake file, swap the action and keep the targets.

jobs:
  build:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4

      - name: Bake
        uses: Warpbuilds/bake-action@v6
        with:
          push: true
          set: |
            *.tags=ghcr.io/${{ github.repository }}:${{ github.sha }}
          profile-name: "api-images"

On a runner that WarpBuild does not operate, add an API key so the action can request a builder assignment, plus an explicit readiness timeout in milliseconds.

        with:
          profile-name: "api-images"
          api-key: ${{ secrets.WARPBUILD_API_KEY }}
          timeout: 600000

The operational limits that decide the choice

Four limits do most of the deciding, and all four are properties of the profile rather than of the workflow.

The cache has a 10 day TTL. A builder profile that goes unused for more than 10 days is reset automatically. A weekly release pipeline stays warm. A monthly one rebuilds cold every time, which is an argument for a registry cache on that workflow.

Concurrent builds share an eventually consistent cache. Multiple jobs can run on the same profile in parallel on the same VM. Layers written by one of those builds may not be visible to another build running at the same time, and become visible to later builds after synchronization. A matrix that expects build N to reuse a layer produced by build N-1 in the same wave should not rely on it.

The builder is billed per session, separately from the runner. A session runs from the moment the builder action starts until the job completes, and concurrent jobs on the same profile share one session billed from the first job's start to the last job's completion. The runner and the builder are two separate, independent resources and both are charged. Generally available Linux and Windows runners do not have plan-level concurrency caps; size the builder profile for the fan-out it will serve.

Architecture caps the profile size. arm64 and multi-arch profiles support a maximum size of 64 vCPU, and the 96 vCPU and 192 vCPU sizes are amd64 only. A multi-arch build runs one session per architecture on the same profile, and those sessions are billed independently.

When a bad layer set gets pinned on a profile, reset it through the API instead of deleting and recreating the profile.

curl -s -X POST \
  -H "Authorization: Bearer $WARPBUILD_API_KEY" \
  https://api.warpbuild.com/api/v1/builder-profiles/$BUILDER_PROFILE_ID/cache/reset

Three platform notes. Docker builds are driven from the Linux labels in that catalog. The Docker builder profile sizes and per-minute rates page lists every profile size, and sharing a Docker layer cache between GitHub Actions jobs covers the fan-out case where one profile serves many jobs. The Docker layer cache described in this guide is separate from WarpBuild's own dependency and build cache, which the caching documentation covers on its own terms.

Cost or Time Model

Rates

Builder profile rates, taken from the WarpBuild Docker Builder price list.

Profile sizePrice per minuteArchitectures
16 vCPU, 32GB RAM, 100GB disk$0.06amd64, arm64, multi
32 vCPU, 64GB RAM, 200GB disk$0.12amd64, arm64, multi
64 vCPU, 128GB RAM, 200GB disk$0.24amd64, arm64, multi
96 vCPU, 192GB RAM, 600GB disk$0.36amd64 only
96 vCPU, 192GB RAM, 2TB disk$0.52amd64 only
192 vCPU, 384GB RAM, 600GB disk$0.72amd64 only
192 vCPU, 384GB RAM, 2TB disk$0.88amd64 only

Runner rates used below: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) at $0.008 per minute, warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) at $0.016 per minute, and warp-ubuntu-latest-x64-32x (32 vCPU, 128 GB) at $0.064 per minute.

Model 1: one image, 500 builds per month

Assumptions, which you should replace with the timings in your own job log. The image is a multi-stage Node service. Option A imports a 1:10 cache and exports it in 1:40. Option B has no import and rebuilds the compile stage. Option C runs the build on a 16 vCPU profile with warm layers on disk.

OptionRunnerJob lengthBuilder sessionCost per build500 builds
A. Registry cache, mode=maxwarp-ubuntu-latest-x64-8x at $0.0169:00none$0.144$72.00
B. Inline cachewarp-ubuntu-latest-x64-8x at $0.01610:00none$0.160$80.00
C. Builder profile, 16 vCPUwarp-ubuntu-latest-x64-4x at $0.0085:303:00 at $0.06$0.224$112.00

Read the last column before the middle ones. At this shape the builder profile costs $40.00 more per month than the registry cache and returns 3:30 of wall clock on every build, which is 1,750 minutes or about 29 hours of pipeline time across 500 builds. The registry cache option also carries a registry storage and bandwidth line on your registry bill that the table does not include.

Model 2: a build-bound job on a large runner

The picture inverts when the runner was sized for docker build rather than for the rest of the job. Take a 20 minute job on warp-ubuntu-latest-x64-32x, of which 8 minutes is the image build and the other 12 minutes leave 32 vCPU mostly idle.

Line itemRateQuantityCost
Before: runner warp-ubuntu-latest-x64-32x$0.064 per minute20 minutes$1.280
After: runner warp-ubuntu-latest-x64-4x$0.008 per minute14 minutes$0.112
After: builder session, 32 vCPU profile$0.12 per minute5 minutes$0.600
After: total per build$0.712

At 500 builds per month that is $356.00 against $640.00, a difference of $284.00, and the job also finishes sooner. The rule that falls out of both models: a builder profile pays for itself in dollars when it lets you shrink the runner label, and it pays for itself in time when the import and export lines are large.

None of this needs a plan change.

FAQ

Which buildx cache backend should I use on GitHub Actions?

Use inline cache for a single-stage image where the push is cheap. Use a registry cache with mode=max when you stay on runner-local buildx and have a heavy build stage. Move to a remote builder profile when the cache import and export lines are a top-three cost in the job log, when many jobs build the same image, or when you build multi-arch.

Do I still need cache-from and cache-to with a remote builder?

No. With WarpBuild Docker Builders the cache-to and cache-from options are not required. The builder keeps the layers on its own disk and reuses them for later builds, so there is no export step and no import step in the job.

What happens to a builder profile cache that goes unused?

The builder cache has a TTL of 10 days. A profile that is not used for more than 10 days is reset automatically, and the next build on that profile runs cold.

Do concurrent builds on one profile share the layer cache?

Yes, and the shared cache is eventually consistent. Layers written by one concurrent build may not be visible to another build running at the same time, and they become visible to later builds after synchronization. Why Docker layer caches invalidate on GitHub Actions covers the Dockerfile-side reasons a warm cache still misses.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.