2026-06-29 Media Stack Single-Command Deploy

Context: Working on Media Stack IaC Declarative Config Evaluation (branch feature/iac-declarative-config).

What I set out to do

Answer a design question on the media-stack Nix seam: “why can’t the activation script bring up the stack as an idempotent check like signoz does, then apply the remaining items?”

What I actually did

Took it in two steps, each landing a commit.

  1. autoStart (2f38b23) — I’d earlier overstated that the host seam can’t start the stack. It can. Added an autoStart option: after layer-1, run an idempotent docker compose up -d, guarded on docker info so a stopped Docker Desktop skips cleanly instead of failing the switch. Layer-2 deliberately stays with the orchestrator sidecar (the host genuinely can’t reach the unpublished *arr ports, and blocking switch on full-stack health is fragile).

  2. Vendored deploy dir (fd61483) — the follow-up question “why must I pass projectDir?” exposed the real fix. The module’s own path is a read-only /nix/store copy (wrong dir to run compose from), but the deployment location is a fact the module can own — exactly the signoz vendored-compose pattern. Dropped the four path options (configRoot/composeSecretsDir/envFile/projectDir) for one deployDir (default ~/.local/share/media-stack). On switch the module vendors the runtime stack out of the flake source (compose, scripts/, terraform/ modules, static config), seeds layer-1 + .env alongside, and runs compose there. Module is now a factory closed over self. Consumers import the module and never clone the repo.

What was striking

  • The good answer came from following the user’s pushback, not my first design. projectDir was a smell; “why am I passing this?” → the module should own the location, like signoz already does for them. Consistency with their existing infra was the tell.
  • rsync discipline matters: --delete for scripts/terraform modules, but --exclude tfstate so the orchestrator’s state survives a redeploy; config overlay without --delete and excluding generator-owned paths so the seam stays authoritative.
  • Verified the deploy by actually assembling the tree in a scratch dir, not just eval — caught nothing broken, but it’s the kind of thing eval won’t tell you.

What was notable / still open

This closes the deploy layer (compose→Nix), so all three layers are now convertible end-to-end in the repo. The one persistent unknown is unchanged: none of it has run against a live home-manager switch — everything is verified in isolation.

Session 2: CI + Nix/Terraform lint hardening

Follow-on the same branch. The repo had prek (pre-commit) hooks locally but CI never ran them — only nix flake check + tofu validate gated PRs, so shellcheck/yamllint/actionlint/codespell could rot.

  • Added a CI pre-commit job that runs prek run --all-files inside nix develop, so the language-agnostic hooks and the new toolchain hooks all run in one job with their tools on PATH.
  • Added Nix gates: nixfmt (adopted the RFC-official formatter — one-time ~430-line reformat of all 5 hand-tuned files), deadnix, statix. Plus tofu fmt for Terraform. All as local pre-commit hooks backed by the devShell.
  • statix surfaced ~6 real nits (useless parens, assign-vs-inherit, repeated dotted keys). Fixed each at the source — nesting the repeated home.* / lib.* keys into single blocks removed the repeated-key warnings cleanly, so no statix.toml global-disable was needed.
  • Kept it DRY: pre-commit owns style/lint, the existing flake check / tofu validate jobs own correctness. No double-checking. just prek + new just fmt now wrap nix develop so local == CI.

Notable: the devShell pins nixfmt-1.2.0 (repo nixpkgs) while I’d formatted with 1.3.1 (system channel) — verified they agree by running the hooks through the devShell, the exact path CI takes. All 16 hooks green; nix flake check green.