The build cache is usually the largest thing Docker owns

Run docker system df on a machine that builds images every day and the big number is rarely next to Images. It’s next to Build Cache, often tens of gigabytes, made of intermediate results from builds that finished months ago. BuildKit keeps them because reusing them is what makes the second build of anything fast.

The cache isn’t the same thing as image layers. Layers belong to images and go when the image goes. Cache records are BuildKit’s own store and survive the deletion of every image on the machine, which is why removing images barely touches this number.

Look at it before you delete it

docker system df
docker buildx du
docker buildx du --verbose | head -40

docker buildx du is the one worth learning. The verbose form lists each cache record with its size, when it was last used, whether it’s shared between builds and whether it’s in use right now. A few minutes with it tells you whether you’re dealing with one enormous record or ten thousand small ones.

When a single build adds 10 GB, the cause is usually a large build context, a cache mount full of package downloads, or early stages of a multi-stage build that produce big intermediates. The verbose listing shows which.

Prune by age, not all at once

Wiping the whole cache makes the next build of every project cold, and a multi-stage Rust or Node build can go from a minute or two warm to a quarter of an hour cold. Pruning by age is almost always the better deal:

docker builder prune --filter until=168h     # anything not used for a week
docker builder prune --keep-storage=20GB     # trim down to a 20 GB ceiling
docker builder prune -a                      # all unused cache, not just dangling records

For cache records, until goes by when a record was last used, which makes it more forgiving than the same filter on images (where it’s the creation time). A year-old base layer that every build still touches stays.

docker system prune also clears unused build cache, which is why it sometimes reports a startlingly large number reclaimed.

Give it a ceiling so you stop doing this by hand

Whether the cache has any ceiling depends on how you build. Docker Engine garbage-collects the builder’s cache, and Docker Desktop’s default engine configuration sets the limit to 20 GB. A builder you made yourself with docker buildx create runs its own BuildKit with its own settings, and a hand-edited engine config may have dropped the limit altogether. Check which builder you’re using with docker buildx ls.

Schematic chart of build cache size over weeks of builds: without a limit the line keeps climbing; with a 20 GB limit it rises to the line, drops when garbage collection runs, and climbs back, hovering under the cap
With a limit set, garbage collection holds the cache near a size you chose instead of whatever the disk allows.

For the default builder, the setting lives in the engine’s JSON configuration: /etc/docker/daemon.json on Linux, or Settings, Docker Engine in Docker Desktop:

{
  "builder": {
    "gc": {
      "enabled": true,
      "defaultKeepStorage": "20GB"
    }
  }
}

A standalone BuildKit, or a builder created with docker buildx create --buildkitd-config buildkitd.toml, reads buildkitd.toml instead (/etc/buildkit/buildkitd.toml by default), where you can also set policies:

[worker.oci]
  gc = true
  maxUsedSpace = "20GB"

  [[worker.oci.gcpolicy]]
    maxUsedSpace = "5GB"
    keepDuration = "48h"
    filters = ["type==source.local", "type==exec.cachemount"]

  [[worker.oci.gcpolicy]]
    maxUsedSpace = "20GB"

Policies are applied in order, and once you list any, the maxUsedSpace above them no longer applies. The first expires local build context and cache mounts quickly, since those churn the most. The second caps everything else.

Put less into it

A limit treats the symptom. How you write the Dockerfile decides how much goes in to begin with.

Order layers by how often they change. Copy the dependency manifest and install dependencies before copying the source. Then a source edit rebuilds only the last layers, and only a change to package.json reruns the install. Get this backwards and every build writes a fresh set of cache records.

Use cache mounts for package managers instead of baking downloads into layers:

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

The downloaded packages then sit in a cache mount that persists between builds without becoming part of any image layer.

Use multi-stage builds and copy only the finished artefact out. The build stage is still cached, but the final image stays small, which matters for other reasons covered in why your image is 2 GB.

Don’t turn BuildKit off to avoid all this. The legacy builder piled up layers too and had none of these controls.

docker builder prune -a between jobs on a CI runner is right. On your own machine it throws away the thing that makes local builds bearable.

On a Mac, the prune is half the job

Everything above frees space inside Docker’s Linux virtual machine. Whether your Mac sees it depends on the VM’s disk file shrinking afterwards, which shrinking Docker.raw covers. If you want a single command for everything else Docker keeps, what each prune flag deletes lists the categories.

Keep reading