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.
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.
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.
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.
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.
PUID is not a Docker featureTake 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
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.
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 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.
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
PUID on an image you did not check. If it is not a
LinuxServer.io image, assume the variable is decorative until docker exec … id proves
otherwise. Prefer user: in compose. The runtime enforces that one.ERROR line buried under a flood of INFO, while the app's own status display said
"100%".chmod 777. That is what hid this for months. The symptom
disappears and the misconfiguration stays in place, ready to resurface the next time something tightens a
mode.PUID-ignored bug had already
been noticed once and written off as cosmetic. "It keeps running as root." Nothing was broken at the time.
It was not cosmetic. It was this outage, waiting.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.