← Back to Wiki
Docker / Proxmox / Storage

PUID and PGID Ignored: Docker UID Mapping in an Unprivileged LXC

Radarr sat on three finished downloads for two weeks with importPending. Unpackerr reported Extraction Failed at 100% extracted. Nothing was broken in either app. PUID and PGID are not a Docker feature. The container had been running as a user nobody intended, on the far side of a uid offset that only exists because the container is unprivileged.

Share on X

The symptom

The stack. Radarr, Sonarr, Lidarr, Prowlarr and qBittorrent as LinuxServer.io Docker images. Unpackerr (golift/unpackerr) to unpack RAR'd releases. All inside an unprivileged Proxmox VE LXC container, writing to a Synology NAS over NFS.

Radarr's queue:

Young.Guns.1988.COMPLETE.UHD.BLURAY-B0MBARDiERS | importPending
  statusMessages: ["Found archive file, might need to be extracted"]

Unpackerr, on the same release:

[Radarr] Status: Young.Guns.1988.COMPLETE.UHD.BLURAY-B0MBARDiERS
  (Extraction Failed, elapsed: 4h25m7s) on archive: 1/1 @ 90.1GB/90.1GB (100%)

"Extraction Failed" at "100%" is the tell. Extraction had worked perfectly. Something after extraction failed. The default INFO log level does not show it. You have to go looking:

docker logs unpackerr 2>&1 | grep -i error
[ERROR] Extraction Failed: Young.Guns...-B0MBARDiERS: extraction failed:
  os.Rename(): rename /downloads/movies/Young.Guns...-B0MBARDiERS_unpackerred/YOUNG_GUNS_UHD.iso
            -> /downloads/movies/Young.Guns...-B0MBARDiERS/YOUNG_GUNS_UHD.iso: permission denied

Unpackerr extracts into a temporary <release>_unpackerred/ directory it creates itself. Then it renames the result back into the release folder. It could create its own directory. It could not write into the one qBittorrent made.

The one command that shows the whole bug

On the NAS, list the two directories with numeric owners. -n matters. Names get resolved through the NAS's own user database, which hides the numbers that are the point:

ls -ln /volume1/downloads/movies/ | grep B0MBARDiERS
drwxr-xr-x 1 101000 101000  Young.Guns.1988...-B0MBARDiERS
drwxr-xr-x 1 100000 100000  Young.Guns.1988...-B0MBARDiERS_unpackerred

Two different owners, 1000 apart, for two directories in the same job. That gap is the entire problem.

Why the numbers look like that

An unprivileged LXC container maps container uids into an unused range on the host, conventionally starting at 100000. That is the security property. Container root is not host root. Anything the container writes to a network share arrives with the mapped uid.

inside the unprivileged LXC as the NAS sees it qBittorrent / Radarr / Sonarr uid 1000 (PUID honoured) uid 101000 ← owns the folder unpackerr (PUID ignored) uid 0, root uid 100000 ← not the owner drwxr-xr-x owner 101000 → 100000 falls through to "other" = r-x → no write → permission denied
The uid offset. Both containers are in the same LXC. One runs as uid 1000 and lands on 101000. The other runs as root and lands on 100000. The NAS applies ordinary Unix permissions to two users that, as far as it knows, are unrelated.

The actual root cause: PUID is not a Docker feature

Take this part away with you. It applies far beyond this stack.

PUID and PGID are a LinuxServer.io convention. Their images ship an init step that reads those variables and switches to that user before starting the app. Docker itself does not know what PUID means. An image that was not built to read it accepts the variable, ignores it, and runs as whatever its Dockerfile says. Usually root.

golift/unpackerr is not a LinuxServer.io image. The compose file had PUID=1000 and PGID=1000 set, looking exactly like the containers around it. They did nothing at all. Setting a variable an image ignores fails silently and looks like configuration.

Check what a container actually runs as, not what you asked for:

docker exec unpackerr id
# uid=0(root) gid=0(root) groups=0(root)     <- not what the compose file says

Why it broke when it did, not when it was configured

The misconfiguration was there from day one and harmless for months. The download folders used to be mode 777. World-writable means uid is irrelevant. Sort the share by modification time and the break shows up cleanly:

drwxrwxrwx  Jun 30, Jul 5, Jul 8      <- extracted fine (world-writable)
drwxr-xr-x  Jul 20, Jul 27, Jul 30     <- "Extraction Failed"

Something tightened the mode from 777 to 755. Most likely a qBittorrent image update changing its umask, though that was never confirmed. A latent misconfiguration plus an unrelated, reasonable-looking permissions change equals a bug that appears to come from nowhere. Nobody noticed for two weeks. Most releases are not RAR'd, so only archived releases touched the broken path at all.

The no_root_squash red herring

The NFS export on the NAS looked like it should make root all-powerful:

/volume1/downloads  10.0.1.43(rw,async,no_wdelay,crossmnt,insecure,no_root_squash,...)

Plenty of guides tell you that permission denied plus root equals a root_squash problem. It is a dead end here. An unprivileged LXC never sends uid 0 in the first place. Container root is already mapped to 100000 before the request reaches NFS. There is no root left for the server to squash. no_root_squash is neither the problem nor the fix. Toggling it changes nothing except your security posture.

The fix

Tell Docker directly instead of asking the image nicely. The user: key is enforced by Docker, not by the image's entrypoint. So it works whatever conventions the image follows:

services:
  unpackerr:
    image: golift/unpackerr
    user: "1000:1000"        # enforced by Docker. PUID/PGID were not
    environment:
      - UN_RADARR_0_URL=...

Or on the command line: docker run --user 1000:1000 …. Then confirm it took effect, and confirm the ownership the NAS sees:

docker exec unpackerr id          # expect uid=1000
ls -ln /volume1/downloads/movies  # expect 101000 on new directories

Preventing the whole class of problem

The one-line version: PUID and PGID only work on images built to read them. An unprivileged LXC adds 100000 to every uid the storage sees. Put those together and a container you believe is user 1000 is really writing as a user that owns nothing. Run ls -ln on the share and docker exec … id in the container. Ten seconds, answered.