2026-07-14 Media Stack Nix Cutover

What I set out to do

Merge the IaC PR (#40), cut the live media stack over from the old host-side mstack/Jinja renderer to the declarative Nix home-manager seam, then converge layer 2.

What I actually did

Merged #40 as a merge commit (preserving its five bisectable steps). Then, before touching the live stack, ran a fidelity check: built the generated config set and diffed it against five days of working config. That check paid for itself immediately.

Found a regression (#42). bazarr and qbittorrent are LinuxServer images that nest their config one level below the bind mount, but the Nix fileMap seeded them flat. The generated content was byte-perfect; only the destination was wrong, which is exactly why nothing caught it. On a fresh deploy qBittorrent would have started with no WebUI subnet allowlist and no seed-ratio limit. Sharpest irony: the module has a hard eval-time guard that subnet must equal the compose ipam subnet because the WebUI allowlist depends on it — then wrote that allowlist where qBittorrent couldn’t read it.

Cutover, with three hazards defused up front: pre-seeded $stateDir/secrets/<svc>.key from the live keys (or all five would have been re-minted while seed-if-absent left the configs on the old ones — 401s everywhere); copied 614 MB of live state to the new deployDir (compose bind-mounts ./config relative to it, so a fresh dir means an empty Sonarr); and held autoStart off, since its compose up --remove-orphans would have deleted list-sync.

Layer 2 (#43). Had never been run against a real, already-configured stack. Every resource already existed, so a bare apply wanted to create them all — I imported them into state first. That exposed three genuine upstream provider defects:

  • Radarr naming: the devopsarr provider’s ColonReplacementFormat enum has no smart member, but TRaSH recommends Smart Replace and Radarr’s API returns exactly that. The provider fails on read, so the resource can’t even be imported. The module had “worked around” this by declaring spaceDashSpace and calling it a “known minor divergence” — on a populated library that means reformatting every title with a colon. Moved naming to setup-radarr.sh instead, following the repo’s own rule (Terraform where a provider exists, setup-*.sh where it doesn’t).
  • Seerr: seerr_metadata_settings drives /api/v1/settings/metadata, which 404s — the provider targets an Overseerr-era API that Seerr dropped.
  • Prowlarr: apps error on every apply because Prowlarr redacts api_key on read and the provider compares its sent value against the mask. The write lands; only the read-back assertion fails.

End state: home-manager switch is the single command. It vendors, seeds layer 1, brings the stack up, and the orchestrator runs layer 2 to exit 0 — All services configured successfully, with both Terraform modules reporting No changes.

What was striking

The deploy dir was the git checkout, and that coupling was hiding things. The repo’s gitleaks --no-git hook scans the filesystem, ignoring .gitignore, so it had been failing locally on 76 real API keys sitting in config/. CI never saw it — a fresh clone has no runtime state. I nearly reached for SKIP=gitleaks and got blocked. The actual fix was the migration itself: decouple source from deployment and the scanner goes quiet honestly. A hook failure that looked like noise was pointing straight at the architectural problem.

Terraform found bugs nobody was looking for. Sonarr’s naming diff revealed the live format string had inverted brackets ({[Mediainfo AudioCodec}{ ...]}) and a typo — Vide**e**BitDepth. Both tokens fail silently, which is why they’d survived. The declarative rewrite surfaced them as a diff.

A provider’s enum gap is a real architectural constraint, not a papercut. The tempting move was to accept spaceDashSpace and let TF own naming. But that trades a TRaSH-recommended setting for tooling convenience, and rewrites filenames Plex has matched. The right answer was to let the provider lose that resource entirely.

extraSpecialArgs vs _module.args. Referencing a flake input from _module.args inside imports is infinite recursion — module args derive from config, which isn’t available while imports resolve. Also: github: can’t fetch a private repo (unauthenticated 404); git+ssh reuses the keys git already has.

Update: two of the “three provider bugs” were stale pins

Revisiting the three provider defects before deleting the modules, two of them evaporated once the pins were bumped — they were never provider bugs, just versions years behind:

  • Radarr smart enum: added in devopsarr radarr 2.x. Pin was ~> 1.7. Bumped to ~> 2.4, restored radarr_naming with colon_replacement_format = "smart", deleted the setup-radarr.sh workaround (#44).
  • Prowlarr redacted api_key: the lifecycle { ignore_changes = [api_key] } band-aid is unnecessary on prowlarr 3.x. Pin was ~> 1.5; bumped to ~> 3.2 and dropped the lifecycle block (#44).
  • Seerr /settings/metadata: the one genuine unreported bug. The typed seerr_metadata_settings still drives the singular path (404). Worked around with seerr_api_object at /api/v1/settings/metadatas (#45) — no fork. Still to upstream (one-char fix, provider 0.20.7).

Then wired both remaining modules into the orchestrator (#45) with a tf_adopt() brownfield-import helper, so Terraform can own resources the setup-*.sh scripts already created without duplicating them. Verified by wiping Prowlarr state and re-adopting: still 1 tag / 1 proxy / 2 apps, no dupes. Deleted ~470 lines of shell (setup-seerr 495→305, setup-prowlarr 537→320).

Post-merge verification (after re-running home-manager switch off merged main): 12/12 containers healthy, all 4 TF modules No changes, library intact (23 series / 6 movies, colon smart both), Seerr metadata tvdb, 1 Sonarr + 1 Radarr server each.

New latent bug found during verification: setup-seerr.sh’s default-permissions step POSTs the whole /settings/main object back to change one field; Seerr 400s it, but || echo "{}" makes the validator pass, so it logs success on a failed write. Live defaultPermissions is 32 (REQUEST), not the intended 58721184. Harmless now (only user is admin, implicit-all-perms, watchlist sync applied via the per-user loop); would misconfigure future non-admin users. Pre-existing, not a #45 regression. Fix: PATCH just {defaultPermissions} and honor the 400.

Open

  • Cutover to the Nix seam; #42 (nested config paths) merged
  • Layer 2 converged; #43 merged; autoStart = true
  • list-sync removed (was outside the IaC entirely, on a branch the refactor had broken)
  • terraform/prowlarr and terraform/seerr wired into the orchestrator with brownfield adoption (#44 pins, #45 wiring)
  • Delete ~/docker/media-stack-preNix-backup (614 MB) once confident
  • Fixed setup-seerr.sh default-permissions silent failure (#46): POST minimal {defaultPermissions} (the full-object POST 400s on read-only apiKey/initialized/defaultQuotas), and branch on the request exit status instead of the || echo {} sentinel that faked success. Verified in-network: live defaultPermissions went 32 to 58721184. The per-user /user/{id}/settings/main POST accepts its full GET body, so that block was left alone.
  • Upstream the one real provider bug: Josh-Archer/seerr /settings/metadata singular (unreported, provider 0.20.7)

2026-07-13 Anime Remuxes Were a Live-Action Size Floor