Skip to main content
flotsam — cover

Featured work

flotsam

Docker disk cleanup that knows which project each leftover came from — a project→directory index, honest layer maths and a dry run by default.

  • Go 1.25
  • Docker SDK v28
  • SQLite (modernc.org)
  • Cobra
  • Vue 3 + Vite
  • GitHub Actions
  • ghcr.io multi-arch

The problem

docker system df will happily tell you that 84 GB are gone. It cannot tell you the only thing that matters before you delete anything:

This volume — which project is it from, and can I remove it without losing data?

Once a project’s containers are gone, its volumes become orphans. They still carry a com.docker.compose.project label, but nothing anywhere records where that project lived on disk — and Docker cannot reconstruct the link afterwards. So the choice degrades to docker system prune -a (delete everything and hope) or leaving tens of gigabytes untouched for years.

Flotsam is what floats up after a ship goes down. That is exactly what this tool collects.

What it knows that Docker doesn’t

flotsam keeps its own index: project → directory, stored in SQLite (pure Go, no CGO) and filled passively on every run from the com.docker.compose.project.working_dir label — that is, while the containers still exist. Months later, after the containers are long gone, the index still answers:

🟢 legacy-crm                                     12.4 GB   ⚠ directory is gone
   /home/v/work/legacy-crm  (last seen 2025-11-03, source: index)
   ├─ volume  legacy-crm_pgdata          8.1 GB   postgresql   🟡 contains database data
   ├─ volume  legacy-crm_uploads         3.9 GB   unknown-data 🟢 project directory gone
   └─ image   legacy-crm-app:latest       340 MB               🟢 project directory gone

Resolution is a chain with an explicit “unknown” at the end: live label → index → filesystem scan → unknown. And a read-only probe mounts the volume to say what is actually inside it — postgres, mysql, mongo, redis or unrecognised data — because “8 GB of something” is not a basis for a decision.

Honest reclaim maths

Deleting five images does not free the sum of their sizes: they share layers. flotsam builds a layer graph and counts a layer as reclaimable only when every reference to it lies inside the set you are about to delete, with images pinned by running containers excluded.

The interesting part was matching ImageHistory entries to RootFS.Layers. The obvious rule — pair up the history entries with Size > 0 — matched only 55 % of images on a real host. The reason is that WORKDIR creates a genuine layer of zero size. Classifying each entry by its CreatedBy instead (RUN/COPY/ADD/WORKDIR produce a layer; ENV/CMD/ENTRYPOINT do not; #(nop) does not), plus a relaxed pass for images built by bazel/ko, took it to 99.4 % — 333 of 335 images matched exactly.

The risk model

Classification is 18 ordered rules, first match wins, and anything that matches nothing at all defaults to risky rather than to “probably fine”:

LevelMeaning
🟢safeProject directory is gone, dangling image, empty volume, long-stopped container
🟡probably-safeProject dormant > 30 days, recent unused image, recent build cache
🔴riskyVolume holds database data, project is active, unknown origin, ambiguous path
neverProtected by config, volume attached to a container, image of a running container

risky and never never enter a plan, whatever flags you pass. A red object can only be removed one at a time, by name, with --force-risky.

Safety guarantees

  1. apply is a dry run by default; real deletion needs --no-dry-run.
  2. A volume with RefCount > 0 is never deleted — not even with --force-risky.
  3. The inventory is re-collected immediately before deleting; anything that changed meanwhile is skipped with a warning.
  4. No docker system prune -a — only targeted deletions by ID.
  5. Nothing outside Docker is ever modified: not your project directories, not the VM disk image. flotsam host prints the commands to compact Docker.raw / ext4.vhdx; you run them.
  6. Every operation is appended to ~/.local/share/flotsam/history.jsonl.
  7. A directory counts as “gone” only when its parent is visible — an unmounted subtree is never mistaken for a deleted project.

That last rule is not theoretical. On macOS and Windows, -v /:/host:ro mounts the root of the Docker Desktop virtual machine, not your disk: /host/Users exists and is empty. Without the parent-visible rule, the containerised build called 8 of 12 live projects orphans. flotsam now detects the situation, marks the host filesystem unverifiable, refuses to call anything an orphan, and says so — instead of inviting you to delete live data.

Shipping

One static binary per platform, six of them (linux/darwin/windows × amd64/arm64), built by GitHub Actions on a v* tag with checksums.txt attached to the release; a 22 MB multi-arch image on ghcr.io; and a Vue 3 SPA embedded into the binary, so go build needs no Node — CI fails if the committed bundle drifts from its sources. Everything the CLI does is available in the browser at 127.0.0.1:7333, token-guarded, with Host/Origin validation and no cookies.

Lessons

  • The valuable data was never in Docker: it was the project→directory link, and it had to be captured before it was needed. A passive index costs nothing per run and is the entire feature.
  • A benchmark from a spec is a hypothesis. The documented layer-matching heuristic worked at 55 % on real images; only the CreatedBy classification made the “how much will this actually free” number honest.
  • When the tool cannot verify something — a host filesystem it cannot see — the correct output is a refusal, not a best guess. Deletion tools do not get to be optimistic.