Skip to main content
RunBook Academy

Docker & ContainersXXVIII Β· MaintenanceReclaiming disk

Build cache maintenance β€” the quiet largest consumer

Intermediate⏱ ~22 mindocker

What you'll learn

  • Measure build cache with `docker buildx du` and read the shared/private split
  • Prune build cache with a bound rather than wholesale
  • Configure BuildKit garbage collection in `daemon.json`
  • Distinguish cache loss from cache invalidation when builds get slow

Prerequisites

Verified against Docker Engine 29.x Β· Docker Engine 28.x Β· Docker Compose 2.x Β· containerd 2.x Β· runc 1.2.x Β· BuildKit 0.20+ Β· Linux kernel 5.15+ Β· Ubuntu 24.04 LTS Β· Debian 12 (Bookworm) Β· 2026-08-11

Not yet marked complete on this device.

On a host that builds images, build cache is usually the largest single consumer of disk and the last one anyone looks at. It does not appear in docker images, it has no tags, and its entries are opaque hashes. The 7.4 GB in the Build Cache row of the previous lesson was larger than every container writable layer on the host by four orders of magnitude.

It is also the only Docker object with a real garbage collector, which makes it the easiest of the four categories to stop worrying about.

Measuring it

docker system df gives you the total. docker buildx du gives you the records.

Read-only / Safebuildx du
$ docker buildx du
ID                           RECLAIMABLE   SIZE       LAST ACCESSED
j5j2jprjqbdgiobhyqqukak41    true          248B*      10 days ago
50w5fx23527c0ex1kai5m4nss    true          405B*      6 days ago
7zxq95ogag1jtq3tc40zfqgcz    true          957B*      6 days ago
j66nkr4hb6t12vmyoz92ple0c    true          553.1MB    6 days ago
Shared:         2.322GB
Private:        5.124GB
Reclaimable:    7.446GB
Total:          7.446GB

Illustrative output

Four numbers at the bottom, and they answer different questions:

  • Shared β€” cache blobs that are also referenced by an image on this host. Pruning the cache record does not free these, because the image still holds the layer.
  • Private β€” blobs held only by the cache. This is what a prune actually frees.
  • Reclaimable β€” the total of records marked RECLAIMABLE true.
  • Total β€” everything, including records currently pinned by a running build.

The asterisk on some sizes marks a shared record. When you are estimating how much a prune will return, Private is the number, not Reclaimable.

Read-only / Safeverbose du
docker buildx du --verbose

The verbose form adds the Dockerfile step or mount that produced each record. That is how you find out that 5 GB of your cache is one RUN npm ci layer retained across forty builds of a Node image.

Why it grows without bound

Pruning with a bound

The wholesale form is the one everyone runs and the one you should almost never use:

Destructivewholesale
docker builder prune -a -f

-a on build cache means β€œinclude internal and frontend records too” β€” it is not the same widening as -a on images, but the practical effect is the same: the next build starts cold.

The bounded forms are better:

Destructivebounded prunes
# Drop records not touched in a week
docker builder prune --filter 'until=168h' -f

# Keep a floor of cache and prune down to it
docker builder prune --reserved-space 10GB -f

# Prune until the cache is under a ceiling
docker builder prune --max-used-space 20GB -f

# Prune until the disk has this much free
docker builder prune --min-free-space 30GB -f

Configuring garbage collection so you never prune by hand

This is the part worth doing once. BuildKit inside dockerd reads its GC policy from daemon.json:

Configuration changedaemon.json
{
"builder": {
  "gc": {
    "enabled": true,
    "defaultKeepStorage": "20GB"
  }
}
}

With enabled: true and a keep-storage figure, BuildKit runs its own collection in the background and holds the cache near that size. The default policy behind that single number is a four-tier sweep, in order:

  1. Ephemeral cache (internal and frontend records) older than 48 hours.
  2. Any cache older than 60 days.
  3. Unshared cache over the size limit.
  4. Any cache over the size limit.

That ordering matters: BuildKit gives up the cheap-to-rebuild records first and only touches shared, recently used records when it has no other choice.

For a builder with its own buildkitd.toml β€” a docker-container driver builder rather than the default in-daemon one β€” the equivalent keys live under [worker.oci] and use reservedSpace, maxUsedSpace and minFreeSpace.

Cache loss versus cache invalidation

When builds suddenly take ten times longer, the cause is one of two things and the remedies are opposite.

SymptomCauseFix
Every step re-runs, including ones whose inputs did not changeCache loss β€” the records were pruned or GC removed themRe-warm the cache; raise the GC cap
Steps re-run from a specific line onwards, consistentlyCache invalidation β€” an input to that step changedReorder the Dockerfile; this is a build design problem
First build after a deploy is slow, subsequent ones fastNormal cold cacheNothing
Read-only / Safediagnose
docker buildx du --verbose | head -40
docker system df

If Total collapsed since yesterday, someone pruned. If it is unchanged and builds are still slow, the Dockerfile changed. The build caching lesson in the build part of this course covers the second case; this lesson is only about the first.

Knowledge check

Knowledge check Β· 4 questions

  1. Q1. In `docker buildx du` output, which figure best predicts the disk you will actually reclaim by pruning the cache?

  2. Q2. Which `daemon.json` structure caps BuildKit cache for the default in-daemon builder?

  3. Q3. Which of these cause build cache to grow faster than image count would suggest? Select all that apply.

  4. Q4. Adding `builder.gc` to `daemon.json` and restarting the daemon immediately shrinks the existing cache to the configured size.

Passing score: 75%. Answers are checked in this browser.