Docker's layer cache is lost on every GitHub Actions run because each job on a GitHub-hosted runner starts on a new VM: no build cache, no pulled images. docker/setup-buildx-action's default builder (docker-container) also starts empty in every job and is removed at its end. The usual fix is to export the cache with buildx. cache-to: type=gha,mode=max and cache-from: type=gha keep the layers in GitHub's Actions cache, 10 GB per repository and shared with every other cache; type=registry keeps them in your registry; type=local in a directory you save with actions/cache. Each turns a rebuild into an upload and a download on every run.
Skipbox keeps the layers where the build runs. Your jobs run on your account's own machine, which keeps Docker's images, layers and build cache between jobs, on a 80 GB disk on S against 14 GB on GitHub's runner. docker build and docker compose build reuse the last build's layers, and images compose pulled are already on disk. With setup-buildx-action, set driver: docker. Change one line, runs-on: skipbox, for a flat $39 a month with unlimited minutes.
- GitHub: a new VM per job, so no layers, no images, and an empty buildx builder every run
- Skipbox: Docker's build cache and images stay on your machine's disk, so unchanged layers aren't rebuilt or downloaded
- 20,000 minutes a month on 4 vCPU: $222 on GitHub, $39 on Skipbox
Why every build starts cold
- A new VM per job. GitHub's runner reference: "each GitHub-hosted runner is a new virtual machine (VM) hosted by GitHub." Nothing the last job built or pulled is on it.
- An empty builder. docker/setup-buildx-action creates and boots a builder with the
docker-containerdriver by default, and itscleanup(on by default) removes the builder at the end of the job. - 14 GB of disk. A private repository's
ubuntu-latesthas 2 CPUs, 8 GB and 14 GB of SSD. A large image, its build cache and a cache archive to unpack can run out of room ("no space left on device").
The buildx cache backends, and what each costs
From Docker's cache backends and cache management with GitHub Actions:
| Backend | Where the layers go | What each run pays | Limits |
|---|---|---|---|
type=gha | GitHub's Actions cache | Uploads new layers, downloads the ones it needs | 10 GB per repository with every other cache, evicted oldest first; the default scope buildkit is overwritten by each build; GitHub's cache API rate-limits |
type=registry | A separate cache image in your registry | Pulls the cache from the registry, pushes the new one | A registry login with push rights; the registry's storage and traffic |
type=inline | Inside the image itself | Pulls the image to read its cache | mode=min only: layers of intermediate stages aren't kept |
type=local + actions/cache | A directory saved with actions/cache | Downloads and unpacks the whole directory, then uploads it | Grows with every run unless reset=true (Buildx 0.35.0 and later); counts in the 10 GB |
Whichever backend you pick, order the Dockerfile so the layers that change least come first: copy the lockfile and install the dependencies before you copy the source. Otherwise every commit invalidates the install layer, and no cache brings it back.
mode=min, the default, keeps only the layers of the final image. mode=max keeps every stage's layers, so a multi-stage build hits more, and uploads more.
A typical type=gha setup, with its own scope per image:
- uses: docker/setup-buildx-action@v4 - uses: docker/build-push-action@v7 with: context: . tags: app:ci load: true cache-from: type=gha,scope=app cache-to: type=gha,scope=app,mode=max Cache mounts don't survive. RUN --mount=type=cache (a package manager's cache inside the build) isn't kept in the GitHub Actions cache by default; Docker's docs point to buildkit-cache-dance to move it in and out of actions/cache, one more download and upload per run.
docker compose pulls every image, every job
Integration tests that start Postgres, Redis and a few services with docker compose up pull every image on every job, because the VM is new. On GitHub-hosted runners the options only move the download:
-
docker savethe images to a tar file, cache it with actions/cache, anddocker loadit in the next job. It counts in the 10 GB, and the load takes time of its own. - Build the service images with buildx and a cache backend above, which still leaves the base images to pull.
When the fix is a machine that keeps Docker's cache
Every backend above exists because the builder forgets. A Docker daemon that stays between jobs already has the last build's layers, its cache mounts and every image it pulled, on its own disk. There is nothing to export, nothing to import, and no 10 GB to share.
Skipbox keeps Docker's cache on your machine
Each plan comes with your account's own machine, kept between jobs:
-
docker buildanddocker compose buildreuse the last build's layers, with no change to the workflow. - Images stay on the disk.
docker compose upfinds the images it pulled last time;docker compose pullfetches only layers that changed. - Cache mounts stay with Docker's build cache, so
RUN --mount=type=cachekeeps the package manager's cache with no extra step. - With docker/setup-buildx-action, add
driver: docker. The build then uses the machine's Docker instead of a new, empty builder, and you can dropcache-fromandcache-to. - Disk: nothing is deleted until the disk is nearly full after a job. Then dangling images go first, then the build cache used longest ago.
jobs: build: runs-on: skipbox # was ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v4 with: driver: docker # use the machine's Docker and its build cache - uses: docker/build-push-action@v7 with: context: . tags: app:ci load: true A job picks its size by label:
| Label | Machine | Disk | Plan |
|---|---|---|---|
skipbox | 4 vCPU · 16 GB | 80 GB | S, $39 a month |
skipbox-8x16 | 8 vCPU · 16 GB | 160 GB | M, $79 a month |
skipbox-16x32 | 16 vCPU · 32 GB | 320 GB | L, $149 a month |
Minutes are unlimited. At 20,000 minutes a month on 4 vCPU, GitHub bills $222; Skipbox S is $39, $183 less.
Moving is one line
- Sign in to Skipbox with GitHub.
- Install the Skipbox GitHub App and pick the repositories.
- Change
runs-onand push.
The first job builds every layer. The second finds them on the machine.
Security
- The machine is your account's alone. No other company's jobs run on it.
- After every job, the runner's credentials, processes and containers are wiped, and so is
~/.docker/config.json, so a registry login doesn't carry over to the next job. Clean every job gives each job a fresh machine instead. - Code from people without write access (such as pull requests from forks) runs on a fresh, throwaway machine, never on yours.
The details are in how jobs are isolated.
Try it on your Docker build
The trial is free for 7 days or 3,000 job-minutes, whichever comes first, and takes no card. Move your Docker build to runs-on: skipbox, run it twice, and put the second run's build step next to GitHub's.
FAQ
Why is the Docker layer cache lost on every GitHub Actions run?
Because each job on a GitHub-hosted runner starts on a new VM, with no build cache and no images from the last run. docker/setup-buildx-action adds to it: by default it creates a docker-container builder that starts empty and is removed at the end of the job. So every layer is rebuilt unless you export the cache with cache-to and import it with cache-from, or run on a machine that keeps Docker's build cache.
How do I cache Docker layers in GitHub Actions?
On GitHub-hosted runners, with buildx's cache exporters: cache-from: type=gha and cache-to: type=gha,mode=max in docker/build-push-action store the layers in GitHub's Actions cache (10 GB per repository, shared with every other cache). type=registry stores them as a separate image in your registry, and type=local in a directory you save with actions/cache. Give each image its own scope with type=gha, since the default scope is overwritten by each build. On a machine that keeps its disk, docker build reuses the layers with no exporter.
Why is buildx with cache-to type=gha still slow?
Because the layers still travel. With mode=max, each run uploads every new layer of every stage, and a later run downloads the layers it needs. The cache shares GitHub's 10 GB per repository with everything else, so it gets evicted, and GitHub's cache API rate-limits a build that makes too many requests. A builder that already has the layers on its disk skips both directions.
How do I stop docker compose pulling the same images in every job?
On a GitHub-hosted runner you can't, only move the download: docker save the images to a tar file, cache it with actions/cache, and docker load it in the next job. That counts in the same 10 GB and is a download and a load every run. On a machine that keeps its disk, the images compose pulled last time are already there, and docker compose pull only fetches layers that changed.
Do I have to change my workflow to keep Docker's cache on Skipbox?
Only runs-on, for docker build and docker compose build: your own machine keeps Docker's images, layers and build cache between jobs. With docker/setup-buildx-action, add driver: docker to it so the build uses the machine's Docker instead of a new empty builder, and you can drop cache-from and cache-to.
How much does it cost to try?
Nothing. The trial is 7 days or 3,000 job-minutes, whichever comes first, with no card. Move your Docker build to runs-on: skipbox, run it twice, and compare the second run's build step with GitHub's.
I build Skipbox, the runner this guide ends on. Originally published at skipbox.dev/guides/docker-layer-cache-github-actions.
