Skip to content
Go back

PUID and PGID: Container Permission Fights

By KingPin 12 min read
PUID and PGID: Container Permission Fights
Contents

Permission denied, again

Sonarr imports an episode. qBittorrent can’t delete the original. Jellyfin can’t see the new file. Everything is “running as the right user” and nothing works.

The cause is one fact: the kernel only knows numbers. It has no idea what sonarr or abc or you means. Every file has a numeric UID and GID, every process has a numeric UID and GID, and the permission check compares numbers. Names live in /etc/passwd files, and each container has its own.

You have two tools for aligning the numbers. The PUID/PGID environment variables are a convention that some images implement. Docker’s own user: directive can be applied to any image, but it comes with sharper edges. Pick the wrong one and the variable gets silently ignored. Your 2 AM self will appreciate knowing which is which.

The kernel only counts

Start with the host. These three commands show the numbers that matter:

Terminal window
id
# uid=1000(you) gid=1000(you) groups=1000(you),100(users)
ls -ln /srv/media
# drwxr-xr-x 2 1000 1000 4096 Nov 3 09:12 tv
stat -c '%u:%g %n' /srv/media/tv
# 1000:1000 /srv/media/tv

ls -n prints numeric IDs instead of names. Use it whenever a name looks right but access fails. Now look from inside a container:

Terminal window
docker exec sonarr id abc
# uid=911(abc) gid=911(abc) groups=911(abc),1000(users)

Ask for abc by name. A plain docker exec sonarr id reports root, because the container starts as root and only the app drops to abc. That app process runs as UID 911. If /srv/media/tv is owned by 1000:1000 with mode 755, UID 911 gets “other” permissions: read and traverse, no write. The name abc is a label in the container’s own /etc/passwd. The host has never heard of it. Only 911 matters.

Two containers writing to the same folder with different UIDs causes every fight. Fix the numbers and the fights stop.

How PUID and PGID work in LinuxServer.io images

LinuxServer.io (LSIO) images start as root, run an init sequence under s6-overlay, then drop to an unprivileged user named abc for the application. The init-adduser script in their base image does the UID swap. This is the relevant part of root/etc/s6-overlay/s6-rc.d/init-adduser/run in linuxserver/docker-baseimage-ubuntu:

init-adduser/run (excerpt)
PUID=${PUID:-911}
PGID=${PGID:-911}
if [[ -z ${LSIO_READ_ONLY_FS} ]] && [[ -z ${LSIO_NON_ROOT_USER} ]]; then
USERHOME=$(grep abc /etc/passwd | cut -d ":" -f6)
usermod -d "/root" abc
groupmod -o -g "${PGID}" abc
usermod -o -u "${PUID}" abc
usermod -d "${USERHOME}" abc
fi

Read that carefully, because it answers most questions:

  1. The default is 911 for both UID and GID when you set nothing.
  2. groupmod and usermod rewrite the abc account to your numbers. The -o flag allows a non-unique ID, so 1000 is fine even if another account already uses it.
  3. The block is skipped when the container runs read-only or as a non-root user.

Later in the same script, the init hands ownership of its own directories to abc:

init-adduser/run (excerpt, later)
if [[ -z ${LSIO_READ_ONLY_FS} ]] && [[ -z ${LSIO_NON_ROOT_USER} ]]; then
lsiown abc:abc /app
lsiown abc:abc /config
lsiown abc:abc /defaults
fi

lsiown is a small helper script that ships in the image. I read the copy inside linuxserver/sonarr. Without -R it sets -maxdepth 0, so it touches only the directory itself, and only when the owner or group differs. With -R it recurses. So the base init does a non-recursive chown of /app, /config and /defaults. It never touches your media mounts.

Individual apps add their own steps. The Sonarr image has an init-sonarr-config service that runs lsiown -R abc:abc /config, a recursive chown of the config directory. That is a few megabytes of database and logs, not a media library.

Finally, the app itself starts under s6-setuidgid abc. In the Sonarr image the service script runs s6-setuidgid abc /app/sonarr/bin/Sonarr -nobrowser -data=/config.

LSIO documents the convention on their Understanding PUID and PGID page: set PUID and PGID to the owner of your volume directories, and find yours with id your_user.

The trap: PUID is an image feature, not a Docker feature

Docker has no PUID setting. It is a plain environment variable. It does something only when the image’s startup code reads it. LSIO and hotio images do. Most others do not. Watch an image that ignores it:

Terminal window
docker run --rm -e PUID=1000 alpine id
# uid=0(root) gid=0(root) groups=0(root),...

Docker accepted the variable, shrugged, and ran as root. No warning, no error. The same applies to the official postgres, redis, nginx and most vendor images. If a README doesn’t mention PUID, assume it does nothing.

The other option: Docker’s user directive

user: in Compose (or --user on the CLI) tells Docker to start the container’s main process as that UID and GID. No init script, no usermod. The kernel is told the numbers up front.

compose.yml
services:
db:
image: postgres:17
user: "1000:1000"
volumes:
- /opt/stacks/db/data:/var/lib/postgresql/data

Docker applies this to any image, but not every image runs correctly that way. It has gotchas:

Terminal window
docker run --rm --user 1234:5678 alpine sh -c 'id; whoami; echo "HOME=$HOME"'
# uid=1234 gid=5678 groups=5678
# whoami: unknown uid 1234
# HOME=/

In an interactive bash shell this shows up as the prompt I have no name!@<container-id>:/$. Harmless, but it confuses people the first time. HOME is /, which is usually not writable, so tools that write dotfiles there will fail.

Can LSIO images use user: too?

Partly, and the story changed. The older PUID/PGID page still says LSIO images are “not yet compatible” with the Docker --user flag. Their newer non-root documentation describes a supported mode, on a “Reasonable Endeavours basis” and only for images they have tested. Images that support it note so in their readme. The Sonarr readme says “This image can be run with a non-root user”.

The limits are real:

It also covers the “both at once” question. I started the Sonarr image with PUID=1000, PGID=1000 and --user 1234:5678. The container printed a banner: running as a non-root user means “UMASK, custom services, & docker mod functionality will be disabled and the PUID/PGID environment variables will have no effect.” The user: value wins and the variables are dead weight. Set one or the other.

Sharing media between several containers

Matching one UID works when a single app owns the files. A media stack has Sonarr writing, qBittorrent writing, and Jellyfin reading. The cleanest fix is a shared group with a setgid directory.

  1. Pick a group ID for the library, say 1500, and create it on the host.
  2. Give every media container that PGID.
  3. Set the setgid bit on directories so new files inherit the group.
  4. Set UMASK=002 so new files are group-writable.
Terminal window
sudo groupadd -g 1500 media
sudo usermod -aG media you
sudo chgrp -R 1500 /srv/media
sudo find /srv/media -type d -exec chmod 2775 {} +
sudo find /srv/media -type f -exec chmod 664 {} +

The leading 2 in 2775 is the setgid bit. A file created inside such a directory gets the directory’s group instead of the creator’s primary group. Without it, a new file takes whatever group the writing process has, and group sharing falls apart one episode at a time.

LSIO images accept UMASK as an optional variable. Their docs describe it as the way to “override the default umask settings for services started within the containers”, with -e UMASK=022 as the example. When I started a Sonarr container with UMASK unset, umask reported 0022. That default strips group write from new files. Your shared stack needs 002.

Here is a Compose file with three services, using an x- extension field and a .env file so the numbers live in one place:

.env
PUID=1000
PGID=1500
TZ=Etc/UTC
compose.yml
x-lsio-env: &lsio-env
PUID: ${PUID}
PGID: ${PGID}
TZ: ${TZ}
UMASK: "002"
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
environment: *lsio-env
volumes:
- /opt/stacks/media/sonarr:/config
- /srv/media:/data
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
environment: *lsio-env
volumes:
- /opt/stacks/media/qbittorrent:/config
- /srv/media:/data
restart: unless-stopped
jellyfin:
image: lscr.io/linuxserver/jellyfin:latest
environment: *lsio-env
volumes:
- /opt/stacks/media/jellyfin:/config
- /srv/media:/data:ro
restart: unless-stopped

The latest tag for linuxserver/sonarr was 4.0.20.3014-ls2 when I checked. Pin a tag if you want fewer surprises on update day.

Mounting one /data root for both the downloader and the importer also keeps hardlinks working, because both paths sit on the same filesystem. LSIO’s Sonarr docs call out the same drawback for separate /tv and /downloads mounts.

NAS shares and NFS

On NFS, the server decides who your numbers become. With root_squash, a client’s root maps to an anonymous user. With all_squash, every client UID maps to one anonymous UID unless you set anonuid and anongid. Either way, a container that starts as root can lose its power to chown on the share.

The practical move is to find the owner numbers of the share and use them as PUID and PGID. Run ls -ln against the mount from the Docker host. On Synology, the first user you create is commonly 1026 and the built-in users group is commonly 100, but treat those as typical, not guaranteed. Read the actual numbers from your own NAS. SMB mounts are different again: ownership is set by the mount options (uid=, gid=), not by the files.

Fixing a tree that’s already broken

Stop the containers, then chown with numeric IDs so no name lookup can go wrong:

Terminal window
sudo chown -R 1000:1500 /srv/media
sudo find /srv/media -type d -exec chmod 2775 {} +

Do this once, by hand. Do not make a container do it on every start. A media library can hold hundreds of thousands of files, and a recursive chown walks every one of them. That is like hiring a forklift to move a couch: technically it works, but your startup time will have questions. It is also why LSIO’s base init stops at /app, /config and /defaults with a non-recursive lsiown, and why the only recursive chown in the Sonarr image targets the small config directory.

Rootless Docker and user namespaces

Rootless Docker and userns-remap shift IDs again. Container UID 1000 maps to a different, high-numbered UID on the host, taken from the subordinate ranges in /etc/subuid. A file the container writes as UID 1000 shows up on the host owned by something like 100999 (rootless) or 101000 (userns-remap), and your PUID math no longer lines up. The LSIO docs also point out that running as non-root and running rootless are different things. If you use either one, read the Docker docs on user namespaces first, then work out the mapped numbers before touching any chown.

Which one should you use?

  1. LSIO or hotio image: use PUID and PGID. The image is built for it, you get the config chown for free, and mods and custom services keep working.
  2. Official or vendor image: use user:. The variable does nothing there, so skip it. Make sure the data directory is already writable by that UID.
  3. Both on an LSIO image: don’t. The user: value wins and the banner tells you PUID and PGID are dead. Choose the non-root mode on purpose or not at all.
  4. Shared media: use one shared GID, setgid directories and UMASK=002. Individual UIDs are fine. The group does the sharing.

Common Questions

Does PUID work with the official Postgres image?

No. The official Postgres image does not read PUID or PGID, so Docker passes them as ordinary environment variables and nothing happens. Set user: "1000:1000" in Compose instead, and make sure the data directory is already owned by that UID. The image docs cover this under “Arbitrary --user Notes”.

What happens if I set both PUID and user: on a LinuxServer.io image?

The user: value wins and PUID and PGID are ignored. In testing, a Sonarr container started with --user printed a banner saying the PUID/PGID variables “will have no effect”. The container also disables UMASK, custom services and Docker Mods in that mode. Pick one method per container.

What are the default PUID and PGID in LinuxServer.io images?

The default is 911 for both. The init-adduser script sets PUID=${PUID:-911} and PGID=${PGID:-911}, so a container with neither variable runs its app as UID 911 and GID 911. Files created on a bind mount then belong to 911, which usually matches no real account on your host.

Do I need to chown my media library when I change PUID?

Yes, once. LinuxServer.io containers chown only their own /app, /config and /defaults directories, and Sonarr also recurses through /config. Nothing walks your media mounts. After changing PUID, run chown -R with numeric IDs on the library yourself, with the containers stopped.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
What Your Reverse Proxy Leaks by Default

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts