SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

What PUID and PGID do in Docker Compose

PUID and PGID are not Docker settings. They are an entrypoint convention in linuxserver.io images. Why bind mount files land as 911:911, and how to fix it.

What PUID and PGID actually are

PUID and PGID are two environment variables that certain container images read at startup. Docker itself never looks at them. They are a convention, used by linuxserver.io images and a handful of others, so an image that was not written to read them ignores them silently.

Inside a linuxserver.io image there is a user called abc, created at build time with UID (user ID) 911 and GID (group ID) 911. The container starts as root, runs its init scripts, and one of those scripts renumbers that user before the application starts:

groupmod -o -g "${PGID}" abc
usermod -o -u "${PUID}" abc

The -o flag allows an ID that is already in use elsewhere. After that the init drops privileges and runs the application as abc. So Docker passes PUID=1000 into the container like any other environment variable and never acts on it. The variable renumbers a user inside the container before the application starts, which means that on a standard rootful daemon without user namespace remapping, newly created files normally land on your disk owned by 1000. Writing an existing writable file does not change its owner. Rootless Docker shifts that number, as explained further down. Leave PUID unset and abc keeps 911, which is why an unconfigured bind mount fills up with files owned by 911:911.

Get your two numbers with id

Run this on the host, as the user who owns the data directories:

id
uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),988(docker)

uid is your PUID and gid is your PGID. For a script, id -u and id -g print the bare numbers. On most fresh VPS images the first human account is 1000:1000, but do not assume it. Allocation depends on the accounts and configuration present: a rebuilt server can allocate 1000 again, while a later account gets a higher available ID. If your services run under a dedicated service account instead of your own login user, run id thatuser and take the numbers from there.

Why your files show up as 911:911

ls -l prints a numeric ID instead of a name when no host account matches that ID. If no host account matches UID 911, there is no name to print. Use ls -ln to see numbers every time and remove the ambiguity:

ls -ln /srv/appdata/sonarr
drwxr-xr-x 2 911 911 4096 Aug  7 09:12 Backups
-rw-r--r-- 1 911 911  512 Aug  7 09:12 config.xml

That output matches the container's built-in defaults, but existing files alone do not prove which user the application runs as now. The config.xml in that listing is also the file that holds the authentication settings, which matters the first time you open the web UI and find that Sonarr and Radarr ship with no default username or password. Confirm it from inside the container rather than guessing:

docker exec sonarr id abc
docker compose logs sonarr | head -n 25

The linuxserver init prints its result in the startup log as two lines:

User UID:    911
User GID:    911

If those lines read 911 after you set PUID=1000 in your Compose file, the running container is not using the value you set. The usual cause is that you edited docker-compose.yml and then ran docker compose restart, which reuses the existing container with its original environment. Environment changes need docker compose up -d, which recreates the container. The other cause is deliberate: when an image runs in its read-only or non-root mode, the init skips the renumbering entirely, even though the variable is present in the container. In read-only mode the banner reports the unchanged ID of abc; in non-root mode it reports the owner of /run.

Why you cannot delete a file the container wrote

The kernel compares numbers, never names. Your shell runs as UID 1000. The file belongs to UID 911. The directory holding it is drwxr-xr-x and also belongs to 911, so group and other get read and execute but no write. Deleting a file requires write permission on its directory, not on the file, so you get this even when the file itself looks harmless:

rm: cannot remove '/srv/appdata/sonarr/config.xml': Permission denied

A writing container hits the same wall from the other side. If the host directory belongs to your user at mode 755 and the application runs as 911, its first write fails with Permission denied and the app reports it in its own words. In a .NET application such as Sonarr or Radarr that surfaces as UnauthorizedAccessException: Access to the path '/data/downloads' is denied. The permission string in front of the file tells you which of the three permission sets you are actually being judged by, and reading drwxr-xr-x correctly is what turns that error from mysterious into obvious.

This is a bind mount problem specifically. When Docker creates an empty named volume and mounts it over a path that exists in the image, it copies that path's contents into the volume, ownership and permission bits included, so the application finds a directory it already owns. A bind mount gets none of that treatment: Docker mounts your host directory exactly as it is. That difference is one of the practical reasons to know when a bind mount beats a named volume and when it does not.

Fixing a directory that is already wrong

Setting PUID and PGID changes what the application does from now on. It does not retroactively fix every file already on disk, although the init corrects ownership on specific paths described below. Stop the stack, correct the ownership yourself, then start it again:

docker compose down
sudo chown -R 1000:1000 /srv/appdata/sonarr
docker compose up -d

Use sudo chown -R "$(id -u):$(id -g)" /srv/appdata/sonarr if you would rather not type the numbers. Do this with the container stopped, because a running application that is mid-write during a recursive chown can end up with a half-corrected directory tree and a confusing second round of errors.

What PUID and PGID do not fix

Here is the part that catches people who did everything right. The linuxserver base image's user setup script chowns three paths on startup: /app, /config and /defaults. Your media mounts are not on that list. /data, /downloads and /tv are handed to the application untouched, so if the host side of those mounts has ownership the container user cannot write to, the container starts cleanly, prints the correct UID in its banner, and then fails on the first import.

That is the right behaviour. A recursive chown across a twelve terabyte media library at every container start would be a disaster. It does mean the media directories are your job, and they are the mounts where permissions actually go wrong. Because that kind of failure surfaces quietly in an application log hours after the container looked healthy, a periodic write test wired into ntfy on your own VPS, pushing alerts to your phone is a cheap way to hear about it before a week of missing episodes does the telling.

Three ways to control the user, and when each one applies

PUID and PGID environment variables

This works only on images whose entrypoint reads them. It is popular because the container still starts as root, does its own setup, fixes /config, and only then drops privileges. Docker Mods and custom init scripts keep working. The cost is that you are trusting a convention rather than a platform feature, and the variable names are not standard across projects.

The user: key in Compose

This one is a real Docker feature and works on every image, because the container runtime applies it before the image's own code runs:

services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    user: "1000:1000"

The process never runs as root, not even for a moment, which is a genuine security gain. It also breaks anything in the entrypoint that needed root. On linuxserver images the project supports this on a reasonable endeavours basis and only for images it has tested, and the caveats are specific: PUID and PGID stop having any effect, Docker Mods will not run, custom services will not run, and you become responsible for the permissions on every mounted volume. Their documented pattern pairs the flag with a writable /run:

user: 1000:1000
tmpfs:
  - /run:uid=1000,gid=1000,exec
security_opt:
  - no-new-privileges=true

One cosmetic side effect surprises people. A numeric user: that has no matching entry in the container's /etc/passwd makes tools inside report whoami: cannot find name for user ID 1000. The ID is valid and file access works normally. Only the name lookup fails.

Rootless Docker

Rootless Docker runs the daemon itself as your unprivileged user, so the daemon and containers run without host root privileges. It changes the ownership arithmetic completely. Container UID 0 maps to the host UID of the user running rootless Docker, and container UID n for any n of 1 or more maps to subuid + (n - 1), where subuid is the base of the range allocated to you in /etc/subuid; group IDs follow the same rule using /etc/subgid. Docker expects at least 65,536 subordinate IDs there.

Read that mapping again, because it inverts the usual advice. Under rootless Docker a container creating a file as root normally produces a file owned by you. A container creating a file as UID 1000 normally produces a file owned by a subordinate ID, such as 100999 when the range starts at 100000. Your shell's access depends on permissions and ACLs; deletion normally depends on the containing directory, so you can delete that file from a directory you control. So the PUID value that is correct on a rootful daemon is the wrong one here. The two mechanisms change ownership at different layers, and stacking them without checking can leave you unable to write to the mounted data. If you go rootless, test the ownership of one written file on your own server before you migrate a library into it.

For most self-hosted stacks on a single VPS, PUID and PGID on a rootful daemon is the pragmatic choice, because it is what the images are built and documented for. Reach for user: when the image README says that image is tested for it, or when you are running an official upstream image that has no PUID support at all. A document workspace such as a self-hosted AFFiNE instance on one VPS falls into that last case, because none of its containers read PUID and the ownership of its database directory and uploaded files is settled by the runtime rather than by anything in the environment block. The same is true of a self-hosted Chatwoot support desk, where the Rails container and its Sidekiq worker both write into one uploads directory and neither of them reads PUID, so that directory has to match whatever user the image already runs as. None of that changes for a newer stack either, so giving every person on the team their own sandboxed OneCLI agent leaves the per-person workspace directories and the Postgres data directory owned by the user each image already runs as, which makes it a user: and chown problem rather than a PUID one. Put a single self-hosted API in front of Codex, Claude Code and Hermes and its entrypoint manages several identities: the container must start as root, the console and gateway run as an unprivileged user, and each agent session runs as its own user. Do not add user: to that deployment; its entrypoint handles the data volume's ownership.

The media stack case: one group shared across containers

An arr media stack with Sonarr, Radarr and a download client is where this stops being theory. The download client writes a finished file into /data/downloads. Sonarr then hardlinks or moves that file into /data/media. For the hardlink to work the two containers need write access to the same tree, and if the download client runs as 1000 while Sonarr runs as 1001 without shared group write permissions, one of them owns files the other can only read.

The fix is a shared group that every container in the stack uses as its PGID:

sudo groupadd -g 13000 media
sudo usermod -aG media deploy
sudo chown -R deploy:media /srv/media
sudo find /srv/media -type d -exec chmod 2775 {} +
sudo find /srv/media -type f -exec chmod 0664 {} +

The leading 2 in 2775 is the setgid bit. On a directory it means every new file and subdirectory created inside inherits the group media instead of the creator's own primary group, so the arrangement survives new downloads without you re-running chown. Log out and back in, or run newgrp media, before you check your own access: a group added with usermod -aG does not appear in an already-open shell session.

Inside the container, groupmod -o -g 13000 abc renumbers the abc group to 13000, so abc writes with the same GID as your host media group. Every linuxserver container that accesses this tree keeps its own PUID and shares that one PGID. That includes containers further down the chain that only read the finished library, such as Jellyfin itself. A front end like Halcyon, which serves that library as a walkable 90s video store, connects to Jellyfin over HTTP using HALCYON_JELLYFIN_URL; its standard deployment needs neither the shared media PGID nor a media-directory mount.

Then set UMASK=002 on every linuxserver container in the stack. This is the step people miss. Left unset, the application keeps its inherited umask unless something else changes it, and the example value the image READMEs show is UMASK=022. With an effective umask of 022, an application requesting 0666 for a new file gets 0644 when there is no default ACL: the rest of the group can still read it but cannot change it, which undoes most of the sharing you just configured. With 002 and no default ACL, an application that requests the usual 0666 for files and 0777 for directories gets 0664 files and, inside the setgid tree, 2775 directories, and the group can write. A umask only removes bits, so an application that asks for 0600 still gets 0600, and a directory carrying a default ACL ignores the umask entirely. Add the variable next to PUID and PGID:

services:
  sonarr:
    image: lscr.io/linuxserver/sonarr:latest
    container_name: sonarr
    environment:
      - PUID=${PUID}
      - PGID=${PGID}
      - UMASK=002
      - TZ=Etc/UTC
    volumes:
      - /srv/appdata/sonarr:/config
      - /srv/media:/data
    restart: unless-stopped

Those two values belong in a .env file next to the Compose file, so the whole stack reads one definition:

PUID=1000
PGID=13000

Compose reads that file automatically for ${PUID} style substitution, which is the same mechanism you use for credentials. The habits around keeping values out of docker-compose.yml and into a .env file apply here too, with the difference that these two numbers are not secret.

Verify it end to end instead of trusting the config. Write a new file as the application's user, applying the configured umask, and read it from the host. Use a test filename that does not already exist:

docker exec --user abc sonarr sh -c 'umask "$UMASK" && touch /data/downloads/permtest'
ls -ln /srv/media/downloads/permtest

A healthy result shows your PUID as the owner, 13000 as the group, and -rw-rw-r-- as the mode. If the group reads 1000, run ls -ldn /srv/media/downloads: either the setgid bit (the s in drwxrwsr-x) is missing from that directory, or the directory itself still belongs to group 1000 instead of media, and the chown and chmod 2775 commands above fix both. If the mode reads -rw-r--r--, check the container's UMASK value and the directory's default ACL; recreate the container if its environment is stale. This test checks creation as abc with the configured mask; the application's own creation mode and effective umask still determine its files. Remove the test file with rm /srv/media/downloads/permtest when you are done.

Which images use which variable

linuxserver.io images use PUID, PGID and UMASK. Paperless-ngx uses different names for the same idea: USERMAP_UID and USERMAP_GID, both defaulting to 1000, and its documentation tells you to read them from id -u and id -g. Photo servers show the same spread: PhotoPrism has its own PHOTOPRISM_UID and PHOTOPRISM_GID pair, while Immich ships no equivalent and leaves the container user to Docker's user: key, so choosing between PhotoPrism and Immich also decides which of these mechanisms you will be maintaining for the largest library on the box. Many official upstream images, including the common database and web server images, ship a fixed built-in user and expect you to use user: or leave it alone. Small single-app deployments raise the same question, so when you stand up a self-hosted openGym workout tracker it is worth checking which user its container actually runs as before you point a bind mount at it, because the directory holding its database inherits that answer whether or not you set PUID. A remote access relay lands in the same category, so when you run your own RustDesk relay server the Ed25519 key pair that hbbs writes on first start arrives in your bind mount owned by whatever user that image ended with. You can correct that afterward with a host side chown, or choose the identity up front with the user: key, because that image sets no user of its own, as long as the mounted directory is writable by the ID you pick. The same applies to infrastructure you bolt on later, so putting Authentik in front of your apps for a single login means running official authentik and Postgres images that read no PUID at all, and their volume ownership comes from the users their processes run as and the setup their entrypoints perform. Authentik removed its Redis dependency in release 2025.10.

So check the README of each image before you copy an environment block between projects. Docker passes any environment variable you set into any container, whether or not something inside reads it, and a PUID that nothing consumes produces no error, no warning and no effect. The container starts as the user selected by Docker's user: setting or the Dockerfile, and its entrypoint can then drop privileges before starting the application. Do that reading before you add anything new to the box, including a self-hosted open-kritt security scanning stack, where the Compose file tells you whether the images honour PUID or whether the ownership of the directories you mount is fixed by the images themselves.

FAQ

Why are my Docker files owned by 911:911?

911 is the UID and GID of the abc user built into linuxserver.io images. Seeing it matches the built-in defaults, but files can retain that ownership after you change the container's PUID and PGID. ls -l shows the raw numbers when no account on your host matches those IDs, so there is no name to display. Set PUID and PGID to the output of id, recreate the container with docker compose up -d, then fix the existing files with sudo chown -R 1000:1000 on the affected directory.

Do PUID and PGID work on every Docker image?

No. They are not a Docker feature and Docker never reads them. They work only on images whose own entrypoint reads them and calls usermod and groupmod before starting the application, which is the linuxserver.io family and a few projects that copied the pattern. Other projects use other names, such as USERMAP_UID and USERMAP_GID in paperless-ngx. On an image that reads neither, the variables are accepted and ignored with no warning.

Should I use PUID and PGID or the user: key in Docker Compose?

Use PUID and PGID when the image supports them, because the entrypoint still runs as root long enough to fix /config and start its own services correctly. Use user: when the image documents support for the chosen user; lacking PUID support alone does not mean its entrypoint works without root. On a linuxserver image, setting user: makes PUID and PGID inert, stops Docker Mods and custom services from running, and makes every mounted volume's permissions your responsibility.

Sonarr has the right PUID but still cannot move files. What is wrong?

Check three things in order. First, the media mount itself: the base image's user setup script chowns /app, /config and /defaults, so /data or /downloads keeps whatever ownership it has on the host. Second, the shared group: give the containers that access the media tree the same PGID and group write permissions. Third, the umask: an effective mask of 022 produces 0644 files when the application requests 0666 and no default ACL applies, so the other containers in the group can read them but not modify them. Leaving UMASK unset does not guarantee a particular mask or file mode. Set UMASK=002 and set the setgid bit on the directories with chmod 2775 so new files inherit the group. An application requesting 0600 still creates private files, and a default ACL changes how creation permissions are assigned.

Was this article useful?