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
+124 -1
View File
@@ -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: |
@@ -293,6 +293,129 @@ 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,nosuid,nodev,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,nosuid,nodev,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: 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,nosuid,nodev,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: 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: "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
+7
View File
@@ -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
@@ -26,7 +26,15 @@ function reload_nginx() {
echo "[INFO] Starting certsync..."
lefile="/etc/letsencrypt/live/frigate/fullchain.pem"
# 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"
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`
@@ -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
@@ -80,22 +80,49 @@ 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"
# 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"
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 +131,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 +147,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
@@ -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
@@ -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()} <your config dir>/{os.path.basename(path)}"
)
return 0
try:
content = os.read(fd, MAX_BYTES).decode("utf-8", "replace")
normalized = normalize(content)
@@ -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
+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.
+6 -4
View File
@@ -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.:
@@ -59,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.
@@ -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.
+1 -1
View File
@@ -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**