The block said the token rides stdin and never argv, then handed it to curl as `-H "X-Plex-Token: $T"` — world-readable in /proc/<pid>/cmdline. It now writes a mode-600 curl config inside the container, which is also how the overnight rescan was actually issued (five sections, all accepted). Two truths the old block got wrong are stated: a section answers 200 with a Directory body when Accept: application/json is sent, and a backslash-newline inside a quoted printf format is literal, so the config builder writes two printfs.
6.8 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | ||
|---|---|---|---|---|---|
| reference | Plex Media Server is a **containerized media server on Dankserver**: a podman |
|
2026-10-30 |
Plex
Plex Media Server is a containerized media server on Dankserver: a podman
quadlet (plex.service) under the systemd user generator, generated from
dankserver/plex/quadlet/plex.container. It shares the host with a second live
media server, Jellyfin (jellyfin.service, listening on 8096); both serve
from the same tree — the Media Metadata View overlay — so the Kodi-format
sidecars Jellyfin writes are the files Plex reads, with no copy and no sync.
Host: Dankserver — Ports: 32400/tcp (PMS web + API), 32400 is the only port PMS needs for client streaming; LAN clients connect directly, public traffic can be fronted by Caddy like every other vhost.
Facts
| Item | Value |
|---|---|
| Runtime | podman quadlet, plex.service (user manager) — migrated from the native plexmediaserver.service OS-package unit (2026-09) |
| Image | lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329 (pinned; verified == the registry's latest manifest digest at pin time: sha256:8f655bad…e9e2334) |
| Data root | /var/lib/plexmediaserver bind-mounted in place as /config (RW) — the migration moved zero bytes |
| Media | git: /var/lib/media-meta/view (the Media Metadata View overlay) bound at the same absolute path /mnt/entropy read-only; live today: /mnt/entropy itself, read-only — the view has never been installed. Either way every library path in the PMS database stays valid, zero re-scan |
| 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 two R9700s stay dedicated to frank-archon |
| Deploy | ./scripts/deploy.sh dankserver --service plex |
| Docs | https://support.plex.tv |
Why in-place bind mounts
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 — only the HOST side of that bind has moved
(the array today; the overlay view in git has never been deployed), so every
absolute library path in the
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(neverdisable— on these hostsdisabledeletes the unit file), chowns/var/lib/plexmediaserverto1000:1000, then starts the quadlet; the deployer blocks until/identityanswers on 32400. - Rollback lever while masked:
systemctl --user stop plex.service && sudo 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.
Integration points
- Caddy: Plex is exempt from the Anubis bot challenge (plain-HTTP legacy clients cannot solve JS challenges) — Anubis "Exempt by construction".
- Tautulli (Tautulli) is the companion: it polls PMS on the appnet for statistics and notifications and makes no sense without Plex.
- Auth wall: if a public vhost is ever pointed at Plex, put the
Authentik outpost + Anubis chain in front of it like every other guarded
vhost (Authentik, Caddy); direct
:32400exposure to the LAN is unauthenticated by design (Plex owns its own auth via claim tokens).
Gotchas
- Do not benchmark on it blind. Transcoding is CPU-only now but still contends with the LLM fleet's host-RAM expert streaming; stop the service before benchmark runs, per the Benchmarking Policy.
- Updates are commit-driven, not apt. The OS package is retired at
cutover; upgrading PMS means pinning a new
-lstag in the unit file and redeploying — never run:latest. - The claim token is the identity of this server; document it in the host
.env, never in this repo (secret rules apply). The token lives inPreferences.xmlinside the RW data bind, preserved by the migration. - Cold container boot is slow. PMS pre-binds 32400 and empty-replies while the HTTP layer finishes loading; the first boot of a years-old library took ~10 minutes (2026-09 cutover). The deployer gate now allows 600 s before failing; later boots are much faster (warm page cache).
Triggering a scan
A rename on disk is invisible until the section is rescanned, and a moved file is
a Couldn't find the file to stream 404 until that scan passes it. Run this from
the host shell through podman exec, never from a neighbour container: PMS's
HTTPD closes without a single byte on any request that arrives from another
container's netns (10.89.x.x reads as "empty reply", not as a 401). The token
rides stdin, never argv, where any process on the host could read it out of
/proc/<pid>/cmdline; it lives in Preferences.xml inside the RW data bind.
umask 077
podman exec plex sh -c "sed -n 's/.*PlexOnlineToken=\"\\([^\"]*\\)\".*/\\1/p' \
'/config/Library/Application Support/Plex Media Server/Preferences.xml'" >/tmp/px.tok
cat >/tmp/pxscan.sh <<'EOS'
read -r T
cfg=$(mktemp)
mk() { { printf 'header = "X-Plex-Token: %s"\nheader = "Accept: application/json"\n' "$T"
printf 'url = "%s"\n' "$1"; } > "$cfg"; }
mk "http://127.0.0.1:32400/library/sections"
for k in $(curl -s -K "$cfg" | grep -o '"key":"[0-9]*"' | cut -d\" -f4); do
mk "http://127.0.0.1:32400/library/sections/$k/refresh"
printf 'request = "POST"\n' >> "$cfg"
echo "plex section $k: $(curl -s -o /dev/null -w '%{http_code}' -K "$cfg")"
done
shred -u "$cfg"
EOS
podman cp /tmp/pxscan.sh plex:/tmp/pxscan.sh
podman exec -i plex sh /tmp/pxscan.sh </tmp/px.tok
shred -u /tmp/px.tok
Every section answers 204 — or 200 with a Directory body when
Accept: application/json is sent. Inside the quoted printf format a
backslash-newline is LITERAL, not a continuation, so mk writes two printfs:
one glued line and one stray backslash is a config file curl rejects, and the
failure prints as option -K: is unknown, never as a bad config line.
Proof that a rename landed is not a scan response: scripts/media-verify.py
replays the run's journal against the database — the only read that
distinguishes "not scanned yet" from "gone"; see Media Relayout.