Compare commits

...
17 Commits
Author SHA1 Message Date
Josh Hawkins db921a14dd report progress during the ownership sweep 2026-08-24 11:36:09 -05:00
Josh Hawkins 3de88492ec unwrap hard-wrapped prose in the installation docs 2026-08-24 10:48:53 -05:00
Josh Hawkins e69795ce8f discard stdout for the unprivileged smoke nginx -t 2026-08-24 10:05:23 -05:00
Josh Hawkins 22b9e4cb45 re-own the nginx shm cache on service restart 2026-08-24 10:01:38 -05:00
Josh Hawkins 92d18a3e82 run smoke nginx -t and the write probe as the runtime user 2026-08-24 10:01:38 -05:00
Josh Hawkins 6caf872999 set HOME to /config for non-root services 2026-08-24 10:01:38 -05:00
Josh Hawkins 383f26a132 chown the s6 log pipe so non-root nginx can reopen /dev/stdout 2026-08-24 09:52:59 -05:00
Josh Hawkins 78e39e7a22 tolerate homekit config chown failures in the go2rtc run script 2026-08-23 18:26:13 -05:00
Josh Hawkins fa191b0faa only write the sweep sentinel when a media volume is mounted 2026-08-23 18:26:13 -05:00
Josh Hawkins 0744021809 Assert non-root services, JWT migration, and escape hatch in CI 2026-08-23 18:17:48 -05:00
Josh Hawkins 90d6712e47 Create /media/frigate after the ownership sweep 2026-08-23 18:17:48 -05:00
Josh Hawkins 94bd0b043f Document non-root operation and per-hardware device access 2026-08-23 18:03:02 -05:00
Josh Hawkins 8183da8020 Hand TensorRT model cache ownership to the runtime user 2026-08-23 18:01:10 -05:00
Josh Hawkins 59b4ff6fd6 Disable bandwidth stats gracefully when not running as root 2026-08-23 17:59:37 -05:00
Josh Hawkins 716326fdcc Run nginx as the frigate user with writable state in /tmp/nginx 2026-08-23 17:59:19 -05:00
Josh Hawkins cb3c3b0e8c Run go2rtc as its own restricted user 2026-08-23 17:59:19 -05:00
Josh Hawkins 74aa85c646 Run the frigate service as the frigate user 2026-08-23 17:59:19 -05:00
17 changed files with 360 additions and 30 deletions
+74 -4
View File
@@ -59,10 +59,15 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Start container
run: |
mkdir -p /tmp/frigate-config
mkdir -p /tmp/frigate-config /tmp/frigate-media
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config/config.yml
# simulate a root-era install: root-owned 0600 jwt secret pre-exists
docker run --rm -v /tmp/frigate-config:/config --entrypoint bash \
${{ steps.setup.outputs.image-name }}-amd64 \
-c "python3 -c 'import secrets; open(\"/config/.jwt_secret\",\"w\").write(secrets.token_hex(64))' && chmod 600 /config/.jwt_secret && chown 0:0 /config/.jwt_secret"
docker run -d --name frigate --shm-size 256m \
-v /tmp/frigate-config:/config \
-v /tmp/frigate-media:/media/frigate \
-p 5000:5000 -p 8971:8971 \
${{ steps.setup.outputs.image-name }}-amd64
- name: Wait for API
@@ -91,16 +96,81 @@ jobs:
echo "response carries frame-ancestors, which breaks cross-origin iframe embedding"
exit 1
fi
docker exec frigate /usr/local/nginx/sbin/nginx -t
# -t must NOT run as root: ngx_create_paths would chown the live cache
# and temp dirs to the `user root` directive user, breaking the workers.
# stdout goes to /dev/null because -t reopens the config's
# error_log/access_log /dev/stdout by path, and the docker exec pipe
# is root-owned; -t reports on stderr, so nothing is lost
docker exec frigate /command/s6-setuidgid frigate bash -c '/usr/local/nginx/sbin/nginx -e stderr -t -c /tmp/nginx/conf/nginx.conf >/dev/null'
docker exec frigate stat -c %a /etc/letsencrypt/live/frigate/privkey.pem | grep -qx 600
docker exec frigate stat -c %a /dev/shm/go2rtc.yaml | grep -qx 640
- name: Assert services run as non-root
run: |
ps_out=$(docker exec frigate ps -eo user=,comm=)
echo "$ps_out"
assert_nonroot() {
# the process must exist AND no instance of it may run as root
echo "$ps_out" | grep -qw "$1" || { echo "$1 is not running"; exit 1; }
if echo "$ps_out" | grep -w "$1" | grep -q '^root'; then
echo "$1 is running as root"; exit 1
fi
}
assert_nonroot python3
assert_nonroot go2rtc
assert_nonroot nginx
# root-era jwt secret must have been captured by the sweep and the
# auth stack must be functional: wrong creds => clean 401, not 500
docker exec frigate stat -c %u /config/.jwt_secret | grep -qx "$(docker exec frigate id -u frigate)"
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:5000/api/login \
-H 'content-type: application/json' -d '{"user":"admin","password":"definitely-wrong"}')
[ "$code" = "401" ] || { echo "login endpoint returned $code"; exit 1; }
# nginx runtime state must belong to the runtime user (a root nginx -t
# in the step above would have chowned it to root)
owners=$(docker exec frigate stat -c %U /tmp/nginx /dev/shm/nginx_cache)
echo "$owners"
if echo "$owners" | grep -qvx frigate; then
echo "nginx runtime dirs are not owned by frigate"; exit 1
fi
# runtime user can write recordings storage
docker exec frigate /command/s6-setuidgid frigate touch /media/frigate/.write-probe
docker exec frigate rm /media/frigate/.write-probe
- name: Assert escape hatch restores root
run: |
mkdir -p /tmp/frigate-config-root
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-root/config.yml
# pre-seed a sentinel: the assertion below is that the escape hatch
# DELETES it. Against a fresh dir the absence check passes vacuously
# and proves nothing about the rm -f in the prepare script.
echo "2:1000:1000" > /tmp/frigate-config-root/.permissions_version
docker run -d --name frigate-root --shm-size 256m \
-e FRIGATE_RUN_AS_ROOT=true \
-v /tmp/frigate-config-root:/config \
${{ steps.setup.outputs.image-name }}-amd64
up=0
for i in $(seq 1 60); do
docker exec frigate-root curl -fs http://127.0.0.1:5000/api/version && up=1 && break
sleep 5
done
if [ "$up" -ne 1 ]; then echo "escape hatch container never healthy"; docker logs frigate-root; exit 1; fi
ps_out=$(docker exec frigate-root ps -eo user=,comm=)
echo "$ps_out"
echo "$ps_out" | grep -w python3 | grep -q '^root'
echo "$ps_out" | grep -w go2rtc | grep -q '^root'
echo "$ps_out" | grep -w nginx | grep -q '^root'
# escape hatch must have DELETED the pre-seeded sentinel. written as
# an if because bash exempts a negated command from set -e
if docker exec frigate-root test -f /config/.permissions_version; then
echo "escape hatch did not delete the sweep sentinel"; exit 1
fi
docker rm -f frigate-root
- name: Assert PUID/PGID remapping
run: |
mkdir -p /tmp/frigate-config-puid
mkdir -p /tmp/frigate-config-puid /tmp/frigate-media-puid
printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-puid/config.yml
docker run -d --name frigate-puid --shm-size 256m \
-e PUID=1500 -e PGID=1500 \
-v /tmp/frigate-config-puid:/config \
-v /tmp/frigate-media-puid:/media/frigate \
${{ steps.setup.outputs.image-name }}-amd64
up=0
for i in $(seq 1 60); do
@@ -110,7 +180,7 @@ jobs:
if [ "$up" -ne 1 ]; then echo "PUID container never became healthy"; docker logs frigate-puid; exit 1; fi
docker exec frigate-puid id -u frigate | grep -qx 1500
docker exec frigate-puid id -g frigate | grep -qx 1500
docker exec frigate-puid cat /config/.permissions_version | grep -qx "1:1500:1500"
docker exec frigate-puid cat /config/.permissions_version | grep -qx "2:1500:1500"
# second boot must skip the sweep (sentinel hit). Poll rather than
# sleep: the string can only come from the second boot (the first
# had no sentinel), so grepping the full log is unambiguous.
@@ -49,7 +49,7 @@ do
then
echo "[INFO] Reloading nginx to refresh TLS certificate"
echo "$lefile: $leprint"
/usr/local/nginx/sbin/nginx -s reload
/usr/local/nginx/sbin/nginx -c /tmp/nginx/conf/nginx.conf -s reload
fi
sleep 60
@@ -4,6 +4,13 @@
set -o errexit -o nounset -o pipefail
# $HOME is /root from the container env and survives s6-setuidgid, so cache
# and telemetry writes (huggingface, openvino) fail after the drop. Set it
# before opt_in_out so the opt-out marker lands where the service will look.
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
export HOME=/config
fi
# opt out of openvino telemetry
if [ -e /usr/local/bin/opt_in_out ]; then
/usr/local/bin/opt_in_out --opt_out > /dev/null 2>&1
@@ -30,4 +37,8 @@ cd /opt/frigate || echo "[ERROR] Failed to change working directory to /opt/frig
# Replace the bash process with the Frigate process, redirecting stderr to stdout
exec 2>&1
exec python3 -u -m frigate
if [[ "$(id -u)" -ne 0 || "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
exec python3 -u -m frigate
else
exec s6-setuidgid frigate python3 -u -m frigate
fi
@@ -110,6 +110,16 @@ fi
readonly homekit_config_path="/config/go2rtc_homekit.yml"
setup_homekit_config "${homekit_config_path}"
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
chown go2rtc:go2rtc /dev/shm/go2rtc.yaml 2>/dev/null || true
# go2rtc rewrites this in place (os.WriteFile, no rename), so owning the
# file is enough; /config grants frigate-data traverse only. Tolerated so
# a chown-refusing mount (NFS root_squash) degrades pairing persistence
# instead of crash-looping the service
chown go2rtc:frigate-data "${homekit_config_path}" 2>/dev/null && chmod 664 "${homekit_config_path}" 2>/dev/null || \
echo "[WARN] Could not hand ${homekit_config_path} to the go2rtc user; HomeKit pairing changes may not persist"
fi
readonly config_path="/config"
if [[ -x "${config_path}/go2rtc" ]]; then
@@ -125,4 +135,8 @@ echo "[INFO] Starting go2rtc..."
# Use HomeKit config as the primary config so writebacks go there
# The main config from Frigate will be loaded as a secondary config
exec 2>&1
exec "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml
if [[ "$(id -u)" -ne 0 || "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
exec "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml
else
exec s6-setuidgid go2rtc "${binary_path}" -config="${homekit_config_path}" -config=/dev/shm/go2rtc.yaml
fi
@@ -2,4 +2,4 @@
set -e
# Wait for PID file to exist.
while ! test -f /run/nginx.pid; do sleep 1; done
while ! test -f /tmp/nginx/nginx.pid; do sleep 1; done
@@ -59,10 +59,14 @@ function set_worker_processes() {
cpus=4
fi
# we need to catch any errors because sed will fail if user has bind mounted a custom nginx file
sed -i "s/worker_processes auto;/worker_processes ${cpus};/" /usr/local/nginx/conf/nginx.conf || true
sed -i "s/worker_processes auto;/worker_processes ${cpus};/" /tmp/nginx/conf/nginx.conf
}
# copied whole so the conf tree's relative includes still resolve
mkdir -p /tmp/nginx/conf /tmp/nginx/client_body /tmp/nginx/proxy \
/tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi
cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/
set_worker_processes
# ensure the directory for ACME challenges exists
@@ -87,15 +91,40 @@ nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py)
# build templates for optional FRIGATE_BASE_PATH environment variable
echo "$nginx_settings" | \
tempio -template /usr/local/nginx/templates/base_path.gotmpl \
-out /usr/local/nginx/conf/base_path.conf
-out /tmp/nginx/conf/base_path.conf
# build templates for additional network settings
echo "$nginx_settings" | \
tempio -template /usr/local/nginx/templates/listen.gotmpl \
-out /usr/local/nginx/conf/listen.conf
-out /tmp/nginx/conf/listen.conf
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
chown -R frigate:frigate /tmp/nginx
# heal the cache if a root `nginx -t` chowned it (ngx_create_paths chowns
# every cycle path to the `user` directive user when run as root)
if [ -d /dev/shm/nginx_cache ]; then
chown -R frigate:frigate /dev/shm/nginx_cache
fi
# error_log/access_log /dev/stdout make nginx REOPEN the s6 log pipe by
# path, and s6 created it root-owned 0600; without this the non-root
# master exits with "open() /dev/stdout failed (13: Permission denied)"
chown frigate /dev/stdout
# self-signed certs are root-generated; tolerant because mounted certs may be :ro
if [ -f "$letsencrypt_path/privkey.pem" ]; then
chown frigate:frigate "$letsencrypt_path/privkey.pem" "$letsencrypt_path/fullchain.pem" 2>/dev/null || true
fi
fi
# Replace the bash process with the NGINX process, redirecting stderr to stdout
exec 2>&1
exec \
s6-notifyoncheck -t 30000 -n 1 \
nginx
# -e stderr: the compile-time default error log under /usr/local/nginx/logs
# is not writable by the runtime user and would alert before config load
if [[ "$(id -u)" -ne 0 || "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
exec \
s6-notifyoncheck -t 30000 -n 1 \
nginx -e stderr -c /tmp/nginx/conf/nginx.conf
else
exec \
s6-notifyoncheck -t 30000 -n 1 \
s6-setuidgid frigate nginx -e stderr -c /tmp/nginx/conf/nginx.conf
fi
@@ -153,7 +153,24 @@ if [[ "$(id -u)" -eq 0 ]]; then
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" == "true" ]]; then
rm -f /config/.permissions_version
else
/usr/local/bin/fix-ownership --sentinel /config/.permissions_version \
# Only record the sentinel when something is mounted under /media: a
# sweep blessed against the container-local dir created below would let
# a volume attached later skip the sweep forever
sentinel_args=(--sentinel /config/.permissions_version)
if ! awk '$2 == "/media" || $2 ~ /^\/media\//' /proc/mounts | grep -q .; then
sentinel_args=()
fi
/usr/local/bin/fix-ownership "${sentinel_args[@]}" \
"${PUID:-1000}" "${PGID:-1000}" /config /media/frigate
fi
fi
# Not in the image, and the runtime user cannot create it under root-owned
# /media. Must stay after the sweep, which reads an absent /media/frigate as an
# unmounted volume rather than a swept one
if [[ "$(id -u)" -eq 0 && ! -d /media/frigate ]]; then
mkdir -p /media/frigate
if [[ "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
chown "${PUID:-1000}:${PGID:-1000}" /media/frigate
fi
fi
+31 -5
View File
@@ -22,7 +22,7 @@ set -o errexit -o nounset -o pipefail
# Permissions-layout epoch. Bump to force a one-time re-sweep on upgrade
# (e.g. when the privilege-drop release must capture files created as root
# since the previous sweep).
schema=1
schema=2
dry_run=0
sentinel=""
@@ -76,6 +76,8 @@ for path in "$@"; do
continue
fi
echo "[INFO] fix-ownership: scanning ${path} for ownership mismatches; this may take a while on large filesystems"
# find may fail mid-walk on a live volume (file deleted under it) or on a
# stale mount. Tolerate it rather than aborting under errexit, but never
# read a failed scan as "nothing to do": that would record the sweep as
@@ -98,17 +100,41 @@ for path in "$@"; do
echo "[WARN] fix-ownership: ${path} contains symlinked directories; ownership behind them is not managed and must be aligned by hand"
fi
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}; on large recordings volumes this can take a long time"
echo "[WARN] fix-ownership: adjusting ownership of ${count} entries under ${path}"
if [[ "$dry_run" -eq 1 ]]; then
echo "[INFO] fix-ownership: dry run, not changing ${path}"
continue
fi
find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \
-exec chown -h "${target_uid}:${target_gid}" {} + || {
# -print feeds the progress counter while -exec {} + keeps the chown
# batched; the scan above is what makes a real percentage possible
started=$SECONDS
if find "$path" \( -not -uid "$target_uid" -o -not -gid "$target_gid" \) \
-print -exec chown -h "${target_uid}:${target_gid}" {} + \
| awk -v total="$count" -v path="$path" '
BEGIN { next_pct = 5 }
{
pct = int(NR * 100 / total)
if (pct > 100) pct = 100
if (pct >= next_pct) {
printf "[INFO] fix-ownership: %s %d%% (%d/%d entries)\n", path, pct, NR, total
# mawk block-buffers to a pipe; without this the whole
# progress log arrives at once when the sweep ends
fflush()
while (next_pct <= pct) next_pct += 5
}
}'; then
elapsed=$((SECONDS - started))
if [[ "$elapsed" -ge 60 ]]; then
elapsed="$((elapsed / 60))m $((elapsed % 60))s"
else
elapsed="${elapsed}s"
fi
echo "[INFO] fix-ownership: finished ${path} in ${elapsed}"
else
swept_clean=0
echo "[WARN] fix-ownership: some entries under ${path} could not be updated (deleted mid-sweep or chown denied); will retry on next mismatch"
}
fi
done
# go2rtc (separate user) must be able to REACH its HomeKit state in /config.
@@ -1,9 +1,15 @@
# Copied to /tmp/nginx/conf at startup and loaded with -c from there. Relative
# includes resolve against the -c file, but every other path directive resolves
# against the compile-time --prefix, so non-include paths must stay absolute.
daemon off;
# Ignored by a non-root master; required by FRIGATE_RUN_AS_ROOT so workers
# stay root instead of the compiled-in default user
user root;
worker_processes auto;
error_log /dev/stdout warn;
pid /var/run/nginx.pid;
pid /tmp/nginx/nginx.pid;
events {
worker_connections 1024;
@@ -13,6 +19,12 @@ http {
map_hash_bucket_size 256;
server_tokens off;
client_body_temp_path /tmp/nginx/client_body;
proxy_temp_path /tmp/nginx/proxy;
fastcgi_temp_path /tmp/nginx/fastcgi;
uwsgi_temp_path /tmp/nginx/uwsgi;
scgi_temp_path /tmp/nginx/scgi;
include mime.types;
default_type application/octet-stream;
+10
View File
@@ -36,6 +36,16 @@ if ! [[ "$puid" =~ ^[0-9]+$ && "$pgid" =~ ^[0-9]+$ ]]; then
fi
echo "[INFO] Using image ${IMAGE} (override with FRIGATE_IMAGE=...)"
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "[INFO] ${IMAGE} is not present locally and has to be pulled first; this may take a while"
fi
if [[ -n "$dry_run_flag" ]]; then
echo "[INFO] Dry run: reporting what would change under ${config_dir} and ${media_dir}, changing nothing"
else
echo "[INFO] Aligning ${config_dir} and ${media_dir} to ${puid}:${pgid}; this may take a while on large filesystems"
fi
# shellcheck disable=SC2086
docker run --rm \
-v "${config_dir}:/config" \
@@ -13,6 +13,16 @@ TRT_VER=${TRT_VER:-$(cat /etc/TENSORRT_VER)}
OUTPUT_FOLDER="${MODEL_CACHE_DIR}/${TRT_VER}"
YOLO_MODELS=${YOLO_MODELS:-""}
# This runs as root after prepare's sentinel-guarded sweep, so the dirs and
# engines it creates below are the runtime user's to fix up, on every exit path
function hand_off_ownership() {
if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then
/usr/local/bin/fix-ownership "${PUID:-1000}" "${PGID:-1000}" \
/config/model_cache "${MODEL_CACHE_DIR}"
fi
}
trap hand_off_ownership EXIT
# Create output folder
mkdir -p ${OUTPUT_FOLDER}
+92
View File
@@ -0,0 +1,92 @@
---
id: non_root
title: Running as a non-root user
---
Frigate's services run as an unprivileged user inside the container. The main Frigate process and nginx run as `frigate`, and go2rtc runs as its own more restricted `go2rtc` user. Only the s6 init system and the certsync helper stay root.
By default the runtime user is uid/gid `1000:1000`. You can change it with `PUID`/`PGID`, or bypass Frigate's user handling entirely with Docker's own `user:`.
## Run modes
| Mode | How to enable | Ownership of `/config` and `/media/frigate` | `read_only: true` |
| -------------------------------- | -------------------------------------- | ---------------------------------------------------------- | ----------------- |
| Default | nothing, this is the default | Aligned to `1000:1000` on first boot | Not supported |
| `PUID`/`PGID` | `PUID=1001`, `PGID=1001` | Aligned to the values you set, on first boot | Not supported |
| Docker-native user | `user: "1001:1001"` | You own it, Frigate never changes ownership | Not supported |
| Root (escape hatch) | `FRIGATE_RUN_AS_ROOT=true` | Never touched | Not supported |
`PUID`/`PGID` remapping runs `usermod` at startup, which writes to `/etc/passwd`, so it can't work with a read-only root filesystem. That combination fails fast at startup with a message pointing here rather than failing obscurely later.
`FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored.
## Migrating an existing install
Volumes created by earlier versions of Frigate are owned by root. Ownership has to be aligned with the runtime user once.
This happens automatically on the first boot after upgrading, but on large recordings volumes it's much better to do it from the host beforehand. The boot sweep runs before any service starts, so a multi-terabyte `/media/frigate` can hold the container in startup long enough for Docker's healthcheck to mark it unhealthy, and orchestrators that react to health will restart it mid-sweep. If you'd rather not run the script, raise the healthcheck start period instead (`--start-period=1800s`, or `start_period: 1800s` under `healthcheck:` in compose).
Grab `fix-permissions.sh` from `docker/migration/` in the Frigate repo and dry run it first:
```bash
./fix-permissions.sh --dry-run /path/to/your/config /path/to/your/storage
```
That reports how many entries would change and touches nothing. When it looks right, run it without `--dry-run`:
```bash
./fix-permissions.sh /path/to/your/config /path/to/your/storage
```
Both the script and the boot sweep report progress as they go, so you can tell a slow sweep apart from a stuck one:
```
[INFO] fix-ownership: scanning /media/frigate for ownership mismatches; this may take a while on large filesystems
[WARN] fix-ownership: adjusting ownership of 4823941 entries under /media/frigate
[INFO] fix-ownership: /media/frigate 5% (241197/4823941 entries)
[INFO] fix-ownership: /media/frigate 10% (482394/4823941 entries)
[INFO] fix-ownership: finished /media/frigate in 12m 4s
```
The scan has no percentage behind it because the total isn't known until it finishes. Watch the boot sweep with `docker logs -f frigate`.
Pass `PUID` and `PGID` as the third and fourth arguments if you're not using the default `1000:1000`. The script wraps the same `fix-ownership` helper the container uses, so it's the same logic either way. Override the image it pulls with `FRIGATE_IMAGE=...` if you're not on `stable`.
Once the volumes are aligned, start Frigate normally. A sentinel at `/config/.permissions_version` records what was done, so later boots skip the sweep entirely unless you change `PUID`/`PGID`.
## Rolling back
Set `FRIGATE_RUN_AS_ROOT=true` and restart. Everything runs as root again, exactly as it did before.
The escape hatch never changes ownership, and it deletes the sweep sentinel on startup, so switching back to non-root later re-sweeps whatever root created in the meantime. Toggling in either direction is safe.
## Hardware device access
Supplementary groups can't open a device node that's `root:root` with mode `0600`. If your accelerator's node isn't group readable on the host, no amount of container configuration will fix it, so the fix belongs on the host.
Use `group_add` in compose (`--group-add` with `docker run`) to give the runtime user a host GID. `EXTRA_GROUPS` does the same thing and also covers the `go2rtc` user, which needs render and video access to run hardware accelerated restreams.
| Hardware | Device(s) | Non-root requirement |
| ------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Intel/AMD GPU (VAAPI/QSV) | `/dev/dri/renderD128` | `group_add: ["<host render GID>"]` from `getent group render`, or `EXTRA_GROUPS` |
| Intel/AMD NPU | `/dev/accel` | Host udev rule granting a group, then `group_add` that GID |
| Coral USB | `/dev/bus/usb` | Host udev rule granting plugdev, for example `SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a6e", GROUP="plugdev"` and the same for `18d1` post-init, then `group_add` the plugdev GID |
| Coral PCIe | `/dev/apex_0` | Host udev rule `SUBSYSTEM=="apex", GROUP="apex", MODE="0660"`, then `group_add` that GID |
| Hailo | `/dev/hailo0` | Host udev rule granting a group, then `group_add` that GID |
| NVIDIA | nvidia runtime | Works non-root with the nvidia-container-toolkit defaults |
| AMD ROCm | `/dev/kfd`, `/dev/dri` | `group_add` the host `video` and `render` GIDs |
| Raspberry Pi | `/dev/video11` | `group_add` the host `video` GID |
| Rockchip | `/dev/dri`, `/dev/dma_heap`, `/dev/rga`, `/dev/mpp_service` | These are commonly `root:root` `0600`, so host udev rules are required. If you can't grant access to all four, use `FRIGATE_RUN_AS_ROOT=true` |
| Axera (AXCL) | `/dev/ax_*` per the AXCL driver docs | Unverified. Node ownership is driver dependent, check it on your hardware before assuming this works |
| Synaptics SL1680 | per the Synaptics docs | Unverified |
| MemryX | per the MemryX docs | Still requires `privileged: true`, which means root. Out of scope for non-root operation |
## Known limitations
`telemetry.stats.network_bandwidth` uses nethogs, which needs `CAP_NET_ADMIN` and `CAP_NET_RAW` and therefore root. The stat is disabled automatically when Frigate isn't running as root, with one warning in the log. Use `FRIGATE_RUN_AS_ROOT=true` if you need it.
go2rtc's ffmpeg processes no longer appear in Intel GPU stats. Frigate reads per-process GPU usage from `/proc/<pid>/fdinfo`, which the kernel won't let one user read for another user's processes, so anything go2rtc spawns is invisible to it. Overall GPU utilization is unaffected.
If you mount your own TLS certificate at `/etc/letsencrypt/live/frigate`, the private key has to be readable by the runtime user. Frigate won't change ownership of a certificate you supplied, since the mount may be read-only.
If you're debugging nginx, run the config check as the runtime user with stdout discarded: `docker exec frigate /command/s6-setuidgid frigate bash -c 'nginx -t -c /tmp/nginx/conf/nginx.conf >/dev/null'`. Running `nginx -t` as root hands nginx's runtime directories to root as a side effect, which breaks the running workers until the service restarts, and the config's `/dev/stdout` logs can't be reopened through a root-owned `docker exec` pipe (the results print on stderr either way).
+5 -8
View File
@@ -548,9 +548,7 @@ services:
### Recommended security options
Frigate does not need elevated container privileges for most setups. The
following hardens the container; add the `devices`/`group_add` entries your
hardware requires (see the hardware acceleration docs):
Frigate does not need elevated container privileges for most setups. The following hardens the container; add the `devices`/`group_add` entries your hardware requires (see the hardware acceleration docs):
```yaml
services:
@@ -564,15 +562,14 @@ services:
:::note
`telemetry.stats.network_bandwidth` uses nethogs, which requires root with
NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]`
or add `cap_add: [NET_ADMIN, NET_RAW]`.
`telemetry.stats.network_bandwidth` uses nethogs, which requires root with NET_ADMIN/NET_RAW capabilities. If you enable that stat, omit `cap_drop: [ALL]` or add `cap_add: [NET_ADMIN, NET_RAW]`.
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups)
are called out in their own sections and are unaffected by this guidance.
Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups) are called out in their own sections and are unaffected by this guidance.
:::
Frigate's services run as an unprivileged user inside the container. See [Running as a non-root user](../configuration/non_root.md) for the run modes, the one time volume ownership migration, and what each accelerator needs on the host.
**Docker CLI**
If you can't use Docker Compose, you can run the container with something similar to this:
+1
View File
@@ -122,6 +122,7 @@ const sidebars: SidebarsConfig = {
"configuration/ffmpeg_presets",
"configuration/pwa",
"configuration/tls",
"configuration/non_root",
],
},
{
+26
View File
@@ -0,0 +1,26 @@
"""Tests for bandwidth stats privilege handling."""
import unittest
from unittest.mock import MagicMock, patch
from frigate.util import services
class TestBandwidthStatsPrivileges(unittest.TestCase):
def setUp(self):
services._bandwidth_warning_logged = False
@patch("frigate.util.services.sp.run")
@patch("frigate.util.services.os.geteuid", return_value=1000)
def test_returns_empty_and_warns_once_without_root(self, _, sp_run):
config = MagicMock()
with self.assertLogs("frigate.util.services", level="WARNING") as logs:
assert services.get_bandwidth_stats(config) == {}
assert services.get_bandwidth_stats(config) == {}
sp_run.assert_not_called()
warnings = [m for m in logs.output if "require root" in m]
assert len(warnings) == 1
if __name__ == "__main__":
unittest.main()
+15
View File
@@ -187,8 +187,23 @@ def get_physical_interfaces(interfaces) -> list:
return physical_interfaces
_bandwidth_warning_logged = False
def get_bandwidth_stats(config) -> dict[str, dict]:
"""Get bandwidth usages for each ffmpeg process id"""
global _bandwidth_warning_logged
if os.geteuid() != 0:
if not _bandwidth_warning_logged:
logger.warning(
"Network bandwidth stats require root (nethogs needs CAP_NET_ADMIN/CAP_NET_RAW) "
"and are disabled; set FRIGATE_RUN_AS_ROOT=true or disable "
"telemetry.stats.network_bandwidth to silence this warning"
)
_bandwidth_warning_logged = True
return {}
usages = {}
top_command = ["nethogs", "-t", "-v0", "-c5", "-d1"] + get_physical_interfaces(
config.telemetry.network_interfaces