My CI runner lives in a VM on the Proxmox box in my house. This matters to the story, because when its disk fills up, no platform provider swaps in a fresh machine for me… it just fills up, jobs start failing, and the person on call is me, again, going “how is it always the cargo volumes?”
It kept happening. I’d clear space, a few weeks would pass, and the disk would creep back to full, always dominated by Rust and Go build caches. Trimming symptoms was clearly a mug’s game, so I finally sat down to find out where the bytes were actually coming from.
Straight out of the manual
The cache configuration in my CI component library was, at the time, straight out of the manual:
cache:
key:
files: [go.sum]
paths:
- .go/pkg/mod/
- .go/cache/
policy: pull-push
Key the cache on a hash of the lockfile. It’s the documented pattern, and the logic is sound: when dependencies change, the old cache is stale, so a new key gives you a clean one. The Rust template did the same with Cargo.lock and a target/ directory that grows like ivy.
Here’s the bit the manual doesn’t spell out. On GitLab’s Docker executor, each distinct cache key becomes its own cache volume on the runner’s disk. So a new lockfile hash means a new volume… and no old volume is ever garbage-collected. Not on merge, not on branch delete, not ever. The executor’s cache directory only ever grows. Now add how lockfiles actually behave in 2026: every feature branch nudges one, and Renovate nudges several a week all on its own. Each nudge, another multi-gigabyte volume.
Forever.
When I finally counted, the runner was hoarding about forty orphaned cache volumes totalling 28 GB, on a VM whose entire job is running two jobs at a time.
And the safety net? It had a hole in it. A systemd timer on the runner was meant to be pruning Docker’s leftovers, but docker system prune --volumes turns out to reject the until= time filter it had been handed, so the timer had been failing on every single run, for months, and nobody was watching its exit code. Which is the same as having no cleanup job at all, only you don’t find that out until the disk is already full.
The fix, and the trap right beside it
The fix that shipped is almost insultingly small:
cache:
key: "${CI_PROJECT_PATH_SLUG}-go"
(the commit, shipped in v0.12.0.) One stable cache per project per toolchain. When the lockfile changes, the job pulls the slightly-stale cache, downloads the handful of new modules, and pushes the refreshed cache back over the same key. Bounded disk, forever: the volume count now grows with the number of projects, not the number of times anyone touched a lockfile.
There’s a smaller trap I nearly walked into on the way. Keying per-branch ($CI_COMMIT_REF_SLUG) feels tidier, stops branches trampling each other’s caches… and re-creates the exact same leak, only now it’s one volume per branch instead of one per lockfile. Same disease, different symptom. Whatever you build that key out of, its number of possible values had better be something small you can count on one hand.
The same pass swept in two related tune-ups while the bonnet was open (28 jobs marked interruptible so superseded pipelines cancel, and terminal jobs demoted to policy: pull so linters stop re-uploading caches they didn’t change), but the keys were the disk story.
Whose disk was the advice written for?
I want to be fair to the docs here, because the per-lockfile pattern isn’t wrong. On shared SaaS runners it’s good: the cache storage is ephemeral, it’s someone else’s, and it’s effectively free to you, so maximal cache precision costs you nothing and saves you minutes. The advice just leans on an assumption nobody bothered to write down… that cache storage is disposable. On a persistent homelab runner, where the “cloud” is a VM I have to balance against everything else on the Proxmox host, that assumption flips right over, and the best practice becomes a slow leak with excellent references.
And that was the real takeaway for me that week. Every default, every best practice, was written for somebody’s infrastructure, and it pays to work out whose… because it might well not be yours. Mine’s 4 vCPUs, one disk, and a memory budget I guard like a dragon sat on its hoard. The cache keys know that now.
The prune timer got fixed too. It runs, it succeeds, and these days I even look at its logs… about as often as you check yours, I’d wager, but the cache no longer needs the safety net.





