quadlet: drop the userns identity model — rootless cannot map uid 997

Evidence from the failed first cutover: podman top shows container root
maps to dank (1000) under UserNS=host rootless (subuid range 100000:65536
cannot express 997), so PUID=997 fell back to root and PMS crash-looped on
write access to its 997:984-owned data root. Fleet model instead: PUID=0/
PGID=0 (container root = dank, same as jellyfin/copyparty data roots) and
deploy_plex chowns /var/lib/plexmediaserver to 1000:1000 inside the
first-cutover block. Zero bytes moved; rollback regains one chown -R
997:984 step, documented in Plex.md. Seam test pins PUID=0/PGID=0 and the
absence of any userns key.
This commit is contained in:
Frank
2026-09-01 22:46:00 -07:00
parent 1939135e4c
commit 58aa084d9c
6 changed files with 36 additions and 28 deletions
+3 -4
View File
@@ -1,14 +1,13 @@
[Unit]
Description=Plex Media Server (quadlet, userns=host)
Description=Plex Media Server (quadlet, rootless fleet identity)
After=network-online.target
Wants=network-online.target
[Container]
Image=lscr.io/linuxserver/plex:1.43.3.10896-cb3ebc72d-ls322
ContainerName=plex
UserNS=host
Environment=PUID=997
Environment=PGID=984
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/Los_Angeles
Volume=/var/lib/plexmediaserver:/config
Volume=/mnt/entropy:/mnt/entropy:ro
@@ -1,14 +1,13 @@
[Unit]
Description=Tautulli (quadlet, userns=host)
Description=Tautulli (quadlet, rootless fleet identity)
After=network-online.target
Wants=network-online.target
[Container]
Image=lscr.io/linuxserver/tautulli:v2.18.1-ls242
UserNS=host
Network=dankserver_appnet:alias=tautulli
Environment=PUID=1000
Environment=PGID=1000
Environment=PUID=0
Environment=PGID=0
Environment=TZ=America/Los_Angeles
Volume=/home/dank/Containers/tautulli:/config
PublishPort=8181:8181
+7 -2
View File
@@ -672,10 +672,15 @@ deploy_plex() {
if ! systemctl --user is-active -q plex.service; then
# First cutover: retire the native package unit BEFORE starting the
# container — both cannot own 32400. Mask (not disable): on these hosts
# `systemctl disable` deletes the unit file itself; mask keeps an
# instant byte-safe rollback while nothing is signed off yet.
# `systemctl disable` deletes the unit file itself; mask keeps a
# one-command rollback while nothing is signed off yet.
run_privileged systemctl stop plexmediaserver.service
run_privileged systemctl mask plexmediaserver.service
# Rootless identity model: container root maps to the dank user
# (uid 1000) — real uid 997 is not mappable rootless. Adopt the data
# root into the fleet convention (jellyfin et al. run as 1000:1000).
# Rollback chowns it back to 997:984 before restarting the native unit.
run_privileged chown -R 1000:1000 /var/lib/plexmediaserver
fi
systemctl --user restart plex.service
# Readiness gate: /identity answers MediaContainer XML once PMS is up;
+11 -8
View File
@@ -18,7 +18,7 @@ public traffic can be fronted by Caddy like every other vhost.
| Image | `lscr.io/linuxserver/plex:1.43.3.10896-cb3ebc72d-ls322` (pinned; verified == the ghcr `latest` manifest digest at pin time) |
| Data root | `/var/lib/plexmediaserver` bind-mounted **in place** as `/config` (RW) — the migration moved zero bytes |
| Media | `/mnt/entropy` mounted at the same absolute path **read-only** — every library path in the PMS database stayed valid, zero re-scan |
| Identity | `UserNS=host` (quadlet key for `--userns=host`) + `PUID=997 PGID=984` — container writes land owned 997:984, byte-compatible with the former native unit |
| Identity | fleet rootless model — container root maps to the `dank` user (1000:1000); the deployer chowns the data root on first cutover |
| Port | `32400/tcp` published on `0.0.0.0` — zero client reconfiguration |
| Network | `dankserver_appnet`, alias `plex`; Tautulli reaches it at `http://plex:32400` |
| GPU | none by decision — CPU decode only; the RTX 3090 stays dedicated to `frank-default-small` |
@@ -30,18 +30,21 @@ public traffic can be fronted by Caddy like every other vhost.
The migration moved zero bytes: the container bind-mounts the native data
root where it already lived, and the media archive re-mounts at
`/mnt/entropy` inside the container, so every absolute library path in the
PMS database keeps resolving. `UsernsMode=host` + `PUID=997/PGID=984` makes
the container's file identity indistinguishable from the old service's —
no chown, no re-scan. Rollback is byte-safe at any time before decommission.
PMS database keeps resolving. The one metadata change: rootless podman
cannot map the real host uid 997, so the fleet model applies — container
root runs as the `dank` user, and the deployer adopts the data root with
`chown -R 1000:1000` on first cutover (rollback chowns it back).
## Cutover & rollback
- First deploy stops and **masks** `plexmediaserver.service` (never
`disable` — on these hosts `disable` deletes the unit file), then starts
the quadlet; the deployer blocks until `/identity` answers on 32400.
`disable` — on these hosts `disable` deletes the unit file), chowns
`/var/lib/plexmediaserver` to `1000:1000`, then starts the quadlet; the
deployer blocks until `/identity` answers on 32400.
- Rollback lever while masked: `systemctl --user stop plex.service && sudo
systemctl unmask plexmediaserver.service && sudo systemctl start
plexmediaserver.service`.
chown -R 997:984 /var/lib/plexmediaserver && sudo systemctl unmask
plexmediaserver.service && sudo systemctl start plexmediaserver.service`
(ownership-only churn — zero bytes ever moved).
- Decommission (`sudo apt-get -y purge plexmediaserver`) only after explicit
operator sign-off.
+1 -1
View File
@@ -16,7 +16,7 @@ it; it talks to PMS over the appnet at `http://plex:32400`).
| Runtime | podman quadlet, `tautulli.service` (user manager) — migrated from snap `snap.tautulli.tautulli.service` |
| Image | `lscr.io/linuxserver/tautulli:v2.18.1-ls242` (pinned; verified == the ghcr `latest` manifest digest at pin time) |
| Data root | `/home/dank/Containers/tautulli` bind-mounted as `/config` (fresh at cutover — the snap's `/var/snap/tautulli/common` was empty) |
| Identity | `UserNS=host` (quadlet key for `--userns=host`) + `PUID=1000 PGID=1000` |
| Identity | fleet rootless model — container root maps to the `dank` user (1000:1000) |
| Port | `8181/tcp` published on `0.0.0.0` |
| Network | `dankserver_appnet`, alias `tautulli` |
| PMS URL | `http://plex:32400` — seeded into `config.ini` by the deployer on first start |
+11 -9
View File
@@ -67,14 +67,16 @@ load helpers
grep -q '^PublishPort=8181:8181$' "$REPO_ROOT/dankserver/tautulli/quadlet/tautulli.container" || return 1
}
@test "plex keeps the native unit's identity (UserNS=host, PUID=997, PGID=984)" {
# Zero-chown ownership contract: /var/lib/plexmediaserver is bind-mounted
# in place, so every file the container writes must land owned 997:984 —
# byte-identical to the former plexmediaserver.service identity. UserNS=
# is the quadlet v1 key (UsernsMode= is compose syntax and the generator
# hard-fails the whole unit on it); pin the exact keys.
@test "plex runs the fleet identity model (PUID=0/PGID=0, no userns remap)" {
# Rootless podman cannot map the real host uid 997 (subuid range is
# 100000:65536), so the fleet model applies: container root maps to the
# dank user and the deployer chowns /var/lib/plexmediaserver to
# 1000:1000 on first cutover; rollback chowns back to 997:984. Pin both
# keys and the ABSENCE of any userns key — UsernsMode= is compose syntax
# (the v1 generator hard-fails the whole unit on it) and UserNS= would
# remap the container away from the fleet identity.
unit="$REPO_ROOT/dankserver/plex/quadlet/plex.container"
grep -q '^UserNS=host$' "$unit" || return 1
grep -q '^Environment=PUID=997$' "$unit" || return 1
grep -q '^Environment=PGID=984$' "$unit" || return 1
grep -q '^Environment=PUID=0$' "$unit" || return 1
grep -q '^Environment=PGID=0$' "$unit" || return 1
! grep -qE '^(UserNS|UsernsMode)=' "$unit" || return 1
}