Skip to main content

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.

The runtime user is uid/gid 1000:1000 by default. You can change it with PUID/PGID, or bypass Frigate's user handling entirely with Docker's own user:.

Most upgrades need nothing. Frigate aligns your volume ownership on the first boot and grants access to your hardware at startup. The sections below cover the cases that need attention: large storage volumes, network storage, and hardware the automatic grant can't reach.

Run modes​

ModeHow to enableOwnership of /config and /media/frigateread_only: true
Defaultnothing, this is the defaultAligned to 1000:1000 on first bootSupported
PUID/PGIDPUID=1001, PGID=1001Aligned to the values you set, on first bootNot supported
Docker-native useruser: "1001:1001"You own it, Frigate never changes ownershipSupported
Root (escape hatch)FRIGATE_RUN_AS_ROOT=trueNever touchedNot supported
Granular rootFRIGATE_ROOT_SERVICES=frigateAligned at boot; recordings and exports also at createNot 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. The default mode and Docker's user: mode both work with read_only: true; see 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.

Keeping individual services root​

FRIGATE_ROOT_SERVICES takes a comma separated list of frigate, go2rtc, and nginx. A listed service keeps running as root, and everything else about non-root operation still applies: PUID/PGID remapping, the ownership sweep, and ownership of the files those services create.

There are two reasons to use it:

  • Your detector hardware won't work as an unprivileged user, even after reading Hardware device access. FRIGATE_ROOT_SERVICES=frigate keeps the main process and its detectors as root while nginx and go2rtc stay unprivileged.
  • You want everything to run as root but still want your files owned by PUID/PGID instead of root. FRIGATE_ROOT_SERVICES=frigate,go2rtc,nginx does that.

Try the device grants and EXTRA_GROUPS first. The frigate service runs the API and every ffmpeg process that decodes your camera streams, so listing it puts those back on root as well, not just your detectors.

A listed service also stops honoring a custom ffmpeg or go2rtc build kept in /config, since that directory stays owned by the unprivileged user and a binary there would run as root. FRIGATE_RUN_AS_ROOT=true has no such restriction.

Recordings and exports are owned by PUID/PGID as soon as they're written, even by a root service. Snapshots, thumbnails, and other files under clips/ are corrected on each restart, so they can show as root-owned from the host until then. A listed service also keeps root's home directory, so library caches go to the container layer instead of /config. The same applies to the detector runtimes Frigate installs at first start (Hailo, MemryX, AXEngine): a root frigate service installs them into /root/.local, which is lost when the container is recreated, and never loads a copy left behind in /config/.local.

Listing all three services is not the same as FRIGATE_RUN_AS_ROOT=true. The escape hatch never touches ownership; the list keeps the ownership handling active. A few more details:

  • An unknown name in the list stops the container at startup, rather than silently leaving a service unprivileged.
  • Changing the list runs the full ownership sweep once on the next boot.
  • If both are set, FRIGATE_RUN_AS_ROOT=true wins and the list is ignored.
  • With Docker's user:, the list does nothing, since the container never has root to keep.

Migrating an existing install​

Volumes from earlier versions of Frigate are owned by root, so ownership has to be aligned with the runtime user once. This happens automatically on the first boot after upgrading.

On large recordings volumes, do it from the host beforehand instead. 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 watch 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 the Frigate repo and dry run it first:

./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:

./fix-permissions.sh /path/to/your/config /path/to/your/storage

Pass PUID and PGID as the third and fourth arguments if you're not using the default 1000:1000. The script wraps the same helper the container uses, so the result is identical either way. Override the image it pulls with FRIGATE_IMAGE=... if you're not on stable.

Both the script and the boot sweep report progress, so you can tell a slow sweep 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 because the total isn't known until it finishes. Watch the boot sweep with docker logs -f frigate.

Once the volumes are aligned, start Frigate normally. A file at /config/.permissions_version records what was done, so later boots skip the sweep unless you change PUID/PGID.

If something under your volumes can't be chowned, a read-only btrfs snapshot directory for example, the sweep warns and names the path and doesn't record the migration as finished. It retries on the next boot instead. Either move those paths outside /media/frigate or expect the scan to repeat.

Network storage​

Recordings on a NAS behave differently, so check what you have before migrating:

findmnt -T /path/to/your/storage -o TARGET,FSTYPE,OPTIONS

SMB and CIFS don't store per-file ownership at all. It's synthesized from the mount options, so a per-file chown fails and isn't needed. Mount the share as the uid and gid Frigate runs as, and every file already looks correct to the sweep:

//nas/frigate /media/frigate cifs credentials=/root/.smb,uid=1000,gid=1000,file_mode=0664,dir_mode=0775 0 0

NFS exports default to root_squash on most servers, which maps the container's root to nobody. The chown then fails, you get [WARN] fix-ownership: some entries under /media/frigate could not be updated, and since the sweep didn't finish it doesn't record the migration, so it retries on every boot.

The best fix is to not chown over NFS at all. Do it on the server, where there's no squash and no network round trip per file:

# on the NAS itself, against the exported directory
chown -R 1000:1000 /export/frigate

Frigate's sweep then finds nothing to change and records the migration normally. If you can't get a shell on the server, you can export temporarily with no_root_squash, migrate, and put it back, or leave ownership alone and set PUID/PGID to whichever uid already owns the files.

Either way the uid has to mean the same thing on both machines. NFS sends numeric uids, so container uid 1000 is uid 1000 on the server no matter what the usernames are.

Expect the first boot to be slow even when nothing needs changing, because checking ownership costs a round trip per file. That's a one-time cost. If the sweep runs on every boot rather than once, ownership isn't actually being applied, and the warning above will say so.

Keep /config on local storage either way. Frigate's database is SQLite and network shares handle its locking poorly. That's a long-standing recommendation, not something running non-root introduces.

Rolling back​

Set FRIGATE_RUN_AS_ROOT=true and restart. Everything runs as root again, exactly as it did before. This is the fastest way to get a broken install running while you sort out a device permission problem.

The escape hatch never changes ownership, and it clears the record of the last sweep on startup, so switching back to non-root later corrects whatever root created in the meantime. Toggling in either direction is safe.

Hardware device access​

Frigate grants the runtime user access to your devices at startup. Pass your hardware with --device (or devices: in compose) and detection and hardware acceleration work with no group or udev setup on the host.

The grant covers the common accelerator and camera nodes: GPU render nodes, Intel/AMD NPUs (/dev/accel), Coral, Hailo, Rockchip, Jetson, /dev/video*, and the USB bus. For hardware it misses, add your own paths with DEVICE_ACL_PATHS, a comma separated list of globs:

environment:
DEVICE_ACL_PATHS: "/dev/mydev*"

Set FRIGATE_DEVICE_ACLS=false if you manage device permissions yourself and want Frigate to leave them alone.

Frigate grants access by adding an ACL entry for the runtime users. The device's owner and mode are unchanged, and nothing is made world accessible. One thing to know: --device nodes belong to the container, but a bind mounted /dev/bus/usb (the usual Coral USB setup) shares the host's device nodes, so the entry is visible on the host until udev recreates the node.

Manual setup​

You only need this for hardware the automatic grant can't reach, or for Docker's user: mode, where there's no root startup to do the granting.

Your accelerator most likely worked in older versions because Frigate ran as root. Device nodes are usually owned by root:root, and root either matches the group or skips the check entirely. The runtime user does neither, so a device that worked before can become unreadable with no change to your Frigate config.

Read what your device requires​

Find the node and look at its owner, group, and mode:

ls -ln /dev/dri/renderD128
crw-rw---- 1 0 105 226, 128 Jul 5 10:12 /dev/dri/renderD128
# ^ ^ ^
# | | group GID 105
# | owner UID 0 (root)
# mode: owner rw, group rw, other none

Then work out which of the three permission sets applies to the runtime user. It isn't the owner, since that's root, so it gets the group bits if it belongs to that GID and otherwise falls through to "other". In the example above "other" is empty, so without membership in group 105 the runtime user can't open the node.

Watch for a node that looks permissive but isn't. A USB Coral defaults to this:

ls -ln /dev/bus/usb/004/003
crw-rw-r-- 1 0 0 189, 386 Jul 5 10:12 /dev/bus/usb/004/003

The group is 0, so "other" applies to the runtime user, and "other" here is read only. libedgetpu needs to write to the node, so detection fails with No EdgeTPU was detected as though no Coral were attached. Read access alone isn't enough for most accelerators.

Grant access​

Give the runtime user the GID with EXTRA_GROUPS, a comma separated list of numeric host GIDs. They're added to both the frigate and go2rtc users, which matters because go2rtc needs its own render and video access for hardware accelerated restreams.

environment:
EXTRA_GROUPS: "105,44" # host render and video GIDs

Use numeric GIDs from the host, not names. Group names don't have to match between the host and the container, and the kernel only checks the number. If the GID doesn't exist in the image, Frigate creates a placeholder group for it.

Two things that look like they should work but don't:

  • Docker's group_add has no effect in the default or PUID modes. Frigate rebuilds the supplementary group list from /etc/group when it drops privileges, which discards what Docker passed in. It is the right tool with Docker's user:, where no privilege drop happens and EXTRA_GROUPS does nothing.
  • privileged: true doesn't help. It grants capabilities to root, and the runtime user isn't root, so the file permissions on the node still apply.

If the node's group is root or the mode denies the group, no EXTRA_GROUPS value will help. You need a udev rule first.

Verify access​

Check the group landed, then check the runtime user can open the node. Test for write, not just read:

docker exec frigate id frigate
docker exec frigate /command/s6-setuidgid frigate sh -c 'test -w /dev/dri/renderD128 && echo ok'
docker exec frigate /command/s6-setuidgid go2rtc sh -c 'test -w /dev/dri/renderD128 && echo ok'

A permission check is only a proxy for the driver working. These exercise the real libraries as the runtime user:

docker exec frigate /command/s6-setuidgid frigate vainfo
docker exec frigate /command/s6-setuidgid frigate python3 -c "import openvino as ov; print(ov.Core().available_devices)"

vainfo should reach va_openDriver() returns 0 and list profiles. Complaints about XDG_RUNTIME_DIR or an X server above that are normal. OpenVINO should list GPU; if it returns only CPU, detection has fallen back and inference will be much slower without an error in the log.

To tell a permissions problem from anything else, start the container once with FRIGATE_RUN_AS_ROOT=true. If the device works as root and not otherwise, it's node permissions and a udev rule is the fix. If it's missing either way, the problem is your device mapping or the host, and isn't related to running non-root.

udev rules by device​

Rules go in /etc/udev/rules.d/ on the host and take effect after:

sudo udevadm control --reload-rules && sudo udevadm trigger

A device that's already connected sometimes keeps its original ownership through a trigger. If ls -ln doesn't show the new group, replug it, or reboot for a built-in device.

Coral USB needs two rules, because the device re-enumerates after loading firmware. It appears as Global Unichip 1a6e before and Google 18d1 after, with a different node each time. A rule covering only 1a6e gives you a Coral that starts up once and then disappears mid-run.

SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", GROUP="plugdev", MODE="0664"
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", GROUP="plugdev", MODE="0664"

Map the whole /dev/bus/usb rather than a single node, for the same reason. Most hosts put plugdev at GID 46 and the image agrees, so a USB Coral often needs no EXTRA_GROUPS entry. Confirm with getent group plugdev and add the number if your host differs.

Coral PCIe is often crw------- root root, which only root can open:

SUBSYSTEM=="apex", MODE="0660", GROUP="apex"

Create the group with sudo groupadd -f apex, then add its GID to EXTRA_GROUPS.

Hailo works the same way. Grant /dev/hailo0 a group and add that GID:

SUBSYSTEM=="hailo_chardev", MODE="0660", GROUP="hailo"

Intel and AMD GPUs usually need nothing beyond EXTRA_GROUPS, since most distributions ship a render group that owns /dev/dri/renderD128. The GID often differs between the host and the image, so pass the host's number rather than assuming the name resolves. Debian based images have no render group at all.

Quick reference​

What each device needs when you're setting it up by hand. The automatic grant covers most of these already, so start here only if it didn't.

HardwareDevice(s)What non-root needs
Intel/AMD GPU (VAAPI/QSV)/dev/dri/renderD128Host render GID in EXTRA_GROUPS, from getent group render
Intel/AMD NPU/dev/acceludev rule granting a group, then that GID in EXTRA_GROUPS
Coral USB/dev/bus/usbudev rules for both 1a6e and 18d1; usually already covered by plugdev 46
Coral PCIe/dev/apex_0udev rule granting a group, then that GID in EXTRA_GROUPS
Hailo/dev/hailo0udev rule granting a group, then that GID in EXTRA_GROUPS
NVIDIAnvidia runtimeNothing, works with the nvidia-container-toolkit defaults
AMD ROCm/dev/kfd, /dev/driHost video and render GIDs in EXTRA_GROUPS
Raspberry Pi/dev/video11Host video GID in EXTRA_GROUPS
Rockchip/dev/dri, /dev/dma_heap, /dev/rga, /dev/mpp_serviceCommonly root:root 0600, so all four need udev rules. If you can't grant all four, use FRIGATE_RUN_AS_ROOT
Axera (AXCL)/dev/ax_* per the AXCL driver docsUnverified. Check node ownership on your hardware before assuming this works
Synaptics SL1680per the Synaptics docsUnverified
MemryXper the MemryX docsStill requires privileged: true, which means root. Out of scope for non-root operation
Nvidia Jetsonnvidia runtime plus Jetson nodesUnverified. The nvidia runtime handles mapping, but check /dev/nvhost-* ownership on your board
VeriSilicon NPU (Teflon)per the driver, commonly /dev/galcoreUnverified. Check node ownership on your hardware before assuming this works
CPU detectornoneNothing, no device is opened
ZMQ detectornoneNothing, inference happens over a socket
Apple SiliconnoneNothing, 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.

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 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.

Detector runtimes that Frigate installs at first start (Hailo, MemryX, AXEngine) are staged in /tmp and installed into /config/.local, so they work with a read-only root filesystem in the default mode and under user:. A root frigate service installs into /root/.local instead, which a read-only root filesystem prevents; either leave frigate out of FRIGATE_ROOT_SERVICES or drop read_only.

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::

    user: "1000:1000" # NOT compatible with PUID/PGID, see the run modes table
tmpfs:
- /tmp:size=256m
- /tmp/cache:size=1000000000
- /run:exec,nosuid,nodev,mode=0755,uid=1000,gid=1000,size=16m # uid must match user:

/run has to be owned by that uid as well. s6 writes its runtime state there before anything else starts, and with no root in the container a root-owned /run stops it during init with cannot create /run/test of writability. Keep uid and gid in the tmpfs options matching user:, and don't carry that pair back into the default mode, where a root-owned /run is what keeps the unprivileged services out of s6's runtime state.

This only bites once root is genuinely gone. s6's init helper is setuid, so user: on its own still lets init regain root and correct /run itself. The no-new-privileges:true above is what blocks that, which is also what makes the /run ownership mandatory. Dropping it would hide the problem by handing init root again.

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. 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. Its SDK is installed into /config/.local like the other detector runtimes.

Network isolation​

Everything above limits what a compromised container can do to the host. It doesn't limit what your cameras can do to your network. Camera firmware is closed source, rarely patched, and not something you can audit, and none of it needs internet access for Frigate to work.

Put the cameras on their own VLAN or subnet, give the Frigate host a route into it, and deny that VLAN any route out. Frigate reaches in to pull streams, the cameras reach nothing. A second NIC on the Frigate host is the simplest version of this, and a tagged VLAN on the NIC you already have works just as well.

Here's the deny as nftables on the router, with cameras on vlan20 and the Frigate host at 192.168.10.5:

table inet cameras {
chain forward {
type filter hook forward priority filter; policy accept;

ct state established,related accept
iifname "vlan20" ip daddr 192.168.10.5 accept
iifname "vlan20" drop
}
}

It's in its own table so it can sit alongside an existing ruleset without touching it. Streams keep working because Frigate opens those connections and the return traffic is established. Cameras can still reach each other on their own VLAN, since that traffic never reaches the router, so use client isolation on the switch if that matters to you.

Two things break when you do this. The manufacturer's phone app stops working, which is the point, and camera clocks drift, because most of them set their time over NTP and are bad at it. Point them at an NTP server on your own network rather than opening the VLAN back up, or their timestamps and Frigate's will disagree.

Frigate itself needs some outbound access, though nearly all of it is optional. The startup version check is the only piece that's on by default, and telemetry.version_check: false turns it off. Everything else (model downloads for the enrichment features, push notifications, Frigate+, and cloud GenAI providers) only reaches out once you enable that feature. See Network Requirements for the full list and how to run fully offline.

For containers that only talk to each other, an internal compose network gets you the same isolation without involving the router:

services:
frigate:
networks: [default, iot]
# the rest of your frigate service
mosquitto:
image: eclipse-mosquitto
networks: [iot]

networks:
iot:
internal: true

internal: true gives that network no route off the host, so the broker isn't reachable from anywhere else on your LAN. Frigate sits on both networks and keeps its normal outbound path.

One Docker specific trap: published ports are inserted ahead of the host firewall, so ufw deny 8971 doesn't do what it looks like it does. Bind the port to the interface you want instead, like 127.0.0.1:8971:8971 for a reverse proxy on the same host, or your LAN address for everything else.

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.

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, which runs nginx. Frigate hands the key to that user at startup if the mount is writable; on a read-only mount, make the key readable by uid 1000 (or your PUID) yourself.

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.