Why Docker layer cache disappears between GitHub Actions jobs

On your laptop, rebuilding an image after a one-line code change is fast because BuildKit reuses every layer whose inputs haven't changed. In GitHub Actions that reuse usually vanishes. GitHub-hosted runners are ephemeral: each job gets a fresh virtual machine, and when the job ends the machine is discarded along with the Docker engine's storage. The next run starts with an empty layer cache. It reinstalls the same packages, downloads the same base image, and recompiles the same dependencies, even when only application code changed.

BuildKit solves this with exportable cache. Instead of keeping layers only in the builder's local store, docker buildx build can write cache metadata and layer blobs to an external location with --cache-to and read them back on a later run with --cache-from. That location might be a container registry, GitHub's own cache service, or a directory you persist yourself. The cache lives outside the runner, so a new VM can pull it in and skip any step whose inputs still match.

The payoff is shorter feedback loops. A pull request that touches one source file shouldn't wait for apt-get or npm install to run again from scratch. Backend and platform engineers feel this most when they run many image builds per day across branches, monorepo services, or multi-architecture targets. In those setups, repeated dependency installation is a large share of total CI time, and it is paid on every push.

Run N (ephemeral VM)                    Run N+1 (new ephemeral VM)
+-------------------------------+       +-------------------------------+
| docker buildx build           |       | docker buildx build           |
|  +-------------------------+  |       |  +-------------------------+  |
|  | BuildKit local cache    |  |       |  | BuildKit local cache    |  |
|  | (base, apt, npm layers) |  |       |  | (starts empty)          |  |
|  +-------------------------+  |       |  +-------------------------+  |
+---------------+---------------+       +---------------+---------------+
                | --cache-to               --cache-from ^
                |                                       |
                v                                       |
        +-------------------------------------------------------+
        | External cache store (outlives the runner)            |
        | registry | GitHub Actions cache | your own directory  |
        | (type=registry)  (type=gha)  (type=local)             |
        +-------------------------------------------------------+

  Job ends -> VM and its local layer cache are discarded.
  Only the exported cache survives to seed the next run.

Each GitHub-hosted job starts on a fresh VM with an empty BuildKit cache, so layers are exported with --cache-to to a store outside the runner and imported by the next run with --cache-from.

Who should use exported BuildKit cache

Exported cache is worth setting up if most of the following describe your pipeline:

  • You build images on GitHub-hosted runners. These runners are the ones that throw away the local layer cache after every job. Without an exported cache, every build is cold.
  • Your Dockerfiles have expensive, slow-changing steps. Typical examples are system package installs, npm ci, pip install, go mod download, or compiling native dependencies in a builder stage. The more time sits in layers that rarely change, the more a cache hit saves.
  • You build often. Teams that build an image on every push, across many branches, or for several services in a monorepo pay the cold-build cost many times a day.
  • You build multi-stage or multi-platform images. These have intermediate stages and per-architecture layers that are costly to rebuild and benefit from mode=max and per-platform scoping.
  • Developers wait on the build. If an image build gates a pull request check or a preview deployment, cutting minutes from it shortens the feedback loop directly.

It is usually not worth the effort if your images are small and build quickly from scratch, if your base layers legitimately change on every run, or if you run on self-hosted runners whose builder already keeps its cache between jobs. The trade-offs and decision guide sections below cover those cases in more detail.

How it works: cache keys, export and import

BuildKit treats a Dockerfile as a graph of build steps. For each step it computes a cache key from the parent step's key, the instruction itself, and, for COPY and ADD, a checksum of the files being copied. If a step with the same key already exists in the cache, BuildKit reuses its result instead of running it.

Exported cache adds two phases around a normal build:

  1. Import (--cache-from). At the start of the build, BuildKit fetches cache metadata from the configured backend. That metadata records the cache keys and the layer blobs they map to. BuildKit does not download every blob up front; it pulls the layers it needs when a step's key matches.
  2. Build. Each step's key is compared against the local cache and the imported metadata. Matching steps are marked CACHED and skipped. The first step that misses runs normally, and every step after it also misses, because its key depends on its parent's.
  3. Export (--cache-to). After the build, BuildKit writes the cache metadata and the layer blobs to the backend. Depending on the mode, it exports either only the final image's layers or the intermediate stages too. Blobs the backend already has don't need to be uploaded again.

Several --cache-from sources can be listed, and BuildKit can use any of them for a match. That is what makes branch-then-main fallback work. Import failures are not fatal: if a cache source is missing or unreadable, BuildKit logs a warning and builds without it.

How the BuildKit cache backends work: inline, registry, gha and local

BuildKit solves this by exporting its cache to somewhere that outlives the runner and importing it at the start of the next build. Four backends do this, and they differ mainly in where the cache is stored and how much of the build graph they can keep.

Inline writes cache metadata into the image config of the image you push. There is nothing extra to store, but the cache is only as complete as that image. Registry pushes a separate cache manifest to its own ref, such as a dedicated tag or repository, independent of the runtime image. gha uploads cache blobs to the GitHub Actions cache service through the runner's runtime token, so no registry is involved. local writes a directory on the runner, which you then persist with a generic cache step.

The export mode determines how useful the cache is. mode=min exports only the layers that appear in the final image. mode=max also exports layers from intermediate stages. In a multi-stage build, the expensive compiler and dependency stages never reach the final image, so min throws away the layers you most want reused. Inline effectively supports only min, which is why it often gives poor hit rates for multi-stage Dockerfiles.

Backend Where cache lives Modes Credentials Size limits Eviction Sharing scope
inline Inside the pushed image min only Registry push/pull Image size Follows image retention Anyone who can pull the image
registry Separate cache ref min, max Registry push/pull Registry storage Registry retention policy Any registry client
gha Actions cache service min, max Runner token (automatic) Per-repository quota Unused and least recently used entries Repo, branch-scoped
local Runner directory + cache step min, max None Per-repository quota Same as Actions cache Repo, branch-scoped

Implementing each backend in a workflow

gha backend

Pass --cache-to type=gha,mode=max,scope=<name> and --cache-from type=gha,scope=<name>. The scope is the key that matters. Without one, every build in the repository writes to the same default scope. A matrix that builds api and worker, or linux/amd64 and linux/arm64, will then overwrite each other's cache on every run. Build the scope from the things that change the layer graph: Dockerfile or image name, platform, and branch, such as api-arm64-feature-x. The gha backend also needs the Actions runtime token and cache URL in the build environment. The official Docker setup action exposes these, but a bare docker buildx call in a run step does not.

registry backend

Write the cache to a dedicated tag instead of the image tag: --cache-to type=registry,ref=<registry>/app:buildcache-main,mode=max. Keeping it separate means mode=max can export intermediate stages, including builder stages, without bloating the image people pull. It also means you can delete or expire cache tags without touching releases.

local backend

The local exporter adds blobs to the directory and never removes old ones. To stop it growing without bound, export to a new directory with --cache-to type=local,dest=/tmp/cache-new,mode=max and read from the old one with --cache-from type=local,src=/tmp/cache. After the build, delete /tmp/cache and move cache-new into its place before the cache step saves it.

Fallback order

Repeat --cache-from with the branch scope or ref first, then the default branch's. A new branch's first build then reuses main's layers. Option names and defaults have changed across buildx releases, so check them against the documentation for the version you use.

#!/usr/bin/env bash
set -euo pipefail
IMAGE=api                         # separate scope per Dockerfile/image
PLATFORM=linux/arm64              # separate scope per platform
ARCH=${PLATFORM#*/}
BRANCH=$(echo "${GITHUB_REF_NAME:-main}" | tr '/' '-')   # feature/x -> feature-x
REG=registry.example.com/acme
SCOPE="${IMAGE}-${ARCH}"
BACKEND=${1:-gha}

case "$BACKEND" in
  gha)
    # Needs ACTIONS_RUNTIME_TOKEN and the cache URL in the env; a bare run step lacks them.
    # Read the branch cache first, then fall back to main's; write only the branch scope.
    CACHE=(--cache-from "type=gha,scope=${SCOPE}-${BRANCH}"
           --cache-from "type=gha,scope=${SCOPE}-main"
           --cache-to "type=gha,mode=max,scope=${SCOPE}-${BRANCH}") ;;
  registry)
    # Dedicated cache tag, never the image tag, so mode=max does not bloat pulled images.
    CACHE=(--cache-from "type=registry,ref=${REG}/${IMAGE}:buildcache-${ARCH}-${BRANCH}"
           --cache-from "type=registry,ref=${REG}/${IMAGE}:buildcache-${ARCH}-main"
           --cache-to "type=registry,ref=${REG}/${IMAGE}:buildcache-${ARCH}-${BRANCH},mode=max") ;;
  local)
    # Read the old dir, export to a fresh one so stale blobs are not carried forward.
    CACHE=(--cache-from "type=local,src=/tmp/cache"
           --cache-to "type=local,dest=/tmp/cache-new,mode=max") ;;
esac

docker buildx build --platform "$PLATFORM" -f "docker/${IMAGE}.Dockerfile" \
  -t "${REG}/${IMAGE}:${GITHUB_SHA:-dev}" "${CACHE[@]}" .

if [ "$BACKEND" = local ]; then
  # Swap before the cache step saves /tmp/cache, otherwise the directory grows without bound.
  rm -rf /tmp/cache
  mv /tmp/cache-new /tmp/cache
fi

A single build script that configures the gha, registry and local cache backends. It scopes each cache by image, platform and branch, reads the branch cache before falling back to main's, and uses the move-and-replace step to prune the local cache directory.

Dockerfile structure still decides your hit rate

A cache backend only stores layers; it cannot make them reusable. BuildKit computes each step's cache key from its parent's key plus the instruction itself, and for COPY and ADD it also includes a checksum of the copied files. Once one step misses, every step after it misses too. That makes instruction order the main lever on hit rate.

Order instructions from least to most frequently changing. Base image and system packages come first, then language dependencies, then application source. Copy only the dependency manifests and lockfiles (package.json and its lockfile, go.mod and go.sum, requirements.txt) and run the install step before copying the rest of the source. A typical commit then changes only the final layers, and the expensive dependency install is reused.

Three habits defeat this ordering:

  • An early COPY . . puts every file in the context into the checksum. Editing a README invalidates the dependency install. A tight .dockerignore helps, but it does not replace ordering.
  • Build args that change on every run, such as a commit SHA or a timestamp, invalidate the steps that consume them. Declare and use them as late as possible.
  • Non-deterministic inputs, such as generated files that embed build times, change checksums even when nothing meaningful changed.

For package manager downloads, use RUN --mount=type=cache,target=<cache dir>. This lets a partial rebuild reuse previously downloaded packages. However, none of the backends above export cache mounts. On ephemeral GitHub-hosted runners, the mount starts empty in every job, so it only helps when a builder persists between builds.

FROM node:20-bookworm-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates \
 && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY package.json package-lock.json ./

RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

COPY . .

ARG GIT_SHA=unknown
LABEL org.opencontainers.image.revision=$GIT_SHA

USER node
CMD ["node", "server.js"]

A Node.js Dockerfile ordered from least to most frequently changing: manifests are copied before source, the npm cache uses a cache mount, and the per-commit build arg is declared last so it invalidates only the final layer.

Trade-offs and when to avoid each backend

gha: shared quota, unpredictable eviction

The gha backend writes into the same per-repository cache storage that actions/cache and setup actions use. When the repository exceeds its limit, older entries are evicted. A few mode=max exports from different branches or Dockerfiles can push each other out, along with unrelated dependency caches. Hit rates then depend on what else ran recently. If your cache is large or many branches build in parallel, use per-branch or per-image scope values and keep an eye on total usage.

registry: credentials, cost and latency

Registry cache needs push credentials on every job that writes. It also consumes registry storage, and each build pays egress and extra round trips to fetch cache manifests and blobs. For small images, that overhead can make a registry-backed build slower than gha or even an uncached build of cheap layers.

mode=max: exporting more than you save

mode=max exports intermediate stages as well as the final ones. If those stages are quick to rebuild, uploading them costs more time than restoring them saves. The upload also runs inside the build that produces it. Measure before you adopt it everywhere.

Forks and cache poisoning

Pull requests from forks usually get no write access. Configure them to read the cache and skip cache-to, so missing credentials don't fail the job. More broadly, only protected branches should write cache. Otherwise any branch can seed layers that main will later trust.

When to skip remote backends

On self-hosted runners with persistent disks, a long-lived builder keeps its local cache and cache mounts between jobs with no export or import step. In that setup, it often outperforms every remote option.

Verifying the cache works and estimating cost and time savings

Confirm hits before trusting timings. Run a build with --progress=plain and look for steps marked CACHED. A warm run with no source changes should show nearly every step cached. If it doesn't, the cache was never imported. Check the log for import warnings, because BuildKit keeps building without the cache rather than failing the job. A wrong scope, a missing registry permission or an expired entry can make every build cold while the job still reports success.

Next, time a cold run (empty cache) and a warm run with a typical one-file code change on your own pipeline. Repeat each a few times, because runner and network speed vary.

Illustrative estimate (assumed numbers, not a benchmark)

Assumptions: cold build 8 min, transfer rate 50 MB/s, min-mode cache 400 MB, max-mode cache 1.5 GB. Min mode leaves the dependency stage uncached, so 3 min of steps rerun. Max mode reruns 1.5 min of steps.

Warm time = (cache size ÷ rate) × 2 (import + export) + uncached step time

Mode Transfer overhead Uncached steps Warm total Saved per build
None 0 8 min 8.0 min —
min ~16 s 3 min ~3.3 min ~4.7 min
max ~60 s 1.5 min ~2.5 min ~5.5 min

At an assumed 40 builds per day, max mode saves about 220 runner minutes daily:

Daily cost saved = minutes saved × per-minute rate − (stored GB × storage price) − (transferred GB × egress price)

If 20 of those builds block a developer, that is about 110 minutes of engineer wait time recovered per day. That often matters more than the runner bill.

#!/usr/bin/env bash
set -euo pipefail

CACHE_REF="registry.example.com/myteam/myapp:buildcache"   # placeholder cache image
IMAGE="registry.example.com/myteam/myapp:ci"
LOG=build.log

start=$(date +%s)
docker buildx build \
  --progress=plain \
  --cache-from "type=registry,ref=${CACHE_REF}" \
  --cache-to "type=registry,ref=${CACHE_REF},mode=max" \
  -t "$IMAGE" --load . 2>&1 | tee "$LOG"
end=$(date +%s)

steps=$(grep -cE '^#[0-9]+ \[' "$LOG" || true)
cached=$(grep -cE '^#[0-9]+ CACHED' "$LOG" || true)
echo "wall time: $((end - start))s, cached steps: ${cached} of ${steps}"

if grep -iE 'cache' "$LOG" | grep -iqE 'warn|error|fail|not found|denied'; then
  echo "::warning::cache import/export problem, review $LOG"
fi

if [ "${cached}" -eq 0 ]; then
  echo "::warning::no CACHED steps; the cache was probably never imported"
fi

A bash wrapper that builds with --progress=plain, times the run, counts CACHED steps, and flags cache import problems that would otherwise leave the job green while every build runs cold.

Alternatives to exporting BuildKit cache

Exported cache is not the only way to avoid cold image builds. Depending on your constraints, one of these may be simpler or faster:

  • Self-hosted runners with a persistent builder. If the runner's disk survives between jobs, the BuildKit builder keeps its local layer cache and its --mount=type=cache contents. There is no import or export step at all. The cost is that you now operate and secure the runner hosts, and concurrent jobs on different hosts don't share a cache unless you also export one.
  • A long-lived remote BuildKit daemon. Instead of building on the runner, buildx can send builds to a BuildKit instance you run elsewhere. Its cache persists like a self-hosted builder's, and many runners can share it. You take on running, sizing and pruning that daemon, and you need to secure the connection to it.
  • Pre-built base images. Move the slow, rarely changing steps, such as system packages, toolchains and sometimes dependencies, into a base image that a separate workflow rebuilds on a schedule or when its inputs change. Application builds then start FROM that image and pull it like any other base. This trades cache configuration for an extra image to version and keep patched.
  • Saving images with docker save and the Actions cache. You can docker save a built image to a tarball, store it with actions/cache, and docker load it in the next job. It works without buildx cache exporters, but the tarball holds whole images rather than the BuildKit cache graph, it doesn't restore intermediate stages, and loading large tarballs can take longer than the build it replaces.
  • Not caching image builds at all. If the image builds quickly from scratch, or caching dependency downloads at the language level (outside Docker) already covers the slow part, an uncached build may be the cheapest option to maintain.

Decision guide: choosing a backend and mode

Once you know what a cache hit is worth, choose a backend by three properties: how large the cache gets, who needs to read it, and what evicts it.

Situation Backend and mode Scoping strategy
Single repo, small images gha, mode=max One scope per image
Monorepo, many images registry, or gha Distinct scope or ref per image
Multi-platform builds registry, mode=max Separate cache ref per platform
Self-hosted runners Persistent local builder Builder state stays on the host
Forks and pull requests Any, read-only cache-from No cache-to on untrusted refs
Cross-repo sharing registry Writes only from protected branches

The gha backend works well when the total cache fits comfortably inside the repository's cache quota. In a monorepo, many images compete for that quota, so entries evict each other and hit rates drop with no visible error. A registry has no shared quota and lets you set retention yourself.

Multi-platform builds produce different layers for each architecture. If every platform writes to one ref, each export overwrites the others. Giving each platform its own ref prevents this.

Fork pull requests should read the cache and never write to it. This keeps untrusted code out of cache entries that later builds on trusted branches will consume. The same reasoning applies across repositories, which is why only protected branches should write to a shared registry cache.

Sometimes exporting a cache isn't worth it. For example, a build may be fast already, or its base layers may change on every run. In those cases, skip cache-to entirely. The export and upload time may cost more than the hits save.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to github actions docker layer cache, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.