How to Prevent Docker Build Cache Bloat with Automatic Pruning Settings
This page has been translated by machine translation. View original
Introduction
This is Fujii (Dai) from the Manufacturing Business Technology Department.
When you use Docker, you sometimes find that free disk space has quietly shrunk.
If you look at docker system df, build cache usually accounts for several GB.
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 10 1 9.55GB 391.8MB (4%)
Containers 1 1 2.807GB 0B (0%)
Local Volumes 3 3 1.006GB 0B (0%)
Build Cache 53 0 3.681GB 513.6MB
You could just run docker builder prune whenever you notice.
But it's tedious to repeat the cycle of panicking and deleting only after space gets tight.
BuildKit has a mechanism for automatically deleting cache (garbage collection, GC), and you can change its behavior by writing a policy in daemon.json.
Depending on what you want to do, there are two ways to write it.
If you only want to set a capacity threshold, one line is enough.
{ "builder": { "gc": { "enabled": true, "defaultMaxUsedSpace": "10GB" } } }
This is not a strict disk quota, but only a deletion criterion applied when GC runs (it can be temporarily exceeded).
If you also want to decide by elapsed time, such as "delete anything more than 24 hours past its last use," write an until filter in policy.
{ "builder": { "gc": { "enabled": true, "policy": [ { "filter": ["until=24h"] } ] } } }
This is the minimal form.
In practice you will also add a capacity tier, so I've put how to choose between them and a complete example in "So how should you configure it?" below.
I took quite a few detours before settling here...
At first I imitated the example in the official documentation and wrote the elapsed time with a key called keepDuration, but this does not exist in daemon.json. If you write it, it is silently discarded.
As I investigated, I found that the GC settings diverge from the documentation to the implementation in many places.
In this article, I summarize the settings that actually work, and these discrepancies in five parts.
The verification environment is as follows.
- Rancher Desktop 1.24.0 (Docker Engine 29.5.3,
dockerdriver, BuildKit v0.30.0) - Host is macOS
Source quotations are taken from the tag docker-v29.5.3, which corresponds to the verified version.
Docker Engine release tags use the format docker-vX.Y.Z, which is different from vX.Y.Z.
First, look at the policy being applied
Before writing any settings, let's cover how to check them.
With docker buildx inspect, you can see the policy that is actually applied.
docker buildx inspect
GC Policy rule#0:
All: false
Filters: type==source.local,type==exec.cachemount,type==source.git.checkout
Keep Duration: 48h0m0s
Max Used Space: 488.3MiB
GC Policy rule#1:
All: false
Keep Duration: 1440h0m0s
Max Used Space: 10GiB
GC Policy rule#2:
All: false
Max Used Space: 10GiB
GC Policy rule#3:
All: true
Max Used Space: 10GiB
How the JSON you wrote was interpreted is displayed as is, so this is the most reliable way to check.
I found most of the discrepancies listed in this article, such as keepDuration and the conversion results of filters, from this output.
Also, as the "10GB" written in daemon.json is displayed as 10GiB, capacity values are interpreted in binary.
They are not treated as decimal and converted to something like 9.31GiB.
You can check cache usage with docker buildx du.
docker buildx du
Shared: 507.6MB
Private: 419.5MB
Reclaimable: 927.1MB
Total: 927.1MB
The distinction between Shared and Private will become important later.
GC is running even if you configure nothing
The output in the previous section is from a state where only "10GB" was specified for defaultMaxUsedSpace.
If you don't write policy, the default four-tier structure is used.
- When usage exceeds the limit for temporary cache, delete
source.local,exec.cachemount, andsource.git.checkoutolder than 48 hours - When usage exceeds the limit, delete cache older than 60 days (1440 hours)
- When usage exceeds the limit, delete regardless of elapsed time
- If that is still not enough, delete including the types protected as
All: true
The first and second tiers have conditions on elapsed time since last use: "48 hours" and "60 days."
And every tier has a capacity condition attached.
As you can see in the previous section's output, Max Used Space is listed even for the first and second tiers that have Keep Duration.
In other words, 48 hours and 60 days are not simple expiration periods.
Only when the capacity condition is met is deletion performed, narrowed down by elapsed time.
While the disk has room, cache is not deleted no matter how many days pass, let alone 60 days.
The implementation is DefaultGCPolicy in daemon/internal/builder-next/worker/gc.go.
The default capacity values are calculated from the disk size only when reservedSpace, maxUsedSpace, and minFreeSpace are all zero, and become 10%, 80%, and 20% of the total disk respectively.
The 2GB value in the implementation is not a default; it is a fallback used when disk information could not be read.
The reason Max Used Space in the first tier is an odd value like 488.3MiB is that a lower limit of 512MB (= 488.3MiB) is set for the temporary cache capacity.
The official documentation says the default of defaultKeepStorage is 20GB, but that value is explicitly described as being "for Docker Desktop."
For a plain Docker Engine, it is determined by the ratio to the disk, as described above.
The cache growing if left alone is not because GC isn't running.
It's because the elapsed-time conditions are long, at 48 hours and 60 days, and the capacity conditions are also a ratio of the disk, so the limit is larger than you might feel.
Location of the configuration file and how to apply it
Write the GC policy in the Docker daemon configuration file daemon.json.
The location varies by environment.
- Running dockerd directly on Linux:
/etc/docker/daemon.json - Linux rootless mode:
~/.config/docker/daemon.json(ifXDG_CONFIG_HOMEis set, that takes precedence) - Docker Desktop: There is a JSON editor in the Docker Engine tab of Settings (the actual file is
~/.docker/daemon.json) - Running dockerd directly on Windows:
%programdata%\\docker\\config\\daemon.json
On macOS and Windows, dockerd itself runs inside a Linux VM.
With Docker Desktop, editing from the GUI editor is the natural way.
The Rancher Desktop I used has no such GUI, so I edited /etc/docker/daemon.json inside the VM directly.
You can enter the VM with rdctl shell.
rdctl shell sudo vi /etc/docker/daemon.json
A reload does not apply it
After writing, apply the settings.
Note here that a reload (SIGHUP) does not apply them.
Some settings in daemon.json can be applied without a restart, so I tried a reload first.
When you send SIGHUP to dockerd, the entire loaded configuration is dumped to the log.
# For Rancher Desktop
rdctl shell sudo sh -c 'kill -HUP $(pgrep -f "^/usr/bin/dockerd"); sleep 2; tail -3 /var/log/docker.log'
In the log, it looks successful.
level=info msg="Got signal to reload configuration" config-file=/etc/docker/daemon.json
level=info msg="Reloaded configuration" config="{... \"builder\":{\"GC\":{},\"Entitlements\":{}} ...}"
However, the configuration dump still shows "builder":{"GC":{}}, and the policy I wrote is not in it.
Looking at daemon/reload.go, the list of handlers called on reload contained nothing related to builder.
This means BuildKit's GC settings are read only when the daemon starts.
# When running dockerd directly on Linux
sudo systemctl restart docker
# For Rancher Desktop
rdctl shell sudo rc-service docker restart
With Docker Desktop, the Apply & restart button in the Docker Engine tab takes care of the restart as well.
GC runs at startup
I also checked when deletion happens.
GC runs when the daemon starts.
Even after restarting three times with gc disabled, the cache stayed at 6 entries and 948.1MB, with nothing happening.
In other words, a restart itself does not clean the cache.
On the other hand, if you put in a policy that deletes things and restart, the cache shrinks on the spot.
You can verify by changing the settings, restarting, and looking at docker buildx du.
In BuildKit's implementation (control/control.go), a GC is scheduled for one second later when the controller is initialized, that is, when the daemon starts.
It is scheduled in the same way when a build (Solve) finishes, and execution is throttled to at most once per minute.
This is why it doesn't shrink while idle.
In this verification, some records were not deleted by the startup GC alone and were additionally deleted after a subsequent build.
Depending on reference state and so on, it seems that not everything necessarily becomes a deletion target at once.
Discrepancy 1: keepDuration does not exist in daemon.json
Here is the main topic.
What I wrote first was the following policy, imitating the example in the official documentation.
{
"builder": {
"gc": {
"enabled": true,
"policy": [
{ "keepDuration": "24h", "reservedSpace": "1GB" },
{ "reservedSpace": "10GB", "all": true }
]
}
}
}
This doesn't work.
Looking at docker buildx inspect, there is no Keep Duration line.
In the configuration dump too, only KeepDuration has dropped out of the loaded content.
"Policy": [
{ "Filter": [], "ReservedSpace": "1GB" },
{ "All": true, "Filter": [], "ReservedSpace": "10GB" }
]
The reason is clear when you look at the struct.
https://github.com/moby/moby/blob/docker-v29.5.3/daemon/config/builder.go
type BuilderGCRule struct {
All bool `json:",omitempty"`
Filter BuilderGCFilter
ReservedSpace string `json:",omitempty"`
MaxUsedSpace string `json:",omitempty"`
MinFreeSpace string `json:",omitempty"`
}
There is no KeepDuration field.
Go's encoding/json ignores unknown keys by default, so keepDuration is silently discarded.
Even in getGCPolicy, which passes the policy to BuildKit, only the three capacities and the filter are passed.
https://github.com/moby/moby/blob/docker-v29.5.3/daemon/internal/builder-next/controller.go
for i, p := range conf.GC.Policy {
reservedSpace, maxUsedSpace, minFreeSpace, err := parseGCPolicy(p, "")
...
gcPolicy[i], err = toBuildkitPruneInfo(buildbackend.CachePruneOptions{
All: p.All,
ReservedSpace: reservedSpace,
MaxUsedSpace: maxUsedSpace,
MinFreeSpace: minFreeSpace,
Filters: filters.Args(p.Filter),
})
Nevertheless, the official documentation's "Custom GC policies in the Docker daemon configuration file" contains a daemon.json example that uses keepDuration.
{ "reservedSpace": "50GB", "keepDuration": ["1440h"] },
The keepDuration in this first line does not work as an elapsed-time condition, and only the reservedSpace part is read.
The full daemon.json example in the dockerd reference also includes keepDuration, as a string "48h" there, while the GC page uses an array ["1440h"].
So a nonexistent key appears on both pages, without even the notation being consistent (as of August 2026).
This discrepancy has been raised as an issue since November 2023.
The main point is that "the GC settings in daemon.json are complicated, and you can't write multiple filters in one policy," and it also mentions that you can't write the equivalent of BuildKit's keepDuration.
The multiple-filter matter is the same problem as "Discrepancy 4" below.
As of August 2026 it is still open.
Discrepancy 2: Write elapsed time with the until filter
So is it impossible to specify elapsed time with the docker driver? That turned out not to be the case.
Write until in filter.
{
"builder": {
"gc": {
"enabled": true,
"policy": [
{ "filter": ["until=24h"] }
]
}
}
}
When I applied this and looked at docker buildx inspect, it was converted to Keep Duration.
GC Policy rule#0:
All: false
Filters:
Keep Duration: 24h0m0.000000917s
The filter is passed from getGCPolicy in the previous section to toBuildkitPruneInfo, where until is interpreted as an elapsed time and removed from the filters.
That is why Filters is empty.
The trailing 0.000000917s is not a rounding error; it is the real time elapsed between two retrievals of the current time, added when the until value is converted back into a duration via an absolute time.
That is why the fractional part changes every time you run it, and in practice you don't need to worry about it.
The same can be written with unused-for, but in the implementation until is the official name and unused-for is a deprecated alias (daemon/internal/builder-next/builder.go).
If you write both at the same time, dockerd fails to start with the error conflicting filters: "until" and "unused-for".
Use until for new configurations.
Verifying that it really works
It's possible it is only displayed but doesn't actually work.
I varied the elapsed time and capacity conditions and checked whether deletion really happens.
The cache was 6 entries and 948.1MB, of which Private was 629.3MB.
Looking with docker buildx du --verbose, all of them were last used a few minutes ago.
| Policy | Result |
|---|---|
{"filter": ["unused-for=24h"], "maxUsedSpace": "100MB"} |
No deletion (stays at 6 entries 948.1MB) |
{"filter": ["unused-for=1s"], "maxUsedSpace": "100MB"} |
6 entries 948.1MB → 3 entries 633.4MB |
{"filter": ["unused-for=1s"]} (no capacity condition) |
7 entries 1.263GB → 3 entries 633.4MB |
{"filter": ["unused-for=1s"], "all": true} |
3 entries 633.4MB → 1 entry 4.183MB |
The reason only the third row starts with more entries is that I recreated what was deleted in the second row before trying it.
Also, the table is from the investigation and uses the alias unused-for, but you can read it as until.
The first row is the key point.
Private, which is the subject of the capacity condition check, is 629.3MB, well over the 100MB limit, yet nothing is deleted when 24 hours is specified.
This means cache used a few minutes ago is protected, so it works as an elapsed-time condition.
As in the third row, deletion happened by elapsed time alone even without a capacity condition.
As for all in the fourth row, with the default false, some types are protected.
The reason it stopped at 3 entries 633.4MB even with unused-for=1s is that records of the type mount / from exec ... remained.
Adding all: true removed these too, and finally only one record of 4.183MB remained.
This is Shared: true, that is, a layer referenced from an image (if you delete the referencing image, Shared changes to false).
As described later, all: true is implemented to include shared records as a target, so the reason this remains should not be sharing itself.
Nor was it in use; this record was Reclaimable: true (Reclaimable in docker buildx du is the negation of InUse).
No deletion failure errors appeared in the daemon log either, and I did not get as far as identifying the reason.
Discrepancy 3: Capacity conditions apply to the amount excluding Shared
There is also behavior in the capacity limit that is not written in the documentation.
docker buildx du shows usage divided into Shared and Private.
Shared is the layers shared with images and so on, and Private is the portion exclusively occupied by build cache.
I deliberately created a breakdown and tried it.
Shared: 507.6MB
Private: 419.5MB
Total: 927.1MB
In this state, even when I set the limit to 600MB, between Total and Private, no deletion occurred.
It worked when I lowered it to 300MB.
| Limit | Relation to Private | Result |
|---|---|---|
| 600MB | 419.5MB < 600MB | Not deleted |
| 300MB | 419.5MB > 300MB | 7 entries 927.1MB → 6 entries 675.4MB |
Looking at BuildKit's implementation explains the reason.
When calculating totalSize, which is used for the decision, Shared records are excluded.
https://github.com/moby/buildkit/blob/v0.30.0/cache/manager.go
totalSize := int64(0)
if opt.MaxUsedSpace != 0 || opt.ReservedSpace != 0 || opt.MinFreeSpace != 0 {
du, err := cm.DiskUsage(ctx, client.DiskUsageInfo{})
if err != nil {
return err
}
for _, ui := range du {
if ui.Shared {
continue
}
totalSize += ui.Size
}
}
In other words, capacity conditions such as maxUsedSpace are evaluated against the exclusive portion, not the total including Shared.
If no capacity condition is specified, this calculation itself is not performed.
In the docker system df at the beginning, Build Cache had SIZE 3.681GB and RECLAIMABLE 513.6MB, and in this environment that RECLAIMABLE matched Private in docker buildx du.
Even if you set the limit to 1GB in this case, 513.6MB is below 1GB, so nothing happens.
When deciding the limit, look at Private in docker buildx du, not SIZE.
Discrepancy 4: Write filter as "one key per element"
filter is a JSON array, but if you split it into elements naively, startup can fail.
Here are the results of trying each notation.
| Notation | Result |
|---|---|
["until=24h"] |
Starts |
["until=24h", "type=source.local"] |
Starts |
["type=source.local", "type=exec.cachemount"] |
Startup failure (filters expect only one value) |
["until=24h,type=source.local"] |
Startup failure |
The rule is "one key per array element."
- To specify multiple values for the same key, join them with commas into one element
- For different keys, split them into separate elements
- Joining different keys with commas into one element makes startup fail
When listing the same key, mix operators
The tricky part starts here.
When you list the same key separated by commas, dockerd may start but fail when GC runs.
If you write ["type=source.local,type=exec.cachemount"] and look at inspect, it shows this.
Filters: type==source.local,type=exec.cachemount
Only the first is normalized to ==, and the second remains =.
When GC runs in this state, an error appears in the daemon log.
level=error msg="gc error: filters: parse error:
[type==source.local,type >|=|< exec.cachemount]: unsupported operator \"=\": invalid argument
failed to parse prune filters [type==source.local,type=exec.cachemount]"
The parser supplies = only for the first condition, so you need to mix the operators when writing.
Here are the results of trying three ways.
| Notation | inspect display |
When GC runs |
|---|---|---|
All = |
type==source.local,type=exec.cachemount |
Error (unsupported operator "=") |
First =, second and later == |
type==source.local,type==exec.cachemount |
Works |
All == |
type===source.local,type==exec.cachemount |
Error (unsupported operator "===") |
Only the middle one works.
"filter": ["type=source.local,type==exec.cachemount,type==source.git.checkout"]
As you can see, this is a notation that depends on an implementation inconsistency, and there is an issue about it.
It is an open issue filed in December 2024, which speculates that the parser supplies = only for the first condition.
And this failure cannot be detected with docker buildx inspect.
inspect only displays the conversion result and does not run the parse performed when GC executes.
If you write the same key multiple times, check not only inspect but also the daemon log after actually running GC.
Discrepancy 5: Unknown keys are silently ignored
As the keepDuration case shows, you can't judge whether a key you wrote is being read by whether you were able to write it.
To see how far detection goes, I put impossible keys in two places.
When put inside a policy, nothing happens.
{ "keepDuration": "3h", "reservedSpace": "1GB", "totallyBogusKey": "xyz" }
dockerd started normally, and no warning appeared in the log.
On the other hand, when put at the top level, startup fails.
{ "totallyBogusTopLevelKey": "xyz" }
unable to configure the Docker daemon with file /etc/docker/daemon.json:
the following directives don't match any configuration option: totallyBogusTopLevelKey
At least inside builder.gc.policy, unknown keys do not cause errors.
This means there are places where it can start even if you get a key name wrong.
The mechanism is in daemon/config/config.go.
Unknown key detection works by flattening the JSON only one level and matching against flag names, and builder is excluded from flattening and treated as skipped for validation.
There is no mechanism to validate deeper levels, and loading is a plain json.Unmarshal, so anything you write inside builder.gc.policy is silently ignored.
This matters because key names are in flux between old and new in this area.
For the capacity keys, reservedSpace, maxUsedSpace, and minFreeSpace were introduced in Docker Engine 28 and later, and the earlier keepStorage and defaultKeepStorage became the legacy names.
It was filed in October 2024 and closed in February 2025 (milestone 28.0.0).
It was to keep up with BuildKit v0.17's GC settings.
The inconsistent notation within the official documentation is a remnant of this migration: the GC page uses defaultKeepStorage, while the configuration file example in the dockerd reference uses defaultReservedSpace.
However, the legacy names are not ignored.
When I wrote defaultKeepStorage, it was loaded as DefaultReservedSpace.
The reality is that keys that live on as aliases and keys that don't exist at all are mixed together.
That is exactly why checking with docker buildx inspect is worthwhile.
So how should you configure it?
There are two ways depending on the use case.
If you only want to set an upper limit
Don't write policy; specify only the capacity key.
{
"builder": {
"gc": {
"enabled": true,
"defaultMaxUsedSpace": "10GB"
}
}
}
Since policy remains empty, the default four-tier structure is used, and the elapsed-time conditions of 48 hours and 60 days remain as well.
This is the simplest.
There are two points to note.
One is that this is not a strict disk quota.
It is the deletion criterion when GC runs, so it will be temporarily exceeded during builds and so on.
If there are records that cannot be deleted, it may not come down all the way to the configured value.
The other is that the automatic calculation from the disk ratio is done only when all three capacity keys are zero.
If you write only defaultMaxUsedSpace, reservedSpace and minFreeSpace become 0, and the default 10% and 20% are not carried over.
If you also want to set the lower limits, explicitly specify values that suit your environment.
{
"builder": {
"gc": {
"enabled": true,
"defaultReservedSpace": "2GB",
"defaultMaxUsedSpace": "10GB",
"defaultMinFreeSpace": "5GB"
}
}
}
This 2GB is not a default, just an example (as noted above, the plain default is 10% of the disk).
If you also want to delete by elapsed time
Use until and write policy.
{
"builder": {
"gc": {
"enabled": true,
"policy": [
{ "filter": ["until=24h"] },
{ "maxUsedSpace": "10GB" },
{ "maxUsedSpace": "10GB", "all": true }
]
}
}
}
The first tier deletes cache more than 24 hours past its last use.
Since no capacity condition is written, it is deleted even if the disk has room.
The second tier is an upper limit regardless of elapsed time, and the third tier widens the target when that is still not enough.
The key is not to add all casually.
all: true does not mean "all build cache"; it includes records that are protected by default, such as internal and frontend records, and shared records as targets.
If you add it to the first tier, all of those get deleted just because they went unused for 24 hours, making rebuilds heavy.
When you write policy, the default four-tier structure is replaced.
The 48-hour and 60-day conditions and the per-type handling are also replaced with what you wrote, so if an upper limit alone is enough, the former approach is more straightforward.
In either case, after writing, restart and check with docker buildx inspect.
Supplement: The docker-container driver uses different settings
The daemon.json settings so far apply only to builders using the docker driver.
Builders using the docker-container driver, created with docker buildx create, run an independent BuildKit inside a container and hold their own separate cache.
You can tell the type of your builder from the DRIVER column of docker buildx ls.
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default* docker v0.30.0 linux/amd64, linux/arm64
For the docker-container driver, you configure it with buildkitd.toml.
And here keepDuration exists as is.
[worker.oci]
gc = true
reservedSpace = "10GB"
[[worker.oci.gcpolicy]]
keepDuration = "24h"
reservedSpace = "1GB"
[[worker.oci.gcpolicy]]
all = true
reservedSpace = "10GB"
The official documentation also states explicitly that it may be "either an integer number of seconds (e.g., 172800) or a string (e.g., "48h")."
The key name is also filters rather than filter, and the operator is ==.
I imagine the reason keepDuration ended up in the daemon.json example is that it got mixed up with this BuildKit-side configuration.
Conclusion
If you only want to declaratively set the build cache upper limit, use one line of defaultMaxUsedSpace; if you also want to delete by elapsed time, write an until filter in policy.
Here is a summary of the discrepancies between documentation and implementation that I stumbled over on the way.
- There is no
keepDurationfield in thedaemon.jsonpolicy, and writing it is ignored as an unknown key - To specify elapsed time, use
untilinfilter.unused-foris a deprecated alias, and writing both prevents startup - The default 48 hours and 60 days are not simple expiration periods; they are evaluated in combination with capacity conditions
- Capacity conditions are evaluated against the exclusive portion excluding Shared
filterallows only one key per array element. When listing the same key, if you don't use=for the first and==for the second and later, it fails when GC runs- Inside
builder.gc.policy, dockerd starts even if you get a key name wrong - If you write only
defaultMaxUsedSpace,reservedSpaceandminFreeSpacebecome 0
All of these appear in the form of "I wrote it but it doesn't work," so the causes were hard to find.
If you get into the habit of checking the interpretation result with docker buildx inspect, you'll find this kind of problem much faster.
I hope this helps others who are troubled by build cache in the same way.