Skip to main content
  1. Posts/

Frigate NVR Setup: From Docker to HA Notifications

Author
Yang Hu

Setting up Frigate NVR on a dedicated Debian server (Intel N97) to replace a traditional NVR. Covers Docker compose, go2rtc stream config, hardware acceleration, HA integration, push notifications, and zone-based alerting. Traffic between VLANs goes through the main router (UCG Ultra).

Hardware & Context
#

  • Server: Intel N97 mini PC (debian.lan, 10.0.10.11), Debian 13
  • Cameras: 8 Reolink PoE cameras on camera VLAN (10.0.40.0/24), 2 Nanit monitors on IoT VLAN (10.0.20.0/24)
  • Existing NVR: kept running for continuous recording; Frigate handles detection and event clips only
  • Home Assistant: on IoT VLAN (10.0.20.10), MQTT broker already running

Storage Design
#

Frigate recordings go to a dedicated NAS share — no local NVMe waste for surveillance video.

On Synology DSM: create shared folder surveillance, NFS export to 10.0.10.11 with Map all users to admin squash. This avoids UID mismatch issues since the Frigate container UID (1001) has no corresponding NAS user.

Mount on debian:

1
2
sudo mkdir -p /mnt/nas-surveillance
sudo mount -t nfs 10.0.10.10:/volume1/surveillance /mnt/nas-surveillance

Add to /etc/fstab:

1
10.0.10.10:/volume1/surveillance /mnt/nas-surveillance nfs vers=3,rw,_netdev,nofail,soft,rsize=65536,wsize=65536,timeo=300 0 0

Docker Compose
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# /home/yang/docker/frigate/docker-compose.yml
services:
  frigate:
    container_name: frigate
    image: ghcr.io/blakeblackshear/frigate:stable
    restart: unless-stopped
    shm_size: "256mb"
    devices:
      - /dev/dri/renderD128:/dev/dri/renderD128
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ./config:/config
      - /mnt/nas-surveillance/frigate:/media/frigate
    ports:
      - "5000:5000"
      - "8554:8554"
      - "8555:8555/tcp"
      - "8555:8555/udp"
    env_file: docker-compose.env
1
2
3
4
5
# docker-compose.env
FRIGATE_RTSP_PASSWORD=...
FRIGATE_DOORBELL_PASSWORD=...   # doorbell has a different password
FRIGATE_MQTT_USER=...
FRIGATE_MQTT_PASSWORD=...

Credentials are referenced in config.yml as {FRIGATE_VARIABLE_NAME} — Frigate resolves these from container environment variables, keeping passwords out of the config file itself.

shm_size: 256mb — shared memory for frame buffers. 8 cameras at 5fps detection streams needs roughly 20–30 MB; 256 MB is a safe headroom.

/dev/dri/renderD128 — Intel iGPU for hardware-accelerated video decoding (VAAPI). No privileged: true needed; passing the device directly is sufficient.


config.yml
#

MQTT & Hardware
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
mqtt:
  enabled: true
  host: 10.0.20.10       # HA's IP (IoT VLAN)
  port: 1883
  user: "{FRIGATE_MQTT_USER}"
  password: "{FRIGATE_MQTT_PASSWORD}"

ffmpeg:
  hwaccel_args: preset-vaapi

detectors:
  ov:
    type: openvino
    device: GPU

model:
  path: /openvino-model/ssdlite_mobilenet_v2.xml
  labelmap_path: /openvino-model/coco_91cl_bkgr.txt
  width: 300
  height: 300

ffmpeg vs detector acceleration: preset-vaapi handles video decoding (H.264/H.265 frames → raw pixels) using the Intel iGPU’s media engine. The OpenVINO detector handles inference (running the object detection model) using the iGPU’s execution units. Both run on the same Intel N97 iGPU but use different hardware blocks, so they coexist without contention.

Why OpenVINO over CPU? The N97 supports OpenVINO natively. OpenVINO inference on GPU is 3–5× faster than CPU, at no hardware cost. The default model (ssdlite_mobilenet_v2) is the same — only the execution backend changes.

Model path must be at the top level. The model: block is global config, not nested under detectors. Putting path inside detectors.ov.model silently results in a None path and a startup crash.

MQTT host is HA’s IP (10.0.20.10). Traffic routes through the UCG Ultra.

Recording
#

1
2
3
4
5
6
7
8
record:
  enabled: true
  alerts:
    retain:
      days: 7
  detections:
    retain:
      days: 7

No retain.days at the top level means no continuous recording — only event clips are saved. The existing NVR handles 24/7 recording; Frigate stores short tagged clips around detection events. These are complementary, not duplicates.

go2rtc Streams — Pitfall
#

Frigate uses go2rtc internally for stream management. Each camera needs two named streams: one for recording (main, high-res) and one for detection (sub-stream, low-res). The key mistake to avoid:

Wrong — both streams bundled under one name:

1
2
3
4
5
go2rtc:
  streams:
    front:
      - rtsp://admin:pass@10.0.40.x:554/h264Preview_01_main
      - rtsp://admin:pass@10.0.40.x:554/h264Preview_01_sub

go2rtc treats multiple sources as fallbacks — it picks one. Referencing front_sub elsewhere returns 404.

Correct — separate named streams:

1
2
3
4
5
6
go2rtc:
  streams:
    front:
      - "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.40.x:554/h264Preview_01_main"
    front_sub:
      - "rtsp://admin:{FRIGATE_RTSP_PASSWORD}@10.0.40.x:554/h264Preview_01_sub"

Then cameras reference them:

1
2
3
4
5
6
7
8
cameras:
  front:
    ffmpeg:
      inputs:
        - path: rtsp://127.0.0.1:8554/front_sub
          roles: [detect]
        - path: rtsp://127.0.0.1:8554/front
          roles: [record]

Reolink RTSP URLs#

Standard Reolink cameras:

  • Main: rtsp://admin:pass@IP:554/h264Preview_01_main
  • Sub: rtsp://admin:pass@IP:554/h264Preview_01_sub

Newer models (CX810) use H.265:

  • rtsp://admin:pass@IP:554/h265Preview_01_main

Reolink Duo has two lenses — Preview_01 and Preview_02. In practice only one stream is usefully exposed; treat it as a single camera.

Camera Config
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cameras:
  front:
    ffmpeg:
      inputs:
        - path: rtsp://127.0.0.1:8554/front_sub
          roles: [detect]
        - path: rtsp://127.0.0.1:8554/front
          roles: [record]
    detect:
      enabled: true      # must be explicit — defaults to false in 0.17
      width: 640
      height: 360        # match actual sub-stream resolution
      fps: 5

detect.enabled: true is required. In Frigate 0.17 it defaults to false. When streams fail at startup (e.g. before go2rtc connects), Frigate auto-disables detection and does not re-enable it — even after streams recover. Always set it explicitly.

Match width/height to the actual sub-stream resolution. Frigate resizes frames to this resolution before running detection. A mismatch causes stretching which distorts object shapes and hurts detection accuracy — especially for distant or small subjects. Use ffprobe on the sub-stream to check:

1
2
3
4
docker exec frigate /usr/lib/ffmpeg/7.0/bin/ffprobe \
  -v error -select_streams v:0 \
  -show_entries stream=width,height -of csv=p=0 \
  rtsp://127.0.0.1:8554/front_sub

My camera sub-stream resolutions:

CameraSub-streamNotes
Reolink standard (front, backyard, side_a/b, cx810)640×36016:9
Reolink E1640×360PTZ — no zones
Reolink doorbell480×640Portrait orientation — width/height swapped
Reolink Duo (duo_a)1536×576Ultra-wide 8:3; detect at 1280×480

Detection Tuning
#

Default thresholds: min_score: 0.5, threshold: 0.7. The threshold is the running average confidence across the tracking window — intermittent low-confidence detections may not reach it.

For wide-angle or distant cameras, lower thresholds and higher detect resolution help:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  duo_a:
    detect:
      enabled: true
      width: 1280    # default 640 — more pixels for distant subjects
      height: 720
      fps: 5
    objects:
      filters:
        person:
          min_score: 0.45
          threshold: 0.55

Zones & Alerting
#

Frigate 0.17 splits events into alerts (high-priority) and detections (low-priority). By default, all person and car detections anywhere in frame are alerts. Zones let you restrict this.

How it works
#

  • Zone polygons are drawn in the Frigate UI (visual editor) and saved to config.yml automatically
  • A zone’s objects list restricts which labels activate it
  • loitering_time requires an object to remain in the zone for N seconds before the zone is considered “active”
  • required_zones under review.alerts gates which events become alerts

The correct placement for required_zones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
cameras:
  duo_a:
    review:
      alerts:
        required_zones:
          - frontyard-parking-zone
          - person-driveway-loister-zone
    zones:
      frontyard-parking-zone:
        coordinates: "..."
        loitering_time: 180   # 3 minutes
        objects:
          - car
      person-driveway-loister-zone:
        coordinates: "..."
        loitering_time: 30    # 30 seconds
        objects:
          - person

required_zones goes under cameras.<name>.review.alertsnot under objects.filters (that key doesn’t exist and causes a config error).

Result
#

EventOutcome
Car passing throughDetection only, no alert
Car parked in zone 3+ minAlert
Person walking throughDetection only, no alert
Person lingering in zone 30+ secAlert

Home Assistant Integration
#

Frigate Integration
#

Install via HA: Settings → Integrations → Add → Frigate

Point it at Frigate’s main LAN IP: http://10.0.10.11:5000

Push Notifications
#

HA automation that fires on new person detection with a snapshot:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
alias: Frigate person notification
triggers:
  - trigger: mqtt
    topic: frigate/events
conditions:
  - condition: template
    value_template: >
      {{ trigger.payload_json.type == 'new' and
         trigger.payload_json.after.label == 'person' and
         trigger.payload_json.after.camera in ['doorbell', 'e1'] }}
actions:
  - delay: "00:00:02"
  - action: notify.mobile_app_yangs_iphone
    data:
      title: "{{ trigger.payload_json.after.camera | title }}: Person detected"
      message: "{{ now().strftime('%H:%M') }}"
      data:
        image: "http://10.0.10.11:5000/api/events/{{ trigger.payload_json.after.id }}/snapshot.jpg"
        url: "http://frigate.tailnet:5000/review?camera={{ trigger.payload_json.after.camera }}"
        push:
          sound:
            name: default

Why type == 'new' not type == 'end'? Triggering on end gives a complete clip but delays the notification — and never fires if the person stays in frame indefinitely. new fires immediately; the 2-second delay gives Frigate time to generate the first snapshot.

The snapshot URL uses Frigate’s LAN IP (10.0.10.11); HA fetches it via the main router. The tap URL uses the Tailscale hostname for remote access.


Lessons
#

  • go2rtc stream names must be unique per stream quality. Bundling main+sub under one name makes the sub stream unreachable via go2rtc’s RTSP server.

  • detect.enabled defaults to false in Frigate 0.17. Set it explicitly in every camera config. If streams fail at startup, Frigate disables detection and doesn’t recover automatically.

  • required_zones belongs under review.alerts, not objects.filters. Putting it under filters causes a config validation error.

  • RTSP must be explicitly enabled on some cameras. Newer Reolink models (CX810) have RTSP disabled by default in their web UI. connection refused on port 554 is the symptom.

  • H.265 cameras need different RTSP paths. Use h265Preview_01_main instead of h264Preview_01_main for H.265 cameras.

  • Live view and detection are independent. The browser gets video via WebRTC directly from go2rtc. Even if detection is broken, live view works. A working live stream does not confirm detection is running.

  • HA↔Frigate connectivity goes through the main router. Make sure the UCG Ultra firewall allows IoT VLAN → main LAN traffic on port 5000 and 1935.