Deploy Frigate NVR stack with Mosquitto, Ntfy, and frigate-notify #190

Merged
eblume merged 20 commits from deploy-frigate-nvr into main 2026-02-14 21:27:45 -08:00
39 changed files with 1136 additions and 27 deletions

View file

@ -67,7 +67,7 @@ caddy_services:
- name: navidrome
host: "dj.{{ caddy_domain }}"
backend: "https://dj.tail8d86e.ts.net"
- name: hajimari
- name: homepage
host: "go.{{ caddy_domain }}"
backend: "https://go.tail8d86e.ts.net"
- name: docs
@ -76,6 +76,12 @@ caddy_services:
- name: cv
host: "cv.{{ caddy_domain }}"
backend: "https://cv.tail8d86e.ts.net"
- name: nvr
host: "nvr.{{ caddy_domain }}"
backend: "https://nvr.tail8d86e.ts.net"
- name: ntfy
host: "ntfy.{{ caddy_domain }}"
backend: "https://ntfy.tail8d86e.ts.net"
eblume marked this conversation as resolved

Why no mqtt.ops.eblu.me? There might be a reason.

Why no mqtt.ops.eblu.me? There might be a reason.
- name: sifaka
host: "nas.{{ caddy_domain }}"
backend: "http://sifaka:5000"

18
argocd/apps/frigate.yaml Normal file
View file

@ -0,0 +1,18 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: frigate
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/frigate
destination:
server: https://kubernetes.default.svc
namespace: frigate
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -0,0 +1,18 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: mosquitto
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/mosquitto
destination:
server: https://kubernetes.default.svc
namespace: mqtt
syncPolicy:
syncOptions:
- CreateNamespace=true

18
argocd/apps/ntfy.yaml Normal file
View file

@ -0,0 +1,18 @@
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ntfy
namespace: argocd
spec:
project: default
source:
repoURL: ssh://forgejo@forge.ops.eblu.me:2222/eblume/blumeops.git
targetRevision: main
path: argocd/manifests/ntfy
destination:
server: https://kubernetes.default.svc
namespace: ntfy
syncPolicy:
syncOptions:
- CreateNamespace=true

View file

@ -14,7 +14,7 @@ metadata:
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "ArgoCD"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Infrastructure"
gethomepage.dev/icon: "argo-cd.png"
gethomepage.dev/description: "GitOps CD"
gethomepage.dev/href: "https://argocd.ops.eblu.me"

View file

@ -10,7 +10,7 @@ metadata:
tailscale.com/tags: "tag:k8s,tag:flyio-target"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "CV"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Services"
gethomepage.dev/icon: "mdi-file-document"
gethomepage.dev/description: "Resume / CV"
gethomepage.dev/href: "https://cv.eblu.me"

View file

@ -8,7 +8,7 @@ metadata:
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "PyPI"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Infrastructure"
gethomepage.dev/icon: "pypi.png"
gethomepage.dev/description: "PyPI cache"
gethomepage.dev/href: "https://pypi.ops.eblu.me"

View file

@ -10,7 +10,7 @@ metadata:
tailscale.com/tags: "tag:k8s,tag:flyio-target"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Docs"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Services"
gethomepage.dev/icon: "mdi-book-open-page-variant"
gethomepage.dev/description: "BlumeOps Documentation"
gethomepage.dev/href: "https://docs.eblu.me"

View file

@ -0,0 +1,31 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: frigate-notify-config
namespace: frigate
data:
config.yml: |
frigate:
server: http://frigate:5000
public_url: https://nvr.ops.eblu.me
webapi:
enabled: false
mqtt:
enabled: true
server: mosquitto.mqtt.svc.cluster.local
port: 1883
clientid: frigate-notify
topic_prefix: frigate
alerts:
general:
title: "Frigate Alert"
ntfy:
enabled: true
server: http://ntfy.ntfy.svc.cluster.local:80
topic: frigate-alerts
headers:
- X-Actions: "view, Open Event, {{.Extra.PublicURL}}/review?id={{.ID}}, clear=true; view, Open Camera, {{.Extra.PublicURL}}/#/cameras/{{.Camera}}"

View file

@ -0,0 +1,71 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: frigate-config
namespace: frigate
data:
config.yml: |
mqtt:
host: mosquitto.mqtt.svc.cluster.local
port: 1883
go2rtc:
streams:
# GableCam IP is reserved in UX7 DHCP config
gablecam:
eblume marked this conversation as resolved

here and anywhere else that 192.168.1.159 appears in IaC, include a comment that the IP is reserved in UX7's DHCP config

here and anywhere else that 192.168.1.159 appears in IaC, include a comment that the IP is reserved in UX7's DHCP config
- "rtsp://{FRIGATE_CAMERA_USER}:{FRIGATE_CAMERA_PASSWORD}@192.168.1.159:554/h264Preview_01_main"
gablecam_sub:
- "rtsp://{FRIGATE_CAMERA_USER}:{FRIGATE_CAMERA_PASSWORD}@192.168.1.159:554/h264Preview_01_sub"
cameras:
gablecam:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/gablecam
input_args: preset-rtsp-restream
roles: [record]
- path: rtsp://127.0.0.1:8554/gablecam_sub
input_args: preset-rtsp-restream
roles: [detect]
detect:
enabled: true
stationary:
max_frames:
default: 1500
objects:
car: 150
objects:
track: [person, car, dog, cat, bird]
detectors:
onnx:
type: onnx
model:
model_type: yolonas
width: 320
height: 320
input_tensor: nchw
input_pixel_format: bgr
path: /media/frigate/models/yolo_nas_s.onnx
labelmap_path: /labelmap/coco-80.txt
record:
enabled: true
retain:
days: 3
mode: all
alerts:
retain:
days: 30
mode: active_objects
detections:
retain:
days: 14
mode: motion
snapshots:
enabled: true
retain:
default: 14

View file

@ -0,0 +1,34 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: frigate-notify
namespace: frigate
spec:
replicas: 1
selector:
matchLabels:
app: frigate-notify
template:
metadata:
labels:
app: frigate-notify
spec:
containers:
- name: frigate-notify
image: ghcr.io/0x2142/frigate-notify:v0.3.5
volumeMounts:
- name: config
mountPath: /app/config.yml
subPath: config.yml
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
volumes:
- name: config
configMap:
name: frigate-notify-config

View file

@ -0,0 +1,80 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: frigate
namespace: frigate
spec:
replicas: 1
selector:
matchLabels:
app: frigate
template:
metadata:
labels:
app: frigate
spec:
containers:
- name: frigate
image: ghcr.io/blakeblackshear/frigate:0.16.4-standard-arm64
ports:
- containerPort: 5000
name: http
- containerPort: 8554
name: rtsp
- containerPort: 1984
name: go2rtc
env:
- name: FRIGATE_CAMERA_USER
valueFrom:
secretKeyRef:
name: frigate-camera
key: username
- name: FRIGATE_CAMERA_PASSWORD
valueFrom:
secretKeyRef:
name: frigate-camera
key: password
volumeMounts:
- name: config
mountPath: /config/config.yml
subPath: config.yml
- name: recordings
mountPath: /media/frigate
- name: database
mountPath: /db
- name: shm
mountPath: /dev/shm
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "2Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /api/version
port: 5000
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
httpGet:
path: /api/version
port: 5000
initialDelaySeconds: 15
periodSeconds: 10
volumes:
- name: config
configMap:
name: frigate-config
- name: recordings
persistentVolumeClaim:
claimName: frigate-recordings
- name: database
persistentVolumeClaim:
claimName: frigate-database
- name: shm
emptyDir:
medium: Memory
sizeLimit: 256Mi

View file

@ -0,0 +1,22 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: frigate-camera
namespace: frigate
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-blumeops
target:
name: frigate-camera
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: Reolink Floodlight Camera
property: username
- secretKey: password
remoteRef:
key: Reolink Floodlight Camera
property: password

View file

@ -0,0 +1,26 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: frigate-tailscale
namespace: frigate
annotations:
tailscale.com/proxy-class: "default"
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "NVR"
gethomepage.dev/group: "Infrastructure"
gethomepage.dev/icon: "frigate.png"
gethomepage.dev/description: "Network video recorder"
gethomepage.dev/href: "https://nvr.ops.eblu.me"
gethomepage.dev/pod-selector: "app=frigate"
spec:
ingressClassName: tailscale
defaultBackend:
service:
name: frigate
port:
number: 5000
tls:
- hosts:
- nvr

View file

@ -0,0 +1,15 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: frigate
resources:
- external-secret.yaml
- configmap.yaml
- configmap-notify.yaml
- pv-nfs.yaml
- pvc-recordings.yaml
- pvc-database.yaml
- deployment.yaml
- deployment-notify.yaml
- service.yaml
- ingress-tailscale.yaml

View file

@ -0,0 +1,22 @@
# NFS PersistentVolume for Frigate recordings
# Requires: NFS share on sifaka at /volume1/frigate with NFS permissions for indri
#
# To create on Synology:
# 1. Control Panel > Shared Folder > Create
# 2. Name: frigate, Location: Volume 1
# 3. Control Panel > File Services > NFS > NFS Rules
# 4. Add rule for "frigate" share: Hostname=indri, Privilege=Read/Write, Squash=No mapping
apiVersion: v1
kind: PersistentVolume
metadata:
name: frigate-recordings-nfs-pv
spec:
capacity:
storage: 2Ti
accessModes:
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
nfs:
server: sifaka
path: /volume1/frigate
eblume marked this conversation as resolved

fyi I have created this share

fyi I have created this share

View file

@ -0,0 +1,13 @@
# PersistentVolumeClaim for Frigate SQLite database
# Uses minikube's default storage class for local provisioning
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: frigate-database
namespace: frigate
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi

View file

@ -0,0 +1,15 @@
# PersistentVolumeClaim for Frigate recordings
# Binds to the NFS PV for sifaka:/volume1/frigate
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: frigate-recordings
namespace: frigate
spec:
accessModes:
- ReadWriteMany
storageClassName: ""
volumeName: frigate-recordings-nfs-pv
resources:
requests:
storage: 2Ti

View file

@ -0,0 +1,19 @@
---
apiVersion: v1
kind: Service
metadata:
name: frigate
namespace: frigate
spec:
selector:
app: frigate
ports:
- name: http
port: 5000
targetPort: 5000
- name: rtsp
port: 8554
targetPort: 8554
- name: go2rtc
port: 1984
targetPort: 1984

View file

@ -0,0 +1,490 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-frigate
namespace: monitoring
labels:
grafana_dashboard: "1"
data:
frigate.json: |
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [
{
"options": {
"0": { "color": "red", "index": 0, "text": "DOWN" }
},
"type": "value"
},
{
"options": {
"1": { "color": "green", "index": 1, "text": "UP" }
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "green", "value": 1 }
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 },
"id": 1,
"options": {
"colorMode": "value",
"graphMode": "none",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_service_info",
"refId": "A"
}
],
"title": "Frigate Status",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 15 },
{ "color": "red", "value": 30 }
]
},
"unit": "ms"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 },
"id": 2,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_detector_inference_speed_seconds{name=\"onnx\"} * 1000",
"refId": "A"
}
],
"title": "Detector Inference",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 5 },
{ "color": "red", "value": 10 }
]
},
"unit": "fps"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 },
"id": 3,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_camera_fps{camera_name=\"gablecam\"}",
"legendFormat": "{{type}}",
"refId": "A"
}
],
"title": "Camera FPS",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"unit": "short"
},
"overrides": []
},
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 },
"id": 4,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_detection_total_fps",
"refId": "A"
}
],
"title": "Detection FPS",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"id": 5,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_cpu_usage_percent",
"legendFormat": "{{type}} - {{pid}}",
"refId": "A"
}
],
"title": "CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"id": 6,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_mem_usage_percent",
"legendFormat": "{{type}} - {{pid}}",
"refId": "A"
}
],
"title": "Memory Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"unit": "fps"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"id": 7,
"options": {
"legend": {
"calcs": ["mean", "max"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_camera_fps{camera_name=\"gablecam\"}",
"legendFormat": "{{type}}",
"refId": "A"
}
],
"title": "Camera FPS Over Time",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [{ "color": "green", "value": null }]
},
"unit": "bytes"
},
"overrides": []
},
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"id": 8,
"options": {
"legend": {
"calcs": ["lastNotNull"],
"displayMode": "table",
"placement": "bottom",
"showLegend": true
},
"tooltip": { "mode": "multi", "sort": "desc" }
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "frigate_storage_used_bytes",
"legendFormat": "{{storage}}",
"refId": "A"
}
],
"title": "Storage Usage",
"type": "timeseries"
}
],
"refresh": "30s",
"schemaVersion": 38,
"tags": ["frigate", "nvr", "camera"],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Frigate NVR",
"uid": "frigate",
"version": 1,
"weekStart": ""
}

View file

@ -17,6 +17,7 @@ resources:
- dashboards/configmap-postgresql.yaml
- dashboards/configmap-services.yaml
- dashboards/configmap-zot.yaml
- dashboards/configmap-frigate.yaml
- dashboards/configmap-cv-apm.yaml
- dashboards/configmap-docs-apm.yaml
- dashboards/configmap-flyio.yaml

View file

@ -199,5 +199,7 @@ config:
style: column
Content:
style: column
Misc:
Infrastructure:
style: column
Services:
style: column

View file

@ -0,0 +1,10 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: mosquitto-config
namespace: mqtt
data:
mosquitto.conf: |
listener 1883
allow_anonymous true
persistence false

View file

@ -0,0 +1,47 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mosquitto
namespace: mqtt
spec:
replicas: 1
selector:
matchLabels:
app: mosquitto
template:
metadata:
labels:
app: mosquitto
spec:
containers:
- name: mosquitto
image: eclipse-mosquitto:2
ports:
- containerPort: 1883
name: mqtt
volumeMounts:
- name: config
mountPath: /mosquitto/config/mosquitto.conf
subPath: mosquitto.conf
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
livenessProbe:
tcpSocket:
port: 1883
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
tcpSocket:
port: 1883
initialDelaySeconds: 3
periodSeconds: 10
volumes:
- name: config
configMap:
name: mosquitto-config

View file

@ -0,0 +1,8 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: mqtt
resources:
- configmap.yaml
- deployment.yaml
- service.yaml

View file

@ -0,0 +1,13 @@
---
apiVersion: v1
kind: Service
metadata:
name: mosquitto
namespace: mqtt
spec:
selector:
app: mosquitto
ports:
- name: mqtt
port: 1883
targetPort: 1883

View file

@ -0,0 +1,13 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: ntfy-config
namespace: ntfy
data:
server.yml: |
base-url: https://ntfy.ops.eblu.me
upstream-base-url: https://ntfy.sh
attachment-cache-dir: /var/cache/ntfy/attachments
attachment-total-size-limit: 1G
attachment-file-size-limit: 10M
attachment-expiry-duration: 24h

View file

@ -0,0 +1,54 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ntfy
namespace: ntfy
spec:
replicas: 1
selector:
matchLabels:
app: ntfy
template:
metadata:
labels:
app: ntfy
spec:
containers:
- name: ntfy
image: binwiederhier/ntfy:v2.11.0
args: ["serve", "--config", "/etc/ntfy/server.yml"]
ports:
- containerPort: 80
name: http
volumeMounts:
- name: config
mountPath: /etc/ntfy/server.yml
subPath: server.yml
- name: cache
mountPath: /var/cache/ntfy
resources:
requests:
memory: "32Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /v1/health
port: 80
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /v1/health
port: 80
initialDelaySeconds: 3
periodSeconds: 10
volumes:
- name: config
configMap:
name: ntfy-config
- name: cache
emptyDir: {}

View file

@ -0,0 +1,26 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ntfy-tailscale
namespace: ntfy
annotations:
tailscale.com/proxy-class: "default"
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Ntfy"
gethomepage.dev/group: "Infrastructure"
eblume marked this conversation as resolved

In this same PR, let's move ArgoCD, Prometheus, and devpi/pypi to Infastructure group. CV, Docs, TeslaMate, and Transmission should become "Services".

In this same PR, let's move ArgoCD, Prometheus, and devpi/pypi to Infastructure group. CV, Docs, TeslaMate, and Transmission should become "Services".
gethomepage.dev/icon: "ntfy.png"
gethomepage.dev/description: "Push notifications"
gethomepage.dev/href: "https://ntfy.ops.eblu.me"
gethomepage.dev/pod-selector: "app=ntfy"
spec:
ingressClassName: tailscale
defaultBackend:
service:
name: ntfy
port:
number: 80
tls:
- hosts:
- ntfy

View file

@ -0,0 +1,9 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: ntfy
resources:
- configmap.yaml
- deployment.yaml
- service.yaml
- ingress-tailscale.yaml

View file

@ -0,0 +1,13 @@
---
apiVersion: v1
kind: Service
metadata:
name: ntfy
namespace: ntfy
spec:
selector:
app: ntfy
ports:
- name: http
port: 80
targetPort: 80

View file

@ -44,3 +44,9 @@ data:
- job_name: "kube-state-metrics"
static_configs:
- targets: ["kube-state-metrics.monitoring.svc.cluster.local:8080"]
# Frigate NVR metrics
- job_name: "frigate"
static_configs:
- targets: ["frigate.frigate.svc.cluster.local:5000"]
metrics_path: /api/metrics

View file

@ -11,7 +11,7 @@ metadata:
tailscale.com/tags: "tag:k8s,tag:flyio-target"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Prometheus"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Infrastructure"
gethomepage.dev/icon: "prometheus.png"
gethomepage.dev/description: "Metrics storage"
gethomepage.dev/href: "https://prometheus.ops.eblu.me"

View file

@ -8,7 +8,7 @@ metadata:
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "TeslaMate"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Services"
gethomepage.dev/icon: "teslamate.png"
gethomepage.dev/description: "Tesla data logger"
gethomepage.dev/href: "https://tesla.ops.eblu.me"

View file

@ -9,7 +9,7 @@ metadata:
tailscale.com/proxy-group: "ingress"
gethomepage.dev/enabled: "true"
gethomepage.dev/name: "Transmission"
gethomepage.dev/group: "Misc"
gethomepage.dev/group: "Services"
gethomepage.dev/icon: "transmission.png"
gethomepage.dev/description: "Torrent client"
gethomepage.dev/href: "https://torrent.ops.eblu.me"

View file

@ -0,0 +1 @@
Deploy cloud-free NVR stack: Frigate 0.16.4 (ARM64) with ONNX/YOLO-NAS-s detection, Mosquitto MQTT broker, Ntfy self-hosted push notifications (with iOS APNs relay), and frigate-notify for detection alerting. GableCam (ReoLink Elite Floodlight) connected via RTSP with NFS recordings on sifaka, Grafana dashboard, Prometheus scraping, Homepage integration, and Caddy reverse proxies at nvr.ops.eblu.me and ntfy.ops.eblu.me.

View file

@ -14,3 +14,4 @@ Plans that have been fully implemented and verified. Kept for historical referen
|------|-----------|-------------|
| [[adopt-dagger-ci]] | 2026-02-11 | Adopt Dagger as CI/CD build engine (Phases 13) |
| [[segment-home-network]] | 2026-02-14 | Manual three-network segmentation for UniFi Express 7 |
| [[operationalize-reolink-camera]] | 2026-02-15 | Deploy Frigate NVR stack with Mosquitto, Ntfy, and frigate-notify |

View file

@ -11,8 +11,9 @@ tags:
# Plan: Operationalize ReoLink Camera
> **Status:** Planned (not yet executed)
> **Status:** Completed (2026-02-15)
> **Depends on:** [[add-unifi-pulumi-stack]] — the camera must be on the IoT VLAN, isolated from the rest of the network.
> **PR:** #190
## Background
@ -241,23 +242,23 @@ Camera settings to apply: enable RTSP and ONVIF, set "fluency first" encoding mo
## Verification Checklist
- [ ] Camera streams accessible via RTSP from services subnet
- [ ] Camera has no internet access (blocked at firewall)
- [ ] Frigate pod is running and showing live camera feed in web UI
- [ ] Recordings appearing in NFS share on sifaka
- [ ] Object detection working (person/vehicle detected in Frigate UI)
- [ ] Retention policy active (old recordings cleaned up automatically)
- [ ] Alerts firing on detection events
- [ ] Prometheus metrics visible in Grafana dashboard
- [ ] `mise run services-check` passes
- [x] Camera streams accessible via RTSP from services subnet
- [ ] Camera has no internet access (blocked at firewall) — pending IoT VLAN segmentation
- [x] Frigate pod is running and showing live camera feed in web UI
- [x] Recordings appearing in NFS share on sifaka
- [x] Object detection working (person/vehicle detected in Frigate UI)
- [x] Retention policy active (old recordings cleaned up automatically)
- [x] Alerts firing on detection events (ntfy push notifications with ~6s delivery)
- [x] Prometheus metrics visible in Grafana dashboard
- [x] `mise run services-check` passes
## Open Questions
## Open Questions (Resolved)
- **MQTT broker:** Is there an existing MQTT broker in the cluster, or does one need to be deployed? Mosquitto is lightweight and standard.
- **Home Assistant:** Frigate works standalone, but HA adds richer automation (e.g., turn on floodlight when person detected, arm/disarm by time of day). Evaluate whether to add HA as a future plan.
- **Sifaka NFS share sizing:** How much space to allocate on the NAS? Start with 2 TB and monitor. The hybrid retention strategy keeps this manageable.
- **Additional cameras:** If more cameras are added later, CPU detection may become a bottleneck. At that point, evaluate a Hailo-8L USB accelerator or a dedicated Frigate host (e.g., RPi5).
- **Floodlight automation:** The ReoLink HTTP API supports floodlight control. Could be automated to turn on when Frigate detects a person at night — but this requires either HA or a custom script listening to MQTT events.
- **MQTT broker:** Deployed Mosquitto (eclipse-mosquitto:2) in the `mqtt` namespace. Lightweight, anonymous access, cluster-internal only (no Caddy/ingress needed since MQTT is TCP, not HTTP).
- **Home Assistant:** Deferred. Frigate + frigate-notify + ntfy provides a complete pipeline without HA.
- **Sifaka NFS share sizing:** Allocated 2 TB. Hybrid retention (3d continuous, 30d alerts, 14d detections) keeps usage well within bounds.
- **Additional cameras:** Using ONNX/YOLO-NAS-s on CPU at ~535ms/frame, ~2 FPS detection. Adequate for single camera. Apple Silicon Detector (ASD) via ZMQ is the next upgrade path for better performance (~15ms via Neural Engine). Requires Frigate 0.17+.
- **Floodlight automation:** Deferred to future Home Assistant evaluation.
## Future Considerations

View file

@ -80,6 +80,8 @@ check_http "Transmission" "https://torrent.ops.eblu.me/"
check_http "Immich" "https://photos.ops.eblu.me/"
check_http "Navidrome" "https://dj.ops.eblu.me/"
check_http "CV" "https://cv.ops.eblu.me/"
check_http "Ntfy" "https://ntfy.ops.eblu.me/v1/health"
check_http "Frigate" "https://nvr.ops.eblu.me/api/version"
echo ""
echo "Public services (via Fly.io):"
@ -99,6 +101,10 @@ check_service "grafana" "kubectl --context=minikube-indri -n monitoring get pods
check_service "miniflux" "kubectl --context=minikube-indri -n miniflux get pods -l app=miniflux -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "teslamate" "kubectl --context=minikube-indri -n teslamate get pods -l app=teslamate -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "blumeops-pg" "kubectl --context=minikube-indri -n databases get pods -l cnpg.io/cluster=blumeops-pg -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "mosquitto" "kubectl --context=minikube-indri -n mqtt get pods -l app=mosquitto -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "ntfy" "kubectl --context=minikube-indri -n ntfy get pods -l app=ntfy -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "frigate" "kubectl --context=minikube-indri -n frigate get pods -l app=frigate -o jsonpath='{.items[0].status.phase}' | grep -q Running"
check_service "frigate-notify" "kubectl --context=minikube-indri -n frigate get pods -l app=frigate-notify -o jsonpath='{.items[0].status.phase}' | grep -q Running"
echo ""
echo "ArgoCD app sync status:"