Container Image Housekeeping: Pruning, Pinning, and Not Running latest in Production

The unglamorous discipline that keeps your container hosts sane

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. 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.

Advertisement

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:

$ docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          47        12        18.3GB    14.1GB (77%)
Containers      15        8         420MB     180MB (42%)
Local Volumes   23        9         6.2GB     3.1GB
Build Cache     312       0         9.8GB     9.8GB

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 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.

docker container prune          # remove stopped containers
docker image prune              # remove dangling (untagged) images only
docker image prune -a           # remove ALL images not used by a container
docker builder prune            # clear the build cache

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:

0 4 * * 0 docker system prune -af --filter "until=168h" >> /var/log/docker-prune.log 2>&1

The until=168h filter spares anything touched in the last week, so you don’t nuke an image you pulled yesterday. Mind that -a removes all unused images, not just dangling ones — that filter is your safety net.

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:

services:
  app:
    # Bad: a moving target
    # image: ghcr.io/acme/app:latest
    # Good: a fixed version
    image: ghcr.io/acme/app:1.8.2
    # Best: immutable by digest
    # image: ghcr.io/acme/app@sha256:9b2c...e41a

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.

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.

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, pin everything in production to a version or a digest, and let Renovate handle the nagging. It’s boring, it’s unglamorous, and it’s the difference between infrastructure that quietly works and infrastructure that surprises you. Choose boring.

Advertisement

Related Content

Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.