Docker layer caching in GitHub Actions

A GitHub Actions Docker cache saves image layers from one build so the next build can reuse them. You can store those layers in a registry, in the GitHub Actions cache, or on a warm machine that keeps them.

Why Docker builds are slow in CI

On your laptop, a second docker build is fast. Docker kept the layers from the first one.

A GitHub-hosted runner is a fresh machine for each job. Its layer cache starts empty. So every build pulls the base image again and runs every step again: apt-get install, npm ci, compiling. That is why Docker builds feel slow in CI.

How layer caching works

A Dockerfile is a list of steps. Each step makes a layer. Docker gives each layer a key based on the step and the files it used.

If the key matches a saved layer, Docker skips the step. Once one step changes, every step after it runs again. So order matters:

FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci                 # cached until the lock file changes
COPY . .                   # your code changes often, so copy it last
RUN npm run build

Put things that rarely change at the top. Put your code near the bottom. Then the slow install step stays cached.

A .dockerignore file helps too. It keeps files like node_modules and .git out of the build. Fewer files means fewer changes, so more layers stay cached.

Multi-stage builds help as well. Build in one stage, then copy only the result into a small final image. The heavy build stage can stay cached while the final image stays small.

Option 1: cache-from and cache-to

BuildKit can export its cache to a registry and import it next time. You use docker cache-from and cache-to, through docker/build-push-action:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/my-org/my-app:latest
    cache-from: type=registry,ref=ghcr.io/my-org/my-app:buildcache
    cache-to: type=registry,ref=ghcr.io/my-org/my-app:buildcache,mode=max

mode=max saves every layer, not just the final ones. This works on any runner. The cost is time: each build downloads and uploads the cache over the network.

You also need write access to the registry. For GitHub's registry, give the job packages: write and log in first with docker/login-action.

Option 2: the GitHub Actions cache backend

BuildKit can also store layers in the GitHub Actions cache. It needs no registry:

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    cache-from: type=gha
    cache-to: type=gha,mode=max

This is the easiest way to cache a Docker image in GitHub Actions. But it shares your repo's cache space with every other cache. Big images can push other caches out. Read GitHub Actions cache, explained for the limits and eviction rules.

Option 3: a warm machine with a registry mirror

The third way is to stop starting from zero. Run the build on a machine that keeps its Docker cache between jobs. That is a self-hosted runner.

On your own machine, a plain docker build uses the Docker engine's own cache, which stays on disk. Layers from the last build are still there. No download, no upload. One catch: docker/setup-buildx-action makes a new builder per job and removes it after. Set driver: docker on it to build with the engine and keep its cache.

Codemany runs GitHub Actions jobs on your own Mac or Linux machines. On Linux, it adds a Docker Hub mirror on the machine:

  • BuildKit's docker.io pulls go to the local mirror first.
  • If the mirror is down or lacks an image, BuildKit pulls from Docker Hub. The build never fails because of the mirror.
  • The mirror only pulls public images, with no login. It never holds your credentials or private images.
  • Layers expire a week after their last pull, and the total size is capped.

Note one tradeoff. If you turn on Codemany's opt-in VM per job on Linux, each job starts in a fresh VM, so its caches start cold. The default host mode keeps them warm. See local cache and Docker in Docker.

Which one to pick

OptionGood forWatch out for
cache-from / cache-toAny runner, big cachesNetwork time each build
GitHub Actions cacheSmall images, no registryShares the repo's cache limit
Warm machineFrequent buildsYou run the machine

You can mix them, too. Many teams start with type=gha. When builds are still slow, they move to a warm machine. In our nightly benchmarks, jobs on Codemany run 1.5×–4.3× faster on private code than on GitHub's 2-core runners. If your whole pipeline is slow, read why GitHub Actions is slow.

Whatever you pick, check that it works. Look at the build log. Cached steps show CACHED next to them. If every step runs each time, your key or your Dockerfile order needs work.

Questions

Does GitHub Actions cache Docker layers by default?

No. Each fresh hosted runner starts with an empty layer cache.

What is a registry mirror?

A local copy of image pulls, so base images come from the machine, not Docker Hub.

Does Codemany mirror Docker Hub?

Yes, on Linux. It points BuildKit's docker.io pulls at a local mirror, and falls back to Docker Hub.

Read next: GitHub Actions cache, local cache, Docker in Docker, slow GitHub Actions and the benchmarks.

Start a 14-day trial No card needed. $20 a month for 2 machines after that.