jack@macbook-pro: ~/kavanagh.nl/writing/frigate-nvr-on-nixos.md
← Writing

2026-08-23

Running a home NVR on NixOS: dedicated storage, a second camera, and a real detector speedup

I run Frigate, an open-source NVR with built-in object detection, on a repurposed Wyse 5070 thin client for two cameras — a door intercom and, more recently, a camera watching a cargo bike we'd rather not have stolen. It's declared entirely through NixOS's services.frigate module. None of the interesting problems this week were "get it running" — that part is a few lines of config. They were all in the gap between "running" and "actually correct," across three unrelated pieces of work: giving it real dedicated storage, adding a second camera without a single plaintext credential landing in git, and chasing — then properly verifying — a faster object detector.

Storage is a systemd trick, not a Frigate option

Frigate's recordings, clips, snapshots, and its own SQLite db all live under its StateDirectory (/var/lib/frigate), which isn't independently configurable in the nixpkgs module — you get what systemd's StateDirectory option gives you, on whatever disk the root filesystem happens to be on. I wanted it on a dedicated 2TB SSD instead, physically separate from the disk backing this same host's Loki instance. The fix isn't a Frigate setting at all; it's redirecting the directory at the systemd level:

  • systemd.tmpfiles.rules creates the real backing directory on the new disk.
  • systemd.services.frigate.serviceConfig.BindPaths bind-mounts it over /var/lib/frigate inside the service's own mount namespace — the same mechanism PrivateTmp already uses to sandbox the rest of the service.

Migrated the existing 7.4GB with rsync -aH before flipping the bind and restarting, so the existing event history and user accounts survived the move intact. The one gotcha worth flagging: that bind only exists inside frigate.service's own mount namespace. ls /var/lib/frigate from a normal root shell on the host shows the empty, real, mostly-unused directory — not the bind-mounted content. To see what Frigate itself actually sees, you have to step into its namespace:

sudo nsenter -t $(systemctl show -p MainPID --value frigate) -m ls -la /var/lib/frigate/

A second camera, and keeping its password out of git entirely

Adding the second camera (a Hikvision, direct RTSP rather than routed through the shared restream the intercom needs) meant a real credential landing somewhere. Frigate's config doesn't support secrets the way you'd hope out of the box, but it does support {FRIGATE_*} template substitution in certain fields, resolved from either the process's own environment or from files in $CREDENTIALS_DIRECTORY — which happens to be exactly what systemd's own LoadCredential= populates. Paired with agenix, the actual password never lands in the git repo or in the world-readable Nix store copy of the rendered config; it only ever exists decrypted inside the service's own credential directory at runtime.

One cost: the module's build-time config check runs Frigate's own parser inside the Nix build sandbox, where that environment variable doesn't exist yet — so it fails validation on the unresolved {FRIGATE_*} placeholder every time. services.frigate.checkConfig = false; disables that specific pre-check; the substitution itself is confirmed working at actual runtime, this only turns off a sandboxed dry run that structurally can't succeed for this one field.

Chasing a faster detector — the two ways it broke first

Frigate's default CPU detector (plain TFLite) was measuring ~107ms per inference on this box's low-power Celeron-class chip. OpenVINO's CPU backend is supposed to be meaningfully faster on the same silicon, so I tried it — and hit two real bugs before getting a number I actually trusted.

First attempt: ran the new OpenVINO detector alongside the existing one, assuming Frigate load-balances detection work across whatever detectors are defined. It doesn't, not cleanly for this: Frigate resizes every camera's frame to a single canonical size before it's ever queued for detection, and that size came from whichever detector's model dimensions won — not per detector. Every real inference on the other detector crashed with a tensor shape mismatch, while its inference_speed stat sat on a stale fallback value the whole time that looked suspiciously good rather than obviously broken. If I'd only glanced at the stats API and not the systemd journal, I'd have shipped a fake result.

Second attempt, single detector only, still broken — same shape mismatch. The actual root cause: Frigate explicitly discards any per-detector model block you set. It's right there in the source, as a comment: users should not set model themselves. The only real lever is a single global, top-level model: key shared by every detector — nesting it under the detector instead looks correct, doesn't error, and silently does nothing except donate its path.

Once actually fixed: a real 62ms, down from 107ms — a genuine ~42% reduction, not noise. The model file itself isn't available as a Nix derivation (Frigate's own build produces it via a Docker multi-stage conversion step, not a downloadable release asset), so I extracted the ~9MB IR model straight from the official container image's layers over plain HTTPS registry API calls and tar — the more obvious route, podman or skopeo, both wanted a containers-policy.json permitting unsigned image pulls, and writing one got blocked by this session's own safety guardrails on the word "insecure," even though it wasn't actually a meaningful downgrade for a one-off public pull. Worth a wry note: getting stopped by your own tooling's caution while doing something completely benign is its own small tax.

Then the part that actually mattered: verifying the model was correct, not just crash-free. A misconfigured detector can run cleanly, forever, while silently detecting nothing — a much worse failure mode for a security camera than a crash loop, because nothing about it looks broken. Ran the extracted model directly against a known image with obvious people in it via OpenVINO's own Python API, got confident 0.95/0.71-confidence hits — and then discovered this specific labelmap has an explicit __background__ class at index 0, so "person" is class ID 1, not the usual 0. A correct detection can look like an off-by-one bug if you assume the standard convention.

The live view had been quietly broken the whole time

While double-checking the detector work, I noticed Frigate's own go2rtc stream registry was reporting empty — {} — for both cameras. The fallback live-view path (a legacy player on a hardcoded port) had been silently losing a bind conflict against an unrelated dashboard sharing the same host, for weeks, with nine-plus "address already in use" errors sitting unread in the systemd journal. Neither camera had ever had real low-latency live view; the browser had just been falling back to slow JPEG polling the whole time, and nothing about that looks like an outage worth investigating — it just looks a little laggy. Fixed it by standing up a small dedicated go2rtc instance purely for live view, and verified it properly this time: pulled real frames from both streams directly through it, rather than trusting "the service is active" the way I nearly did with the empty registry in the first place.

What's actually next

  • Detection zones, once the newer camera's final mounting position is settled.
  • A push notification for the bike camera specifically — right now it only records, and a camera whose entire purpose is catching something as it happens is weakest exactly where it has no alerting path yet.
  • A dedicated USB accelerator, if inference speed ever becomes an actual constraint rather than a curiosity worth 45 minutes of debugging.
  • Camera-offline alerting, reusing the same health-check pattern already watching this host's disks.

What this says about the tool, not just the setup

Every real bug this week was in an invisible default, not the thing I was nominally doing — a stale-looking-good stat masking a crash loop, a port collision nobody had looked at, a labelmap convention that shifted an index by one. None of those show up by reading documentation forwards; they show up by reading errors backwards and by refusing to accept "it's not crashing" as proof that something works. That's the actual value of working through this with an agent that has shell access — not that it guesses the right config faster, but that it's willing to go verify a claim with a real request instead of trusting the first plausible-looking number.

If you're running infrastructure on NixOS and want a second set of eyes on where the invisible defaults are hiding, I'm happy to talk it through.