--- 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 after="$(date +%s)" # the TRIGGER's epoch — never a file mtime scripts/media-refresh-plex.sh trigger --library 1 scripts/media-refresh-plex.sh status --library 1 --after "$after" ``` 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. Progress needs no token and no container: `section_locations.scanned_at` in the library database carries the same epoch `GET /library/sections` prints as `scannedAt` (verified 2026-11-04), and that is what `status` compares `--after` against. **The epoch `--after` receives must be the trigger's, not a file's.** `status` answers one question — has this section scanned since that epoch — so any journal or plan whose mtime predates the most recent scan makes it report settled the instant you ask. Measured 2026-11-04: a chain that passed `stat -c %Y ` fell straight through its wait loop and the verify after it read a database that had not yet processed that day's renames. The failure mode is a false *success*, never a false alarm, which is what makes it worth two lines of warning. A purge gate survives it — `scripts/media-verify.py` asks whether any row still points at a path with no file, and a scan can clear rows but cannot invent one — but a "the scan is done" claim does not. 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]].