From 6d45df8db32940318335de6cb4e413cb7dd70a88 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:26:27 -0500 Subject: [PATCH 01/14] Support read-only rootfs with self-signed certs in /config/tls --- .github/workflows/ci.yml | 2 +- .../etc/s6-overlay/s6-rc.d/certsync/run | 8 ++- .../rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 58 ++++++++++++++----- .../usr/local/nginx/templates/listen.gotmpl | 4 +- docs/docs/configuration/tls.md | 8 ++- 5 files changed, 57 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47e19af392..1c10831d87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,7 @@ jobs: # directive user; stdout discarded because -t reopens the config's # /dev/stdout logs and the docker exec pipe is root-owned 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 /config/tls/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: | diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run index d734aa9a73..7d0a3723c1 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run @@ -26,7 +26,13 @@ function reload_nginx() { echo "[INFO] Starting certsync..." -lefile="/etc/letsencrypt/live/frigate/fullchain.pem" +# Resolved once, to match the choice nginx made at its own startup: watching a +# path nginx did not load would compare fingerprints that can never agree. +if [ -f /etc/letsencrypt/live/frigate/fullchain.pem ]; then + lefile="/etc/letsencrypt/live/frigate/fullchain.pem" +else + lefile="/config/tls/fullchain.pem" +fi tls_enabled=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .tls.enabled` listen_external_port=`python3 /usr/local/nginx/get_nginx_settings.py | jq -r .listen.external_port` diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index ce9805a36a..61694e432a 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -80,22 +80,43 @@ cp -r /usr/local/nginx/conf/. /tmp/nginx/conf/ set_worker_processes -# ensure the directory for ACME challenges exists -mkdir -p /etc/letsencrypt/www - -# Create self signed certs if needed +# TLS certs: user-mounted certs at /etc/letsencrypt/live/frigate (documented +# contract) always win; otherwise fall back to a self-signed cert persisted in +# /config/tls, which stays writable under a read-only root filesystem. letsencrypt_path=/etc/letsencrypt/live/frigate -mkdir -p $letsencrypt_path +selfsigned_path=/config/tls -if [ ! \( -f "$letsencrypt_path/privkey.pem" -a -f "$letsencrypt_path/fullchain.pem" \) ]; then - echo "[INFO] No TLS certificate found. Generating a self signed certificate..." - openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ - -subj "/O=FRIGATE DEFAULT CERT/CN=*" \ - -keyout "$letsencrypt_path/privkey.pem" -out "$letsencrypt_path/fullchain.pem" 2>/dev/null - chmod 600 "$letsencrypt_path/privkey.pem" - chmod 644 "$letsencrypt_path/fullchain.pem" +if [ -f "$letsencrypt_path/privkey.pem" ] && [ -f "$letsencrypt_path/fullchain.pem" ]; then + cert_path="$letsencrypt_path" +else + cert_path="$selfsigned_path" + + # The ownership sweep hands /config to the runtime user, so root must not + # create or chmod anything here: that user could plant a symlink and + # redirect openssl onto any root path (both follow symlinks). Generating as + # the runtime user keeps every write inside the privileges it already has. + # The escape hatch never sweeps, so /config stays root-owned and root writes. + gen=() + if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then + gen=(s6-setuidgid frigate) + fi + + "${gen[@]}" mkdir -p "$cert_path" + + if [ ! \( -f "$cert_path/privkey.pem" -a -f "$cert_path/fullchain.pem" \) ]; then + echo "[INFO] No TLS certificate found. Generating a self signed certificate..." + "${gen[@]}" openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 \ + -subj "/O=FRIGATE DEFAULT CERT/CN=*" \ + -keyout "$cert_path/privkey.pem" -out "$cert_path/fullchain.pem" 2>/dev/null + "${gen[@]}" chmod 600 "$cert_path/privkey.pem" + "${gen[@]}" chmod 644 "$cert_path/fullchain.pem" + fi fi +# ACME challenges are only served from a writable rootfs; skipping the mkdir +# under read_only leaves the location 404ing, which is the same as unused +mkdir -p /etc/letsencrypt/www 2>/dev/null || true + # nginx settings are read once; both templates consume them nginx_settings=$(python3 /usr/local/nginx/get_nginx_settings.py) @@ -104,8 +125,10 @@ echo "$nginx_settings" | \ tempio -template /usr/local/nginx/templates/base_path.gotmpl \ -out /tmp/nginx/conf/base_path.conf -# build templates for additional network settings +# build templates for additional network settings; listen.conf is the only +# template that needs the resolved cert directory echo "$nginx_settings" | \ + jq --arg p "$cert_path" '.tls.cert_path = $p' | \ tempio -template /usr/local/nginx/templates/listen.gotmpl \ -out /tmp/nginx/conf/listen.conf @@ -118,9 +141,12 @@ if [[ "$(id -u)" -eq 0 && "$runs_as_root" -eq 0 ]]; then # nginx reopens /dev/stdout by path for its logs, and s6 made the pipe # root-owned 0600; without this the non-root master exits EACCES 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 + # Only mounted certs need handing over; the self-signed pair is already + # owned by the runtime user that generated it. Never chown the /config copy: + # chown follows symlinks, so it would retarget onto any root file the + # runtime user pointed it at. Tolerant because mounted certs may be :ro. + if [ "$cert_path" = "$letsencrypt_path" ] && [ -f "$cert_path/privkey.pem" ]; then + chown frigate:frigate "$cert_path/privkey.pem" "$cert_path/fullchain.pem" 2>/dev/null || true fi fi diff --git a/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl b/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl index 628784b609..5141227d5f 100644 --- a/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl +++ b/docker/main/rootfs/usr/local/nginx/templates/listen.gotmpl @@ -8,8 +8,8 @@ listen {{ .listen.internal }}; listen {{ .listen.external }} ssl; {{ if .ipv6.enabled }}listen [::]:{{ .listen.external_port }} ssl;{{ end }} - ssl_certificate /etc/letsencrypt/live/frigate/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/frigate/privkey.pem; + ssl_certificate {{ .tls.cert_path }}/fullchain.pem; + ssl_certificate_key {{ .tls.cert_path }}/privkey.pem; # generated 2024-06-01, Mozilla Guideline v5.7, nginx 1.25.3, OpenSSL 1.1.1w, modern configuration, no OCSP # https://ssl-config.mozilla.org/#server=nginx&version=1.25.3&config=modern&openssl=1.1.1w&ocsp=false&guideline=5.7 diff --git a/docs/docs/configuration/tls.md b/docs/docs/configuration/tls.md index 9757a78164..ea03d2645a 100644 --- a/docs/docs/configuration/tls.md +++ b/docs/docs/configuration/tls.md @@ -9,7 +9,7 @@ import NavPath from "@site/src/components/NavPath"; # TLS -Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates. +Frigate's integrated NGINX server supports TLS certificates. By default Frigate will generate a self signed certificate that will be used for port 8971, stored in `/config/tls` so it survives container recreation. Frigate is designed to make it easy to use whatever tool you prefer to manage certificates. Frigate is often running behind a reverse proxy that manages TLS certificates for multiple services. You will likely need to set your reverse proxy to allow self signed certificates or you can disable TLS in Frigate's config. However, if you are running on a dedicated device that's separate from your proxy or if you expose Frigate directly to the internet, you may want to configure TLS with valid certificates. @@ -45,7 +45,9 @@ frigate: ... ``` -Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`. +Within the folder, the private key is expected to be named `privkey.pem` and the certificate is expected to be named `fullchain.pem`. Mounted certificates take precedence over the self signed pair in `/config/tls`. + +`privkey.pem` must be readable by the runtime user that runs NGINX. Frigate hands it over at startup when the mount is writable; on a `:ro` mount, make it readable by uid 1000 (or your `PUID`) yourself. See [Running as a non-root user](/configuration/non_root). Note that certbot uses symlinks, and those can't be followed by the container unless it has access to the targets as well, so if using certbot you'll also have to mount the `archive` folder for your domain, e.g.: @@ -73,4 +75,4 @@ frigate: ## ACME Challenge -Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`. +Frigate also supports hosting the acme challenge files for the HTTP challenge method if needed. The challenge files should be mounted at `/etc/letsencrypt/www`. With a read-only root filesystem this has to be a mounted volume, since Frigate cannot create the directory itself. From 928d0bb11638fedbc8d8b9fa8d9326496f4addb2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:27:44 -0500 Subject: [PATCH 02/14] Support read-only rootfs in s6 and pre-compile bytecode --- docker/main/Dockerfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docker/main/Dockerfile b/docker/main/Dockerfile index 0059425ea2..a55d3b0a64 100644 --- a/docker/main/Dockerfile +++ b/docker/main/Dockerfile @@ -316,6 +316,9 @@ EXPOSE 8555/tcp 8555/udp ENV S6_LOGGING_SCRIPT="T 1 n0 s10000000 T" # Do not fail on long-running download scripts ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 +# Allow running with a read-only root filesystem: s6 copies its scan dir into +# /run and executes service scripts from there, so /run must allow exec +ENV S6_READ_ONLY_ROOT=1 ENTRYPOINT ["/init"] CMD [] @@ -387,3 +390,7 @@ FROM deps AS frigate WORKDIR /opt/frigate/ COPY --from=rootfs / / + +# Pre-compile bytecode so a read-only rootfs doesn't force re-parsing the +# source tree on every boot (pip-installed packages are already compiled) +RUN python3 -m compileall -q -j0 /opt/frigate/frigate From ae55a56e9db710122b378d8f897d954e45aff70c Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:29:44 -0500 Subject: [PATCH 03/14] Assert read-only rootfs support in CI --- .github/workflows/ci.yml | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c10831d87..6b444ef213 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -293,6 +293,57 @@ jobs: done if [ "$ok" -ne 1 ]; then echo "sentinel skip never logged"; docker logs frigate-puid; exit 1; fi docker rm -f frigate-puid + - name: Assert read-only rootfs with --user works + run: | + mkdir -p /tmp/frigate-config-ro /tmp/frigate-media-ro + printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-ro/config.yml + sudo chown -R 1000:1000 /tmp/frigate-config-ro /tmp/frigate-media-ro + # /run must allow exec: S6_READ_ONLY_ROOT has s6 copy its service + # scripts there and run them, and --tmpfs defaults to noexec + docker run -d --name frigate-ro --shm-size 256m \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,mode=0755 \ + --user 1000:1000 \ + --security-opt no-new-privileges:true \ + -v /tmp/frigate-config-ro:/config \ + -v /tmp/frigate-media-ro:/media/frigate \ + ${{ steps.setup.outputs.image-name }}-amd64 + up=0 + for i in $(seq 1 60); do + docker exec frigate-ro curl -fs http://127.0.0.1:5000/api/version && up=1 && break + sleep 5 + done + if [ "$up" -ne 1 ]; then echo "read-only container never healthy"; docker logs frigate-ro; exit 1; fi + # an if, not "! grep": bash exempts a negated command from set -e and + # the assertion would never fail + if docker logs frigate-ro 2>&1 | grep -i "read-only file system"; then + echo "a service tried to write to the read-only rootfs"; exit 1 + fi + # the self-signed cert has to land in /config, the only writable path + docker exec frigate-ro test -f /config/tls/privkey.pem + # and nginx must serve it, which is what proves the templated cert path + docker exec frigate-ro curl -ksSI https://127.0.0.1:8971/ >/dev/null + # logging must work via the s6-log fallback (no logutil-service as non-root) + docker exec frigate-ro test -s /dev/shm/logs/frigate/current + # runtime user can write recordings storage + docker exec frigate-ro touch /media/frigate/.write-probe + docker exec frigate-ro rm /media/frigate/.write-probe + docker rm -f frigate-ro + - name: Assert PUID with read-only fails fast with clear error + run: | + docker run -d --name frigate-ro-puid --shm-size 256m \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,mode=0755 \ + -e PUID=1500 -e PGID=1500 \ + -v /tmp/frigate-config-ro:/config \ + ${{ steps.setup.outputs.image-name }}-amd64 + found=0 + for i in $(seq 1 12); do + if docker logs frigate-ro-puid 2>&1 | grep -q "not compatible with read_only"; then found=1; break; fi + sleep 5 + done + if [ "$found" -ne 1 ]; then + echo "no fail-fast error for PUID with a read-only rootfs"; docker logs frigate-ro-puid; exit 1 + fi + docker rm -f frigate-ro-puid - name: Teardown if: always() run: docker rm -f frigate || true From 2bd259b4f21ee8dad929a3cefa23ba68dad4842f Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:32:59 -0500 Subject: [PATCH 04/14] Document hardened read-only deployment --- docs/docs/configuration/non_root.md | 53 +++++++++++++++++++++++++++-- docs/docs/frigate/installation.md | 2 +- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index b62b856291..1895c1b5af 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -17,11 +17,11 @@ Most upgrades need nothing. Frigate aligns your volume ownership on the first bo | ------------------- | ------------------------------- | ------------------------------------------------------ | ----------------- | | 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 | +| Docker-native user | `user: "1001:1001"` | You own it, Frigate never changes ownership | Supported | | Root (escape hatch) | `FRIGATE_RUN_AS_ROOT=true` | Never touched | Not supported | | Granular root | `FRIGATE_ROOT_SERVICES=frigate` | Aligned at boot; recordings and exports also at create | 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 stops at startup with a message pointing here. +`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 stops at startup with a message pointing here. Docker's `user:` mode has no such startup work, which is why it's the one mode that supports `read_only: true`; see [Hardened deployment](#hardened-deployment). `FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored. `FRIGATE_DEVICE_ACLS` works the same way: only the lowercase string `false` turns off the automatic device grants. @@ -263,6 +263,55 @@ What each device needs when you're setting it up by hand. The automatic grant co | ZMQ detector | none | Nothing, inference happens over a socket | | Apple Silicon | none | Nothing, the NPU client runs on the host and Frigate reaches it over the network | +## Hardened deployment + +Docker's `user:` mode is the only run mode that supports `read_only: true`. Together they're the strongest supported configuration: the container never starts as root, and it can't modify its own filesystem. + +```yaml +services: + frigate: + container_name: frigate + image: ghcr.io/blakeblackshear/frigate:stable + restart: unless-stopped + stop_grace_period: 30s + user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table + read_only: true + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + shm_size: "512mb" # size for your cameras, see the shm-size calculation + # Hardware: the automatic device grants need a root startup, which `user:` + # never has. Pass your devices as usual AND grant the uid access on the + # host yourself, with `group_add` or a udev rule. See Manual setup above. + volumes: + - /etc/localtime:/etc/localtime:ro + - /path/to/your/config:/config # must be writable by uid 1000 + - /path/to/your/storage:/media/frigate # must be writable by uid 1000 + tmpfs: + - /tmp:size=256m + - /tmp/cache:size=1000000000 # recording segments, sized as before + - /run:exec,mode=0755,size=16m + ports: + - "8971:8971" + - "8554:8554" # RTSP feeds + - "8555:8555/tcp" # WebRTC over tcp + - "8555:8555/udp" # WebRTC over udp +``` + +`/run` has to allow `exec`. With a read-only root filesystem s6 copies its service scripts into `/run` and runs them from there, and tmpfs mounts default to `noexec`. The equivalent for `docker run` is `--tmpfs /run:exec,mode=0755`. + +Size `/tmp` deliberately. It now carries nginx's config copy and its five proxy temp directories as well as the recording cache. Keeping `/tmp/cache` as its own nested tmpfs, as above, leaves your existing [cache sizing](/frigate/installation#storage) untouched and adds a small allowance for nginx. If you'd rather use one tmpfs over all of `/tmp`, size it as your cache budget plus roughly 50MB, or recordings begin failing once the cache fills. + +The self signed certificate is written to `/config/tls`, which stays writable. Certificates you mount at `/etc/letsencrypt/live/frigate` work unchanged and still take precedence. + +Soak a hardened deployment for 24 hours against real cameras before relying on it. A read-only root filesystem turns an occasional write into a failure that startup won't reveal. + +### Per-variant exceptions + +- **Rockchip** needs `- /sys/:/sys/:ro` alongside its device nodes, in addition to everything above. +- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which can't be combined with this layout. + ## Known limitations `telemetry.stats.network_bandwidth` uses nethogs, which needs `CAP_NET_ADMIN` and `CAP_NET_RAW` and therefore root. The stat is turned off automatically when Frigate isn't running as root, with one warning in the log. Use `FRIGATE_ROOT_SERVICES=frigate` (or `FRIGATE_RUN_AS_ROOT=true`) if you need it. diff --git a/docs/docs/frigate/installation.md b/docs/docs/frigate/installation.md index 24faf437bb..6bbab3acb9 100644 --- a/docs/docs/frigate/installation.md +++ b/docs/docs/frigate/installation.md @@ -568,7 +568,7 @@ Platforms that genuinely require `privileged: true` (MemryX, some QNAP setups) a ::: -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. +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, what each accelerator needs on the host, and the [hardened deployment](../configuration/non_root.md#hardened-deployment) layout with a read-only root filesystem. **Docker CLI** From 688c1571e418a91c5eec29c59070a267faf1e3fc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:43:23 -0500 Subject: [PATCH 05/14] keep certsync's cert selection identical to nginx's --- docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run index 7d0a3723c1..0c699c2db4 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/certsync/run @@ -26,9 +26,11 @@ function reload_nginx() { echo "[INFO] Starting certsync..." -# Resolved once, to match the choice nginx made at its own startup: watching a -# path nginx did not load would compare fingerprints that can never agree. -if [ -f /etc/letsencrypt/live/frigate/fullchain.pem ]; then +# Resolved once, and the condition must stay identical to the nginx run +# script's. Testing only fullchain.pem here would pick the mounted cert on a +# half-populated mount that nginx rejected, and the two fingerprints would then +# never agree, reloading nginx every cycle forever. +if [ -f /etc/letsencrypt/live/frigate/privkey.pem ] && [ -f /etc/letsencrypt/live/frigate/fullchain.pem ]; then lefile="/etc/letsencrypt/live/frigate/fullchain.pem" else lefile="/config/tls/fullchain.pem" From c602fe68c1cffca96764cb676f283b9a0c2b80f9 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:44:59 -0500 Subject: [PATCH 06/14] note the uid trade-off in user: mode --- docs/docs/configuration/non_root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index 1895c1b5af..a45b66680d 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -265,7 +265,7 @@ What each device needs when you're setting it up by hand. The automatic grant co ## Hardened deployment -Docker's `user:` mode is the only run mode that supports `read_only: true`. Together they're the strongest supported configuration: the container never starts as root, and it can't modify its own filesystem. +Docker's `user:` mode is the only run mode that supports `read_only: true`. The container never starts as root and can't modify its own filesystem. The trade-off is that every service runs as the single uid you pass, so go2rtc no longer gets its own more restricted user the way it does in the default mode. ```yaml services: From d04b14e3042a90134413ab5b873591a4d5e7ae8b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:37:31 -0500 Subject: [PATCH 07/14] fail fast when EXTRA_GROUPS or a missing media volume meets read_only --- .github/workflows/ci.yml | 17 +++++++++++++++++ .../etc/s6-overlay/s6-rc.d/init-usermod/run | 9 +++++++++ .../rootfs/etc/s6-overlay/s6-rc.d/prepare/run | 9 ++++++++- docs/docs/configuration/non_root.md | 2 +- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b444ef213..de01fcdd37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -344,6 +344,23 @@ jobs: echo "no fail-fast error for PUID with a read-only rootfs"; docker logs frigate-ro-puid; exit 1 fi docker rm -f frigate-ro-puid + - name: Assert EXTRA_GROUPS with read-only fails fast with clear error + run: | + docker run -d --name frigate-ro-groups --shm-size 256m \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,mode=0755 \ + -e EXTRA_GROUPS=44 \ + -v /tmp/frigate-config-ro:/config \ + -v /tmp/frigate-media-ro:/media/frigate \ + ${{ steps.setup.outputs.image-name }}-amd64 + found=0 + for i in $(seq 1 12); do + if docker logs frigate-ro-groups 2>&1 | grep -q "EXTRA_GROUPS needs a writable /etc"; then found=1; break; fi + sleep 5 + done + if [ "$found" -ne 1 ]; then + echo "no fail-fast error for EXTRA_GROUPS with a read-only rootfs"; docker logs frigate-ro-groups; exit 1 + fi + docker rm -f frigate-ro-groups - name: Teardown if: always() run: docker rm -f frigate || true diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run index ab93a6c9de..489aa6c664 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/init-usermod/run @@ -79,6 +79,15 @@ fi # EXTRA_GROUPS: numeric host GIDs granting device access (e.g. host render/video) if [[ -n "${EXTRA_GROUPS:-}" ]]; then + # groupadd and usermod -aG both write /etc/group. Checked up front so a + # read-only rootfs reports the real problem instead of dying mid-loop. + if [[ ! -w /etc/group ]]; then + echo "[ERROR] EXTRA_GROUPS needs a writable /etc and is not compatible with read_only: true." >&2 + echo "[ERROR] Use docker's group_add: with the same GIDs instead; it needs no writes inside the container." >&2 + echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2 + exit 1 + fi + for gid in ${EXTRA_GROUPS//,/ }; do if ! [[ "$gid" =~ ^[0-9]+$ ]] || [[ "$gid" -eq 0 ]]; then echo "[ERROR] EXTRA_GROUPS must be nonzero numeric GIDs, got '${gid}'" >&2 diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run index 343b2e1082..533133ed38 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/prepare/run @@ -189,7 +189,14 @@ fi # 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 + # The image does not ship this directory, so on a read-only rootfs it can + # only come from a mount. Report that rather than failing under errexit. + if ! mkdir -p /media/frigate 2>/dev/null; then + echo "[ERROR] /media/frigate does not exist and could not be created, which is what happens with read_only: true and no recordings volume." >&2 + echo "[ERROR] Mount a volume at /media/frigate." >&2 + echo "[ERROR] See https://docs.frigate.video/configuration/non_root for the compatibility matrix." >&2 + exit 1 + fi if [[ "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then chown "${PUID:-1000}:${PGID:-1000}" /media/frigate fi diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index a45b66680d..b1053a7746 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -21,7 +21,7 @@ Most upgrades need nothing. Frigate aligns your volume ownership on the first bo | Root (escape hatch) | `FRIGATE_RUN_AS_ROOT=true` | Never touched | Not supported | | Granular root | `FRIGATE_ROOT_SERVICES=frigate` | Aligned at boot; recordings and exports also at create | 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 stops at startup with a message pointing here. Docker's `user:` mode has no such startup work, which is why it's the one mode that supports `read_only: true`; see [Hardened deployment](#hardened-deployment). +`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 stops at startup with a message pointing here. `EXTRA_GROUPS` writes to `/etc/group` and stops the same way; use Docker's `group_add:` instead, which needs no writes inside the container. Docker's `user:` mode has no such startup work, which is why it's the one mode that supports `read_only: true`; see [Hardened deployment](#hardened-deployment). `FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored. `FRIGATE_DEVICE_ACLS` works the same way: only the lowercase string `false` turns off the automatic device grants. From 1dec5ac34c5d8e74b922d0eb1c9cf8d566c6afae Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:53:46 -0500 Subject: [PATCH 08/14] keep nosuid and nodev on the /run tmpfs --- .github/workflows/ci.yml | 6 +++--- docs/docs/configuration/non_root.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de01fcdd37..9c072417f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,7 +301,7 @@ jobs: # /run must allow exec: S6_READ_ONLY_ROOT has s6 copy its service # scripts there and run them, and --tmpfs defaults to noexec docker run -d --name frigate-ro --shm-size 256m \ - --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,mode=0755 \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \ --user 1000:1000 \ --security-opt no-new-privileges:true \ -v /tmp/frigate-config-ro:/config \ @@ -331,7 +331,7 @@ jobs: - name: Assert PUID with read-only fails fast with clear error run: | docker run -d --name frigate-ro-puid --shm-size 256m \ - --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,mode=0755 \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \ -e PUID=1500 -e PGID=1500 \ -v /tmp/frigate-config-ro:/config \ ${{ steps.setup.outputs.image-name }}-amd64 @@ -347,7 +347,7 @@ jobs: - name: Assert EXTRA_GROUPS with read-only fails fast with clear error run: | docker run -d --name frigate-ro-groups --shm-size 256m \ - --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,mode=0755 \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \ -e EXTRA_GROUPS=44 \ -v /tmp/frigate-config-ro:/config \ -v /tmp/frigate-media-ro:/media/frigate \ diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index b1053a7746..0d0ba2c47f 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -291,7 +291,7 @@ services: tmpfs: - /tmp:size=256m - /tmp/cache:size=1000000000 # recording segments, sized as before - - /run:exec,mode=0755,size=16m + - /run:exec,nosuid,nodev,mode=0755,size=16m ports: - "8971:8971" - "8554:8554" # RTSP feeds @@ -299,7 +299,7 @@ services: - "8555:8555/udp" # WebRTC over udp ``` -`/run` has to allow `exec`. With a read-only root filesystem s6 copies its service scripts into `/run` and runs them from there, and tmpfs mounts default to `noexec`. The equivalent for `docker run` is `--tmpfs /run:exec,mode=0755`. +`/run` has to allow `exec`. With a read-only root filesystem s6 copies its service scripts into `/run` and runs them from there, and tmpfs mounts default to `noexec`. The equivalent for `docker run` is `--tmpfs /run:exec,nosuid,nodev,mode=0755`. Spelling out `nosuid` and `nodev` matters: passing any tmpfs options replaces Docker's defaults instead of adjusting them, so asking for `exec` alone would drop those two as well. Size `/tmp` deliberately. It now carries nginx's config copy and its five proxy temp directories as well as the recording cache. Keeping `/tmp/cache` as its own nested tmpfs, as above, leaves your existing [cache sizing](/frigate/installation#storage) untouched and adds a small allowance for nginx. If you'd rather use one tmpfs over all of `/tmp`, size it as your cache budget plus roughly 50MB, or recordings begin failing once the cache fills. From 51863b6ea42c69b738ae668ad7e2ea3afd6c2980 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:57:27 -0500 Subject: [PATCH 09/14] support read_only in the default mode --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++ docs/docs/configuration/non_root.md | 32 ++++++++++++++++--------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c072417f6..dc5fb40ce8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -361,6 +361,43 @@ jobs: echo "no fail-fast error for EXTRA_GROUPS with a read-only rootfs"; docker logs frigate-ro-groups; exit 1 fi docker rm -f frigate-ro-groups + - name: Assert read-only rootfs in the default mode works + run: | + mkdir -p /tmp/frigate-config-rod /tmp/frigate-media-rod + printf 'mqtt:\n enabled: false\ncameras: {}\n' > /tmp/frigate-config-rod/config.yml + docker run -d --name frigate-rod --shm-size 256m \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \ + --security-opt no-new-privileges:true \ + -v /tmp/frigate-config-rod:/config \ + -v /tmp/frigate-media-rod:/media/frigate \ + ${{ steps.setup.outputs.image-name }}-amd64 + up=0 + for i in $(seq 1 60); do + docker exec frigate-rod curl -fs http://127.0.0.1:5000/api/version && up=1 && break + sleep 5 + done + if [ "$up" -ne 1 ]; then echo "read-only default-mode container never healthy"; docker logs frigate-rod; exit 1; fi + if docker logs frigate-rod 2>&1 | grep -i "read-only file system"; then + echo "a service tried to write to the read-only rootfs"; exit 1 + fi + # the point of this mode over docker's user:: the drop still happens + # and go2rtc still gets its own separate user + ps_out=$(docker exec frigate-rod ps -eo user=,comm=) + echo "$ps_out" + for svc in python3 nginx; do + if echo "$ps_out" | grep -w "$svc" | grep -q '^root'; then + echo "$svc is running as root"; exit 1 + fi + done + echo "$ps_out" | grep -w go2rtc | grep -q '^go2rtc' + # the ownership sweep still ran and recorded itself in /config + docker exec frigate-rod cat /config/.permissions_version | grep -qx "2:1000:1000" + # setfacl under a read-only rootfs, which nothing else covers: + # init-devices exits early under --user, so that path is never reached + docker exec frigate-rod mknod /dev/apex_9 c 120 99 + docker exec frigate-rod /etc/s6-overlay/s6-rc.d/init-devices/run + docker exec frigate-rod getfacl -p /dev/apex_9 | grep -q "user:frigate:rw-" + docker rm -f frigate-rod - name: Teardown if: always() run: docker rm -f frigate || true diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index 0d0ba2c47f..fe436db2d5 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -15,13 +15,13 @@ Most upgrades need nothing. Frigate aligns your volume ownership on the first bo | 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 | +| Default | nothing, this is the default | Aligned to `1000:1000` on first boot | 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 | Supported | | Root (escape hatch) | `FRIGATE_RUN_AS_ROOT=true` | Never touched | Not supported | | Granular root | `FRIGATE_ROOT_SERVICES=frigate` | Aligned at boot; recordings and exports also at create | 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 stops at startup with a message pointing here. `EXTRA_GROUPS` writes to `/etc/group` and stops the same way; use Docker's `group_add:` instead, which needs no writes inside the container. Docker's `user:` mode has no such startup work, which is why it's the one mode that supports `read_only: true`; see [Hardened deployment](#hardened-deployment). +`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 stops at startup with a message pointing here. `EXTRA_GROUPS` writes to `/etc/group` and stops the same way; use Docker's `group_add:` instead, which needs no writes inside the container. The default mode and Docker's `user:` mode both work with `read_only: true`; see [Hardened deployment](#hardened-deployment). `FRIGATE_RUN_AS_ROOT` is matched against the exact lowercase string `true`. `True`, `TRUE`, and `1` are all ignored. `FRIGATE_DEVICE_ACLS` works the same way: only the lowercase string `false` turns off the automatic device grants. @@ -265,7 +265,9 @@ What each device needs when you're setting it up by hand. The automatic grant co ## Hardened deployment -Docker's `user:` mode is the only run mode that supports `read_only: true`. The container never starts as root and can't modify its own filesystem. The trade-off is that every service runs as the single uid you pass, so go2rtc no longer gets its own more restricted user the way it does in the default mode. +A read-only root filesystem means the container can't modify itself, only the volumes you give it. It works in the default mode and under Docker's `user:`, but not with `PUID`/`PGID` or `EXTRA_GROUPS`, which both need to write to `/etc`. + +Start with the default mode. It keeps go2rtc on its own restricted user and still grants your hardware automatically, at the cost of a short root startup that finishes before any service runs. ```yaml services: @@ -274,20 +276,16 @@ services: image: ghcr.io/blakeblackshear/frigate:stable restart: unless-stopped stop_grace_period: 30s - user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table read_only: true security_opt: - no-new-privileges:true - cap_drop: - - ALL shm_size: "512mb" # size for your cameras, see the shm-size calculation - # Hardware: the automatic device grants need a root startup, which `user:` - # never has. Pass your devices as usual AND grant the uid access on the - # host yourself, with `group_add` or a udev rule. See Manual setup above. + devices: + - /dev/dri/renderD128:/dev/dri/renderD128 # your hardware, granted at startup volumes: - /etc/localtime:/etc/localtime:ro - - /path/to/your/config:/config # must be writable by uid 1000 - - /path/to/your/storage:/media/frigate # must be writable by uid 1000 + - /path/to/your/config:/config + - /path/to/your/storage:/media/frigate tmpfs: - /tmp:size=256m - /tmp/cache:size=1000000000 # recording segments, sized as before @@ -307,6 +305,18 @@ The self signed certificate is written to `/config/tls`, which stays writable. C Soak a hardened deployment for 24 hours against real cameras before relying on it. A read-only root filesystem turns an occasional write into a failure that startup won't reveal. +### Never starting as root + +To remove root from the container entirely, add Docker's `user:`: + +```yaml + user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table +``` + +Two things change. Every service then runs as that one uid, so go2rtc no longer gets its own restricted user. And the startup device grants can't run, because there is no root to run them, so pass your hardware with `group_add:` or a udev rule per [Manual setup](#manual-setup) instead. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. + +This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starting as root needs `CAP_CHOWN` for the ownership sweep, `CAP_SETUID` and `CAP_SETGID` to drop to the runtime user, and `CAP_FOWNER` for the device grants. + ### Per-variant exceptions - **Rockchip** needs `- /sys/:/sys/:ro` alongside its device nodes, in addition to everything above. From 19303df041d4aaa8438046aea2fa5b6a7f60b09e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:07:40 -0500 Subject: [PATCH 10/14] don't take go2rtc down when the homekit file isn't writable --- .github/workflows/ci.yml | 18 ++++++++++++++++++ .../rootfs/usr/local/go2rtc/prepare_homekit.py | 12 +++++++++++- docs/docs/configuration/non_root.md | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc5fb40ce8..73710a82ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -398,6 +398,24 @@ jobs: docker exec frigate-rod /etc/s6-overlay/s6-rc.d/init-devices/run docker exec frigate-rod getfacl -p /dev/apex_9 | grep -q "user:frigate:rw-" docker rm -f frigate-rod + - name: "Assert switching that install to user: still starts" + run: | + # the config dir above now holds a go2rtc-owned go2rtc_homekit.yml, + # which user: keeps readable but not writable (no supplementary groups) + docker run -d --name frigate-rod-user --shm-size 256m \ + --read-only --tmpfs /tmp:rw,size=1g --tmpfs /run:exec,nosuid,nodev,mode=0755 \ + --user 1000:1000 \ + -v /tmp/frigate-config-rod:/config \ + -v /tmp/frigate-media-rod:/media/frigate \ + ${{ steps.setup.outputs.image-name }}-amd64 + up=0 + for i in $(seq 1 60); do + docker exec frigate-rod-user curl -fs http://127.0.0.1:5000/api/version && up=1 && break + sleep 5 + done + if [ "$up" -ne 1 ]; then echo "container did not survive the switch to user:"; docker logs frigate-rod-user; exit 1; fi + docker logs frigate-rod-user 2>&1 | grep -q "HomeKit pairing changes will not persist" + docker rm -f frigate-rod-user - name: Teardown if: always() run: docker rm -f frigate || true diff --git a/docker/main/rootfs/usr/local/go2rtc/prepare_homekit.py b/docker/main/rootfs/usr/local/go2rtc/prepare_homekit.py index cda82f08cf..17e61b2bee 100644 --- a/docker/main/rootfs/usr/local/go2rtc/prepare_homekit.py +++ b/docker/main/rootfs/usr/local/go2rtc/prepare_homekit.py @@ -66,7 +66,17 @@ def main() -> int: path = sys.argv[1] do_chown = "--chown" in sys.argv[2:] - fd = open_nofollow(path) + try: + fd = open_nofollow(path) + except PermissionError: + print( + f"[WARN] {path} is not writable by uid {os.geteuid()}, so HomeKit " + "pairing changes will not persist. It is owned by the go2rtc user " + "from an earlier run in the default mode. To fix, on the host run: " + f"chown {os.geteuid()}:{os.getegid()} /{os.path.basename(path)}" + ) + return 0 + try: content = os.read(fd, MAX_BYTES).decode("utf-8", "replace") normalized = normalize(content) diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index fe436db2d5..65cdefbc1a 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -313,7 +313,7 @@ To remove root from the container entirely, add Docker's `user:`: user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table ``` -Two things change. Every service then runs as that one uid, so go2rtc no longer gets its own restricted user. And the startup device grants can't run, because there is no root to run them, so pass your hardware with `group_add:` or a udev rule per [Manual setup](#manual-setup) instead. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. +Two things change. Every service then runs as that one uid, so go2rtc no longer gets its own restricted user. And the startup device grants can't run, because there is no root to run them, so pass your hardware with `group_add:` or a udev rule per [Manual setup](#manual-setup) instead. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. Switching an existing install over also leaves `/config/go2rtc_homekit.yml` owned by the go2rtc user, which this mode can't write; `chown` it to your uid or HomeKit pairing changes stop persisting. Frigate warns and starts either way. This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starting as root needs `CAP_CHOWN` for the ownership sweep, `CAP_SETUID` and `CAP_SETGID` to drop to the runtime user, and `CAP_FOWNER` for the device grants. From 058af897691530719887351742b75e1567a804bc Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:10:34 -0500 Subject: [PATCH 11/14] lead with the hardware consequence of switching to user: --- docs/docs/configuration/non_root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index 65cdefbc1a..1bd466f169 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -313,7 +313,7 @@ To remove root from the container entirely, add Docker's `user:`: user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table ``` -Two things change. Every service then runs as that one uid, so go2rtc no longer gets its own restricted user. And the startup device grants can't run, because there is no root to run them, so pass your hardware with `group_add:` or a udev rule per [Manual setup](#manual-setup) instead. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. Switching an existing install over also leaves `/config/go2rtc_homekit.yml` owned by the go2rtc user, which this mode can't write; `chown` it to your uid or HomeKit pairing changes stop persisting. Frigate warns and starts either way. +Two things change, and the first one will break a working install if you skip it. The startup device grants can't run, because there is no root left to run them, so every device you pass stops working until you grant that uid access yourself with `group_add:` or a udev rule; see [Manual setup](#manual-setup). Expect this to surface as a driver error rather than a permission error, like `No VA display found` from VAAPI. And every service then runs as that one uid, so go2rtc no longer gets its own restricted user. `/config` and `/media/frigate` have to be owned by that uid already, since Frigate never adjusts ownership in this mode. Switching an existing install over also leaves `/config/go2rtc_homekit.yml` owned by the go2rtc user, which this mode can't write; `chown` it to your uid or HomeKit pairing changes stop persisting. Frigate warns and starts either way. This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starting as root needs `CAP_CHOWN` for the ownership sweep, `CAP_SETUID` and `CAP_SETGID` to drop to the runtime user, and `CAP_FOWNER` for the device grants. From f8918c9cca426eea1a5e77277045211c6f74f16b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:23:04 -0500 Subject: [PATCH 12/14] refuse to write TLS material through a symlink as root --- .../main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run index 61694e432a..a4d7998b56 100755 --- a/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run +++ b/docker/main/rootfs/etc/s6-overlay/s6-rc.d/nginx/run @@ -91,14 +91,20 @@ if [ -f "$letsencrypt_path/privkey.pem" ] && [ -f "$letsencrypt_path/fullchain.p else cert_path="$selfsigned_path" - # The ownership sweep hands /config to the runtime user, so root must not - # create or chmod anything here: that user could plant a symlink and - # redirect openssl onto any root path (both follow symlinks). Generating as - # the runtime user keeps every write inside the privileges it already has. - # The escape hatch never sweeps, so /config stays root-owned and root writes. + # Root writing into /config follows any symlink planted there, and /config + # is owned by whoever the host mount says, not by root. Generate as the + # runtime user wherever we are going to drop to it; the escape hatch keeps + # root all the way through, so that path is refused rather than dropped. gen=() if [[ "$(id -u)" -eq 0 && "${FRIGATE_RUN_AS_ROOT:-false}" != "true" ]]; then gen=(s6-setuidgid frigate) + elif [[ "$(id -u)" -eq 0 ]]; then + for link in "$cert_path" "$cert_path/privkey.pem" "$cert_path/fullchain.pem"; do + if [[ -L "$link" ]]; then + echo "[ERROR] ${link} is a symlink; refusing to write TLS material through it as root" >&2 + exit 1 + fi + done fi "${gen[@]}" mkdir -p "$cert_path" From 04872c58c920ff2be098ea2ceb1d6197102bd6b2 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:24:23 -0500 Subject: [PATCH 13/14] note that memryx writes models to the root filesystem --- docs/docs/configuration/non_root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/non_root.md b/docs/docs/configuration/non_root.md index 1bd466f169..e96f825eaa 100644 --- a/docs/docs/configuration/non_root.md +++ b/docs/docs/configuration/non_root.md @@ -320,7 +320,7 @@ This mode can also take `cap_drop: [ALL]`, which the default mode cannot: starti ### Per-variant exceptions - **Rockchip** needs `- /sys/:/sys/:ro` alongside its device nodes, in addition to everything above. -- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which can't be combined with this layout. +- **MemryX** and **QNAP Container Station** still require `privileged: true` per their own documentation, which gives back most of what this layout removes. MemryX also downloads its models to `/memryx_models` on the root filesystem, so it can't run read-only regardless. ## Known limitations From 63903ff6e79af789954e698cce185ddec0a97e9b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:57:53 -0500 Subject: [PATCH 14/14] certsync watches whichever cert path nginx loaded --- docs/docs/configuration/tls.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/configuration/tls.md b/docs/docs/configuration/tls.md index ea03d2645a..eb7d4ca053 100644 --- a/docs/docs/configuration/tls.md +++ b/docs/docs/configuration/tls.md @@ -61,7 +61,7 @@ frigate: ``` -Frigate automatically compares the fingerprint of the certificate at `/etc/letsencrypt/live/frigate/fullchain.pem` against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate. +Frigate automatically compares the fingerprint of the certificate it loaded, from either location, against the fingerprint of the TLS cert in NGINX every minute. If these differ, the NGINX config is reloaded to pick up the updated certificate. If you issue Frigate valid certificates you will likely want to configure it to run on port 443 so you can access it without a port number like `https://your-frigate-domain.com` by mapping 8971 to 443.