Docker Build Cache in CI/CD
A CI worker starts with an empty cache every run, which is why naive pipelines rebuild everything every time. Push the cache to a registry instead - proven here by a brand new builder reporting CACHED on a layer it never built.
Operations and Troubleshooting Guide 36 of 46 Advanced
- OSUbuntu 26.04 LTS (resolute)
- Docker Engine29.7.2
- Buildxv0.36.1
- Architectureamd64
- TimeAbout 15 min
- Reviewed22 August 2026
Tested on the versions above. The workflow file is shown as a file. It was not executed on GitHub - the Docker commands it runs were executed locally, and those transcripts are the ones shown.
| Server Name | IP Address | OS | Roles | CPU | RAM | HDD |
|---|---|---|---|---|---|---|
| DOCKER01 | 192.168.0.21 | Ubuntu 26.04 LTS | Docker Host | 2 Core | 4 GB | 50 GB |
Before you start
- Buildx and builders - guide 23 in this path.
- Tagging, pushing and digests - guide 20 in this path.
- Understanding of the build cache - guide 17 in this path.
-
The problem CI has that your laptop does not
Your machine keeps its build cache between builds, so the second build of a project is fast. A CI runner is a fresh machine every time: no images, no layers, no cache. Unless you give the cache somewhere to live between runs, every pipeline pays full price for every layer, including the dependency install that never changes.
bash Example session docker buildx versiongithub.com/docker/buildx v0.36.1 1d8dde89b8aba914e05e45366770736fea1fd690# buildx can export the cache to a registry, which is what makes it survive the runnerExpected resultA buildx version. Anything from v0.10 supports the cache exports used here.
Success conditionYou have buildx. The default docker driver cannot export cache - you need a docker-container builder, created below.
-
Create a builder that can export cache
The
docker-containerdriver runs BuildKit in its own container, which is what unlocks registry cache export and multi-platform builds. Notenetwork=hosthere: without it the builder cannot reach a registry on the host's localhost, because the builder container has its own.bash Example session docker buildx create --name cg-builder --driver docker-container --driver-opt network=host --bootstrap#1 creating container buildx_buildkit_cg-builder0 0.4s done#1 DONE 9.7scg-builderExpected resultThe builder container created and bootstrapped.
Success conditionThe builder exists. In GitHub Actions the
docker/setup-buildx-actionstep does exactly this for you. -
Push the cache to a registry
--cache-to type=registrywrites the cache as an ordinary image tag alongside your build;--cache-fromreads it back.mode=maxexports intermediate layers too, not just the final ones - more upload, far better hit rate on the next run.bash Example session docker buildx build --builder cg-builder -t localhost:5000/cg-ci:2 --cache-to type=registry,ref=localhost:5000/cg-ci:cache,mode=max --cache-from type=registry,ref=localhost:5000/cg-ci:cache --push .#7 DONE 0.2s > importing cache manifest from localhost:5000/cg-ci:cacheExpected resultA
cachetag sitting in the registry next to your image tags.Verify it worked
bash curl -s http://localhost:5000/v2/cg-ci/tags/list{"name":"cg-ci","tags":["1","2","3","cache"]}Success conditionThe cache is a registry artefact now. It outlives the machine that produced it, which is the entire point.
-
Prove it works on a machine that has never built this
This is the test that matters. Delete the builder entirely and make a new one - a fresh BuildKit instance with an empty local cache, standing in for tomorrow's CI runner. It reports CACHED for the package install it has never run, because it pulled that layer from the registry.
bash docker buildx rm cg-builder && docker buildx create --name cg-builder2 --driver docker-container --driver-opt network=host --bootstrapcg-builder2cg-builder removed#1 [internal] booting buildkit#1 pulling image moby/buildkit:buildx-stable-1#1 pulling image moby/buildkit:buildx-stable-1 21.3s done#1 creating container buildx_buildkit_cg-builder20#1 creating container buildx_buildkit_cg-builder20 0.4s done#1 DONE 21.7sdocker buildx build --builder cg-builder2 -t localhost:5000/cg-ci:3 --cache-from type=registry,ref=localhost:5000/cg-ci:cache --push .#6 [2/2] RUN apk add --no-cache curl#6 CACHEDExpected resultCACHED on a builder created seconds ago.
Success conditionA brand new builder skipped the install. That is the saving your pipeline gets on every run after the first.
-
The workflow file
This is the whole thing in GitHub Actions form. Three official actions do the setup, and the build step carries the cache flags you just used by hand.
ghacache is the GitHub-hosted alternative to a registry cache and needs no registry credentials, but it is scoped to the repository and has its own size limits.bash cat .github/workflows/docker.ymlname: docker on: push: branches: [main] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - uses: docker/build-push-action@v6 with: push: true tags: myorg/myapp:latest cache-from: type=gha cache-to: type=gha,mode=maxExpected resultA workflow with checkout, buildx setup, registry login and a cached build.
Success conditionYou can map every line to something you ran by hand above. Nothing here is magic - the actions are wrappers over the same commands.
-
Tag with the commit, deploy by digest
Tagging with
github.shagives every build a unique, traceable name - never rely onlatestfrom CI. The digest is stronger still: it is the content address of exactly that build, so deploying by digest removes any possibility of the tag having moved underneath you.bash Example session docker buildx imagetools inspect localhost:5000/cg-ci:1 --format "{{ .Manifest.Digest }}"sha256:2dd2a150c294f6984e676418b7230fe26b7b1c3aaa7845c362c1179fe7bcfe1dExpected resultA sha256 digest for the pushed image.
Success conditionYou have an immutable reference.
build-push-actionexposes this as an output, so a deploy job downstream can consume it directly. -
Clean up
Builders are containers and they persist until removed, holding their cache with them. On a long-lived runner this is a real disk consumer.
bash docker buildx rm cg-builder2docker rm -f cg-registry# check for the anonymous volume the registry leaves behind: docker volume lsExpected resultThe builder and registry removed.
Success condition
docker buildx lsno longer lists your builder. Remember the registry container leaves an anonymous volume - remove it too.
Troubleshooting
Cache export fails with "not supported for the docker driver"
Why: The default builder cannot export cache. Only the docker-container and other BuildKit drivers can.
Fix:Create a docker-container builder, or in Actions add the
docker/setup-buildx-actionstep, which does it for you.bash docker buildx ls# check the DRIVER column of the builder marked with *The builder cannot reach a registry on localhost
Why: The builder runs in its own container, so its localhost is not the host's.
dial tcp [::1]:5000is the signature.Fix:Create the builder with
--driver-opt network=host, or address the registry by a name the builder can resolve.bash docker buildx create --name b --driver docker-container --driver-opt network=host --bootstrapEvery CI run is a full rebuild despite cache flags
Why: Cache is being written but never read, the ref differs between runs, or an early layer changes each run - a COPY of the whole context invalidates everything after it.
Fix:Confirm the import line appears in the log, then order the Dockerfile so dependencies are installed before source is copied.
bash docker buildx build --cache-from type=registry,ref=REF . 2>&1 | grep -i "importing cache"denied: permission_denied when pushing to ghcr.io
Why: The job lacks
packages: writepermission, or the image name does not match the owning repository.Fix:Add the permission block to the job and tag with
ghcr.io/${{ github.repository }}, which is always lower case and always owned by that repo.bash permissions: contents: readdocker login ghcr.io