--- type: reference description: "Plex Media Server is a **containerized media server on Dankserver**: a podman" tags: - dankserver - plex timestamp: 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 | `/var/lib/media-meta/view` (the [[Media Metadata View]] overlay) bound at the same absolute path `/mnt/entropy` **read-only** — live since the 2026-10-31 cutover, so the PMS database's recorded paths keep resolving with 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 until the 2026-10-31 view cutover, the overlay since), 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` (never `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 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 `:32400` exposure to the LAN is unauthenticated by design (Plex owns its own auth via claim tokens). ## Gotchas 1. **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. 2. **Updates are commit-driven, not apt.** The OS package is retired at cutover; upgrading PMS means pinning a new `-ls` tag in the unit file and redeploying — never run `:latest`. 3. 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 in `Preferences.xml` inside the RW data bind, preserved by the migration. 4. **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). A `systemctl --user start` issued after an `unmask` is a job in the manager, not in the shell that issued it: it lands minutes later and survives that client dying, so a wrapper script that looks hung is usually only waiting — read `is-active` and `podman ps` before declaring a fault, and give every step of such a script its own `timeout`. 5. **`32400` answers TLS only to remote clients.** PMS requires secure connections, so `curl http://192.168.0.235:32400/identity` from any other machine closes without a single byte (curl exit 52) — that reads as a dead server beside a healthy one, and it is how a post-cutover health check gets misread. Probe with `curl -k https://192.168.0.235:32400/identity`. Two consequences: `plex.franksdank.cloud` works **only** because its `transport http` block carries `tls_insecure_skip_verify` (that option makes Caddy speak TLS to PMS's self-signed cert — delete it and the vhost 502s), and the scan block below rides `http://127.0.0.1` **inside** the container, where insecure connections are allowed. ## 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 the driver from the host shell; the section keys are `1` Movies, `3` TV Shows, `5` Audiobooks, `6` Music, `11` VODs & Live Events: ```bash scripts/media-refresh-plex.sh trigger --library 1 python3 scripts/media-verify.py --db-kind plex \ --journal /mnt/entropy/.media-campaign/.jsonl # the real proof ``` Exactly ONE section: a refresh costs a full walk of that tree, and refreshing all five for a rename that touched one buys nothing. `trigger` answers `refresh accepted (204)` — `200` when a body is requested — and `000` means PMS is still warming, which the driver retries out loud; a service restart is never the fix. **`status` is a census, not a waiter: no epoch makes it watch a scan.** It reads `section_locations.scanned_at` — the epoch of the last scan PMS has *recorded* for the section, the same value `GET /library/sections` prints as `scannedAt` (verified 2026-11-04) — and compares it against `--after`. Measured twice on 2026-11-04: a chain fell through its wait loop in the same second its own trigger was accepted, and a direct read 30 minutes later still answered `settled` off that one stamp (`2026-11-04T19:46:59Z`, for a request accepted at `19:46:58Z`) while the walk ran. A journal's mtime fails, and so does the trigger's own epoch — the request satisfies the very door it was asked to wait for. The failure direction is a false *success*, never a false alarm, which is what makes it worth the warning: ask `status` whether a scan got recorded, and never let a chain depend on it. **The proof that a scan did its work is the journal replayed against the database:** `scripts/media-verify.py --db-kind plex --journal ` names every row that still points at a source path (`still at src`) and every entry whose file is physically still there (`REAL`). Both at zero is what gates a purge, and it needs no settle signal at all — a scan can clear rows but cannot invent one. Jellyfin's door has no such trap: `scripts/media-refresh-jellyfin.py wait` watches the scan task itself and refuses by name when no task reaches `Running`. The driver ships its runner into the container with `podman cp` rather than quoting a command through `podman exec sh -c`, because PMS's HTTPD closes without a single byte on a request that arrives from another netns — the host address and the `10.89.x.x` container address both read as "empty reply", never as a 401 — and because a three-layer `ssh → podman exec → sh -c` quote chain dies on a parse error long before it reaches the server. The request therefore rides plain `http://127.0.0.1:32400` from inside the container, where insecure connections are allowed (gotcha 5). The credential is `PlexOnlineToken` in the container's `Preferences.xml` — NOT `PlexHTTPToken`, which a claimed server need not carry — read inside the container into a mode-600 curl config there, so it never enters argv (`/proc//cmdline` is world-readable) and never a URL (proxies log URLs). Hand-writing that config has one more trap: inside a quoted `printf` format a backslash-newline is LITERAL, so a two-line string becomes one glued line plus a stray backslash, and curl answers `option -K: is unknown` — never "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]].