2026-06-27 Storage Cleanup ~208G Reclaimed

What I set out to do

Storage volumes were nearly full: a single APFS container at 97% with only 71G free on a 1.9T disk. Goal was to find and reclaim space.

What I actually did

Surveyed top consumers and worked through them. Reclaimed ~208G total (71G → 279G free, 97% → 85%):

  • Tool caches (~5.8G): playwright, pip, go-build, java-db — all regenerable.
  • Docker prune + raw auto-trim (~20G): docker system prune -a --volumes; modern Docker Desktop auto-trimmed Docker.raw 63G → 43G.
  • Media-stack duplicate downloads (~89G): removed 5 stopped One Piece torrents (episodes 1086–1143) via the qBittorrent API (POST /api/v2/torrents/delete deleteFiles=true), after confirming all 58 episodes were already imported into the tv library.
  • Nix store GC (~26G, 78G → 52G): only worked after granting the terminal App Management permission (see below).
  • FLUX models (~55G): dropped flux1-schnell, flux1-dev, and t5xxl_fp16 from ComfyUI — re-downloadable from HuggingFace. Kept the SDXL checkpoint and loras.
  • Project .venvs (~2.4G): removed 18 stale virtualenvs under ~/projects, recreatable with uv.

What was striking

Two non-obvious diagnoses:

  • The media-stack silently duplicates every import. A downloads file and its tv counterpart had distinct inodes, link count 1, identical size — proof of a full copy, not a hardlink. Cause: Sonarr/Radarr mount /downloads and /tv as separate bind mounts, so hardlinks can’t span them and the *arr falls back to copying. Compounded by qBittorrent’s max_ratio_act=0 (pause), which means torrents complete, pause, and are never removed — so ~/media/downloads refills indefinitely. The real fix is a single /data parent mount (TRaSH guides) plus a seeding policy that removes.

  • nix-collect-garbage aborted freeing 0 bytes on macOS because a dead store path held a .app bundle and App Management TCC blocks chmod on it (Operation not permitted). Granting Ghostty App Management + Full Disk Access and restarting it unblocked the GC. Same protection class as the Zotero copyApps issue. The generation-trim half still worked despite the store-GC abort.

Deferred

  • Structural single-/data remount for the media-stack so imports hardlink instead of copy.
  • qBittorrent seeding policy fix (max_ratio_act → remove, or Sonarr “Remove Completed Downloads”).

Media Stack Download Pipeline Recovery · captured findings to project memory (media-stack hardlink duplication, nix GC App Management block).


Design session: Media Stack IaC evaluation

Separate session, same day. Picked up the feature-opus-4-7-issue-32 branch (the Terraform-for-layer-2 PoC + RFC), rebased it onto origin/main, and used it as the springboard for a longer architecture discussion about declarative IaC for the whole media-stack. Tracked in the new project note Media Stack IaC Declarative Config Evaluation.

What unfolded

Started by reviewing the existing terraform-eval.md RFC (layer 2: partial migration to devopsarr providers viable; Bazarr/qBittorrent have no provider; recommendation is observe-then-decide). Then worked outward to layer 1 (currently mstack + Jinja) and asked how much a declarative system (NixOS / home-manager) actually solves there.

The thread that made it click:

  • Secrets reframing. Treating API keys as ephemeral, machine-minted, network-protected tokens (not durable artifacts to guard at rest) means secret generation can leave layer 1 entirely. That removes the impurity that made the static layer awkward for any hermetic tool. The cleanest form is letting each app self-mint its key on boot and reading it back, or Terraform’s random_* + provider-apply.
  • Prior-art survey. Deployment + static config is solved by Nix (arion, oci-containers, compose2nix). App-config is not: every project that tried to own it in a Nix module (declarative-arr, nixarr’s settings-sync) reduces to API reconciliation and is abandoned or flagged unstable. declarative-arr’s own README: “they all work via sending api requests to mimic a user configuring them,” and it explicitly can’t do custom formats.
  • The payoff. This stack is already ahead of that ecosystem because Recyclarr owns quality profiles / custom formats — the exact thing the Nix modules can’t. So the right architecture is delegating: Nix deploys + generates static, Recyclarr owns quality, a thin scoped Terraform/curl reconciler owns the rest. Not one module absorbing everything.

Wrote it up as docs/rfcs/nix-orchestration-eval.md (committed 07b0e27, emdashes scrubbed per house style). Closed the stale PoC PR #33 and renamed the branch to feature/iac-declarative-config.

What was striking

The Infrastructure as Code note I’d already written predicted the whole conclusion: layer first, one writer per object, never bake mutable app state into a Nix closure. The *arr apps storing config in app-owned SQLite is exactly the “mutable app state” anti-pattern, which is why file-based declarative config of them is impossible and everyone falls back to API reconciliation. The mutable-file ownership conflict that would otherwise block home-manager is already solved here by the mkComposeDeploy activation pattern (Homelab Services Architecture).

Open threads

  • The Terraform Sonarr PoC still needs ~1 week of observation before the migrate-vs-keep call.
  • Layer-1 decision (slimmed mstack vs home-manager generation) hinges on whether the repo must stay host-agnostic for other people.

Media Stack IaC Declarative Config Evaluation · Infrastructure as Code · Terraform · Homelab Services Architecture

Update: built the Nix Sonarr PoC

Went from analysis to a working artifact. The Nix evaluation was lopsided (Terraform shipped a PoC with parity evidence; Nix was prose only), so I built the parallel PoC: a shared pure config.xml renderer feeding a runnable home-manager backend and a NixOS sketch (the backend seam). The activation seam (generate mint-or-read secret seed writable host facts tofu apply against terraform/sonarr) evaluates and builds; a flake check proves the Nix render matches the mstack template byte-for-byte. Committed f29d87d on feature/iac-declarative-config.

The satisfying part: the “can I build a NixOS module and run it on my Mac?” question resolved cleanly. No - NixOS modules compile to systemd, which macOS can’t run - but the macOS-native equivalent (a home-manager module) gives the same declarative-module authoring with a Docker-on-mac backend, and a shared renderer keeps a future NixOS host one thin module away. Tracked in Media Stack IaC Declarative Config Evaluation.

Update: converted the entire mstack config generation to Nix

Pushed the Nix PoC further: ported all 8 mstack templates to pure Nix renderers and proved byte-for-byte parity against the real mstack Jinja Environment (a Python oracle that replicates StrictUndefined/autoescape=False/default trailing-newline). config-parity check: 9/9 identical (nginx rendered both auth-off and auth-on).

Two Jinja gotchas the oracle earned its keep on: keep_trailing_newline defaults False (outputs have no trailing newline - my first Sonarr check was diffing the raw .j2 and was quietly wrong), and the {% if %} blocks emit predictable blank lines. The sharper finding: byte parity requires text-templating, not pkgs.formats - the templates are hand-authored text with holes, so pkgs.formats would reformat them. That corrects the RFC’s “4 of 8 native” optimism.

Annoying tail: adding Python files tripped the project-wide ty hook, surfacing a pre-existing environment.py type error (fixed separately), and the hook’s uv run keeps rewriting uv.lock with an env exclude-newer - the mystery stray uv.lock change from the very start of today. And .gitignore’s Python lib/ rule was silently swallowing nix/lib/, so the renderers nearly didn’t commit. Caught it via git ls-files. Commits 89a4eb1 + ae66eff on Media Stack IaC Declarative Config Evaluation.