Container Image Housekeeping: Pruning, Pinning, and Not Running latest in Production
The unglamorous discipline that keeps your container hosts sane

Contents
Container hosts have a way of quietly filling up. You pull images, you rebuild, you redeploy, and every iteration leaves a sediment of old layers behind. Then one ordinary morning a deploy fails with no space left on device, you go looking, and /var/lib/docker has eaten thirty gigabytes of disk you didn’t know you’d given it. I have had this exact morning, on a box I thought I was looking after, and the indignity of discovering that nothing was wrong — no leak, no runaway log, just months of orphaned layers nobody pruned — is a particular flavour of self-inflicted. Container image housekeeping is the least glamorous topic in this entire field, and it’s the one that’ll wake you at 3am if you ignore it.
It comes down to three disciplines: pruning what you no longer need, pinning what you do need so it doesn’t change underneath you, and never, ever running :latest in production. None of it is hard. All of it is routinely skipped, because none of it is fun and all of it competes for attention with work that feels more productive. The whole argument of this post is that the boring disciplines are the productive work — they’re just the kind whose payoff is the absence of a disaster, which is the hardest kind of payoff to feel grateful for.
Where the disk actually goes
Every image is a stack of layers, and every build or pull can leave older layers orphaned — no longer referenced by any tagged image, but still on disk. Stopped containers keep their writable layers. Build caches accumulate. Anonymous volumes outlive the containers that made them. Add it up over a few months of an active host and you’ve got a slow-motion disk leak.
See the damage before you fix it:
| |
That RECLAIMABLE column is the wasted space. Three-quarters of those images aren’t backing anything running. The build cache alone is nearly ten gigabytes of nothing useful. The ACTIVE column is the reassuring one: those are the things actually in use, and they’re a small fraction of what’s on disk. The gap between TOTAL and ACTIVE is your housekeeping debt, expressed in gigabytes.
None of this is Docker-specific, incidentally. If you’ve moved to a daemonless runtime — and I made the case for running containers with Podman for exactly the reasons of control and rootlessness — the same accounting applies. podman system df and podman system prune mirror their Docker counterparts almost exactly, because the layered-image model and its associated cruft are properties of OCI images, not of the engine that runs them. Switch engines and you change the command prefix, not the discipline.
Pruning, carefully
The blunt instrument is docker system prune, but read the flags before you fire it — the difference between -a and not is the difference between tidying up and a bad afternoon.
| |
Plain docker image prune only removes dangling images — orphaned, untagged layers. That’s safe to run on a schedule. The -a variant removes every image not currently backing a container, which is great on a host where everything that matters is running, and a disaster on a host where you keep images around for quick rollbacks. Know which kind of host you’re on.
For a homelab box, a weekly cron job is plenty:
| |
The until=168h filter spares anything touched in the last week, so you don’t nuke an image you pulled yesterday but haven’t started yet. Mind that -a removes all unused images, not just dangling ones — that filter is your safety net, and on an -a prune you genuinely want it.
A word of warning that has bitten people badly: docker system prune does not touch named volumes by default, and that’s deliberate — your data lives there. If you add --volumes, it will remove any volume not attached to a running container, which on a host where you stop containers for maintenance is a spectacular way to delete a database. I do not run --volumes on a schedule, ever. If you need to reclaim volume space, do it by hand, deliberately, having checked exactly what you’re about to remove with docker volume ls -f dangling=true. Automated volume pruning is the one piece of housekeeping I’d tell you to never automate.
Verify what a prune would do before trusting it on a schedule by running it once interactively and reading the summary it prints — Docker tells you how much it reclaimed and what it removed. Watch one run, confirm it only took the things you expected, then commit the cron line.
Why :latest is a trap
Here’s the part people resist. :latest is not a version. It’s a mutable pointer that means “whatever the maintainer pushed most recently,” and it changes without telling you. Run myapp:latest on Monday and myapp:latest on Wednesday and you may be running two entirely different builds. When one of them breaks, you have no idea what changed, because the tag that’s supposed to identify the build identifies nothing.
The fix is pinning. Use a real version tag, and for the paranoid — production counts as paranoid — pin the digest, which is a content hash that can never point at anything else:
| |
Pinning by digest means the bytes you tested are the exact bytes that run, today and in six months. No surprise upgrade, no “it worked yesterday.” The cost is that you update deliberately — bump the tag, test, deploy — instead of drifting. That deliberate update is the whole point.
There’s a security dimension here that goes beyond stability. A digest is a content hash, which means it’s also an integrity check: if the bytes behind a digest ever changed — through a compromised registry or a tampered mirror — the digest wouldn’t match and the pull would fail. Pinning by digest is the floor; the next storey up is verifying that the image was signed by who you think built it, which is a separate discipline I covered in verifying container images with Sigstore and cosign. Pinning answers “is this the same image I tested?”; signing answers “did the right party build it?” You want both, and they compose neatly: pin the digest, verify the signature on that digest.
Tools to keep you honest
If manually bumping tags sounds tedious, that’s because it is, which is why Renovate and Dependabot exist. They watch your compose files and manifests, notice when a newer version of a pinned image is published, and open a pull request to bump it. You get the safety of pinning with the convenience of automated nudges — review the PR, merge when ready, and you’ve upgraded on purpose rather than by accident. It’s the best of both worlds and I run it on everything.
The detail that makes this genuinely powerful is that a good Renovate configuration doesn’t just bump the tag — it can update the pinned digest alongside it, and surface the release notes in the PR description so you’re reading the changelog before you merge, not after something breaks. You can even set policies: auto-merge patch-level bumps of images you trust, hold minor and major versions for human review. That turns the upgrade treadmill from a chore you forget into a queue you triage, which is the difference between a homelab that drifts quietly out of date and one that stays current without anyone heroically remembering to check. The point was never to avoid upgrading; it was to upgrade deliberately, and tooling makes “deliberate” cheap enough that you’ll actually do it.
When housekeeping goes wrong
The failure modes here are nearly all self-inflicted, which is the good news — they’re avoidable once you’ve seen them.
- You pruned and now an image you needed is gone. This is the
-aprune without anuntilfilter, run on a host where you keep old images for rollbacks. There’s no undo; you re-pull from the registry, which is fine if the tag still exists there and painful if it was a locally built image you never pushed. The lesson: push anything you’d cry over, and use theuntilfilter. prunereclaimed nothing despite a full disk. The space is held by something still referenced — a running container’s logs, a named volume, or images backing stopped-but-not-removed containers.docker system df -vbreaks it down item by item so you can see what’s actually holding the gigabytes. Frequently it’s container logs, not images at all, in which case the fix is a logging driver with rotation, not a prune.- A digest pin won’t pull. The image was removed or re-tagged at the registry, or you copied the digest wrong. Digests are unforgiving by design — one wrong character and there’s no fuzzy match. Re-fetch the current digest with
docker buildx imagetools inspect <image>:<tag>. - Renovate opened a PR that broke the build. Working as intended — that’s the PR catching a breaking upgrade before it reaches production, which is the entire value. Read the changelog, fix forward or close the PR, and be grateful it happened in CI rather than at 3am.
The verdict
This is housekeeping, not heroics, and that’s exactly why it’s worth doing. A weekly prune job costs you one cron line and saves you a midnight disk emergency. Pinning costs you a few seconds of typing a real version and saves you the special hell of debugging a regression you didn’t know you’d deployed.
If you self-host anything you’d be sad to lose, do both. Prune on a schedule with a sensible until filter, never automate volume pruning, pin everything in production to a version or a digest, and let Renovate or Dependabot handle the nagging. Who needs this discipline? Anyone running containers on a host they have to keep alive — which is everyone past their first afternoon of playing with Docker. Who can get away with skipping it? Someone on a throwaway box they’ll reimage next week, and honestly even they’ll thank themselves for pinning. It’s boring, it’s unglamorous, and it’s the difference between infrastructure that quietly works and infrastructure that surprises you. Surprises are for birthdays. Choose boring.




