PUID and PGID Ignored: Docker UID Mapping in an Unprivileged LXCRadarr sat on three finished downloads for two weeks with importPending.
Unpackerr reported Extraction Failed — at 100% extracted. Nothing was broken in either app.
The cause was that PUID and PGID are not a Docker feature, and 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.
The stack: Radarr, Sonarr, Lidarr,
Prowlarr and qBittorrent as LinuxServer.io Docker images, plus
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, and the default INFO log level doesn't 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 renames the result back into the release folder. It could create its own directory. It could
not write into the one qBittorrent made.
On the NAS, list the two directories with numeric owners — -n matters, because names would
be resolved through the NAS's own user database and hide 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.
An unprivileged LXC container maps container uids into an unused range on the host, conventionally starting at 100000. That's the security property: container root isn't host root. Anything the container writes to a network share arrives with the mapped uid.
PUID is not a Docker featureThis is the part worth taking away, because 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 wasn't built to read it will accept the
variable, ignore it completely, and run 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,
and they did nothing at all. Setting a variable an image ignores fails silently and looks like
configuration.
Check what a container is actually running as, rather than what you asked for:
docker exec unpackerr id
# uid=0(root) gid=0(root) groups=0(root) <- not what the compose file says
The misconfiguration was there from day one and harmless for months, because the download folders used
to be mode 777. World-writable means uid is irrelevant. Sorting the share by modification
time shows the break 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 because most releases aren't RAR'd, so only archived releases touched the broken
path at all.
no_root_squash red herringThe 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 will tell you that permission denied plus root equals a
root_squash problem. It's a dead end here, and understanding why is useful:
an unprivileged LXC never sends uid 0 in the first place. Container root is already
mapped to 100000 before the request reaches NFS, so there is no root for the server to squash.
no_root_squash is neither the problem nor the fix, and toggling it changes nothing except
your security posture.
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 regardless of what 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
PUID on an image you didn't check. If it isn't a
LinuxServer.io image, assume the variable is decorative until docker exec … id proves
otherwise. Prefer user: in compose, which is enforced by the runtime.ERROR line buried under a flood of INFO, while the app's own status display
said "100%".chmod 777. That's what hid this for months. It makes
the symptom disappear while leaving the actual misconfiguration in place, ready to resurface the next
time something legitimately tightens a mode.PUID-ignored bug had already
been noticed once and written off as cosmetic — "it keeps running as root" — because nothing was broken
at the time. It wasn't cosmetic; it was this outage, waiting.PUID/PGID only work on images built to
read them, an unprivileged LXC adds 100000 to every uid the storage sees, and the two together mean a
container you believe is user 1000 is really writing as a user that owns nothing. ls -ln on
the share, and docker exec … id in the container, answer it in ten seconds.