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
ColonReplacementFormatenum has nosmartmember, 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 declaringspaceDashSpaceand calling it a “known minor divergence” — on a populated library that means reformatting every title with a colon. Moved naming tosetup-radarr.shinstead, following the repo’s own rule (Terraform where a provider exists,setup-*.shwhere it doesn’t). - Seerr:
seerr_metadata_settingsdrives/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_keyon 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
smartenum: added in devopsarr radarr 2.x. Pin was~> 1.7. Bumped to~> 2.4, restoredradarr_namingwithcolon_replacement_format = "smart", deleted thesetup-radarr.shworkaround (#44). - Prowlarr redacted
api_key: thelifecycle { ignore_changes = [api_key] }band-aid is unnecessary on prowlarr 3.x. Pin was~> 1.5; bumped to~> 3.2and dropped the lifecycle block (#44). - Seerr
/settings/metadata: the one genuine unreported bug. The typedseerr_metadata_settingsstill drives the singular path (404). Worked around withseerr_api_objectat/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-syncremoved (was outside the IaC entirely, on a branch the refactor had broken) -
terraform/prowlarrandterraform/seerrwired into the orchestrator with brownfield adoption (#44 pins, #45 wiring) - Delete
~/docker/media-stack-preNix-backup(614 MB) once confident - Fixed
setup-seerr.shdefault-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: livedefaultPermissionswent 32 to 58721184. The per-user/user/{id}/settings/mainPOST accepts its full GET body, so that block was left alone. - Upstream the one real provider bug: Josh-Archer/seerr
/settings/metadatasingular (unreported, provider 0.20.7)