Container security hardening (phase 4) (#24140)

* Support read-only rootfs with self-signed certs in /config/tls

* Support read-only rootfs in s6 and pre-compile bytecode

* Assert read-only rootfs support in CI

* Document hardened read-only deployment

* keep certsync's cert selection identical to nginx's

* note the uid trade-off in user: mode

* fail fast when EXTRA_GROUPS or a missing media volume meets read_only

* keep nosuid and nodev on the /run tmpfs

* support read_only in the default mode

* don't take go2rtc down when the homekit file isn't writable

* lead with the hardware consequence of switching to user:

* refuse to write TLS material through a symlink as root

* note that memryx writes models to the root filesystem

* certsync watches whichever cert path nginx loaded
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent b99c87f272
commit 41bc1a5844
11 changed files with 287 additions and 30 deletions
+62 -3
View File
@@ -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 | 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. `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.
@@ -263,6 +263,65 @@ 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
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:
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:stable
restart: unless-stopped
stop_grace_period: 30s
read_only: true
security_opt:
- no-new-privileges:true
shm_size: "512mb" # size for your cameras, see the shm-size calculation
devices:
- /dev/dri/renderD128:/dev/dri/renderD128 # your hardware, granted at startup
volumes:
- /etc/localtime:/etc/localtime:ro
- /path/to/your/config:/config
- /path/to/your/storage:/media/frigate
tmpfs:
- /tmp:size=256m
- /tmp/cache:size=1000000000 # recording segments, sized as before
- /run:exec,nosuid,nodev,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,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.
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.
### 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, 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.
### 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 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
`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.