# ServiceDefinition: plex # Source of truth: spec docs/13-service-catalog.md, row 1 (the catalog's largest service). # public | egress internet | config on Longhorn (metadata DB, DR-noncritical/reacquirable) | # auth: native (Plex accounts, NOT Keycloak — docs/13 + docs/15 documented exception). # Validates against schemas/servicedefinition.schema.json. # # BULK MEDIA — NOW ATTACHED (docs/04 bulk tier; the long-standing OQ-2 prerequisite, resolved for # the STATIC case). The library is 8.33 T of ZFS on the Proxmox host at /mnt/Storage/plex, and it # already exists: nothing is provisioned, an existing dataset is mounted. See # docs/BULK-NFS-STORAGE.md for the layout, the export change and the uid analysis. (The bulk-tier # requirement ID is deliberately still not typed here — see that document's "gate" note.) name: plex kind: container source: image: plexinc/pms-docker:latest # plexinc/pms-docker env. Rendered VERBATIM onto the app container by render.py (schema env list). env: # PLEX_UID/PLEX_GID: the image boots as root (s6 PID1), chowns /config to this uid/gid, then drops # the "Plex Media Server" process to it. 1000 matches the Longhorn ownership convention the other # stateful apps use (seerr/semaphore run as UID/GID 1000), keeping the /config PVC writable by the # dropped plex user. (Plex has no podSecurityContext/fsGroup — the image manages ownership itself.) - name: PLEX_UID value: "1000" - name: PLEX_GID value: "1000" # TZ: no other service in the repo sets a timezone (repo-wide grep: none), so use the safe default. - name: TZ value: Etc/UTC # ALLOWED_NETWORKS marks these CIDRs as trusted LAN, so in-cluster clients (Seerr -> plex:32400) are # treated as local and NOT pushed onto Plex's remote-auth/relay path. 10.0.0.0/8 covers the Cilium # pod CIDR (10.0.0.0/16, CLUSTER_POD_CIDR — Seerr's pod is 10.0.x.x) and the k3s service net # (10.43.0.0/16); 192.168.0.0/16 covers the home LAN. - name: ALLOWED_NETWORKS value: 10.0.0.0/8,192.168.0.0/16 # PLEX_CLAIM — the ONE-SHOT plex.tv server claim, sourced from the SOPS Secret `plex-claim` # (apps/wave-0/plex-claim-secret.sops.yaml, same wave => applied in the same reconcile as this # workload). Plex cannot create libraries over the mounted bulk share while the server is # UNCLAIMED, so this is wired; it was previously left out on the reasoning below, and only one # half of that reasoning survived contact with the upstream source. # # A SHORT TTL IS THE OPERATIONAL CONSTRAINT, not a reason to abstain. A https://plex.tv/claim token # expires in MINUTES — read the countdown the claim page renders; do not budget against a number # written in this tree. (This comment previously asserted "~4 minutes" as established fact. It was # UNCITED, upstream plexinc/pms-docker documents no expiry at all, and that number was load-bearing # for the whole procedure design — including a since-disproven claim that the delivery pipeline was # too slow to fit inside it. Measured 2026-09-29: the full loop is ~2m45s and fits.) # # The live token is injected by the operator inside that window, with an explicit `flux reconcile` # to collapse the wait. Scripted end to end as infra/plex/claim-ship.sh; runbooks/plex-claim.md is # the procedure. SERVER CLAIMED 2026-09-29 — this env is now a permanent no-op (see below), and the # committed value is the spent token from that claim rather than the original placeholder. # # ONE-SHOT, AND SAFE TO SIT HERE EXPIRED. Verified from plexinc/pms-docker's own first-run script # (branch `master`, root/etc/cont-init.d/40-plex-first-run, confirmed byte-identical to the file in # the running `:latest` image), NOT assumed: # - the exchange (line 106) is guarded on TWO conditions: PLEX_CLAIM non-empty AND the # PlexOnlineToken pref in /config empty. Once claimed, the block is SKIPPED — this env is a # no-op on every subsequent start, whatever it holds. (Upstream README: "If server is already # logged in, this parameter is ignored.") The claim itself persists on the /config PVC as before. # - an invalid/expired token on a still-unclaimed /config gets a 403 with an empty body; curl's # status is unchecked, the token `sed` yields "", the success branch at line 120 does not fire, # there is no `else` and no `exit 1`, and the script (no `set -e`) exits 0. It comes up UNCLAIMED and # SERVING — it does NOT crashloop. The image also leaves S6_BEHAVIOUR_IF_STAGE2_FAILS at its # default 0, which continues startup even when a cont-init script fails. # So the earlier "a placeholder claim Secret would crashloop the pod" note was WRONG about the # placeholder; it was right only about an ABSENT Secret. secretKeyRef here has no `optional` field, # so the Secret must always exist in the Flux graph — which is why it is committed, encrypted, and # listed in apps/wave-0/kustomization.yaml rather than hand-created at claim time. No schema change # is needed: `optional` would only matter if a missing Secret had to be tolerated, and it doesn't. - name: PLEX_CLAIM valueFrom: secretKeyRef: name: plex-claim key: PLEX_CLAIM # ADVERTISE_IP ("Custom server access URLs") — the ONE hostname this server publishes to plex.tv, # serving BOTH local and remote clients by split-horizon DNS (operator design, 2026-10-04): # LAN -> PiHole answers plex-vip.${BASE_DOMAIN} with ${PLEX_L4_VIP} (the tcp/32400 LoadBalancer # in apps/wave-0/plex-remote.yaml). See catalog/pihole.yaml's split-horizon line, which # needs a per-host override because the zone wildcard otherwise points at the GATEWAY # VIP — measured 2026-10-04: plex-vip.${BASE_DOMAIN} resolved to the Gateway, where # nothing listens on 32400. # PUBLIC -> Cloudflare answers with the WAN address, NATed to the same VIP (off-repo). # # WHY A HOSTNAME AND NOT THE `plex.direct` FORM THIS BRIEFLY USED. The first fix for the # 2026-10-04 outage advertised `https://..plex.direct:32400`, which works # (and is Plex's own mechanism), but it hard-codes BOTH the VIP and the server's certificate hash # into a cluster-var — two values nothing could keep in sync, with no alert if either drifted. A # hostname in our own zone removes that whole class of problem: ${BASE_DOMAIN} is already a knob, # the VIP lives only in PiHole's answer, and `PLEX_ADVERTISE_URL` is deleted outright. # # THIS IS `https`, WHICH ONLY WORKS BECAUSE WE HAND PLEX OUR OWN CERTIFICATE. Plex serves 32400 # with its `*..plex.direct` cert, which does NOT cover this name — advertising # `https://plex-vip...` against the stock cert reproduces the exact failure that caused the # outage (measured: a storm of `CERT: incomplete TLS handshake ... certificate unknown` from real # clients, and `curl` without `-k` failing to connect). The `lifecycle.postStart` hook below # installs the Let's Encrypt cert for this name and points Plex at it, so the advertised URL and # the served certificate agree. Do not change this to a bare IP, and do not drop the hook and # leave the `https` here. # # (A SAN match is not what makes Plex *work* — `httpsRequired=0` and `secureConnections` default # to Preferred, so clients can fall back to cleartext. It is what keeps the remote leg ENCRYPTED # instead of silently downgrading traffic that crosses the internet.) # # The image's /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections" # "${ADVERTISE_IP}"` (line 127, verified in the running image), OUTSIDE the # PLEX_CLAIM/PlexOnlineToken guard, and its `/.firstRunComplete` marker is on the container # OVERLAY fs rather than the /config PVC — so this is re-applied on EVERY pod start and git, not # a web form, owns the value. Hand-typed custom URLs are what broke this service; they are now # overwritten on every start. # # SCHEME IS `http` AND THAT IS TEMPORARY, 2026-10-04. It was `https`, which requires Plex to serve # a certificate for this name — and the Certificate that would have supplied one is NOT APPLIED # (see apps/wave-0/plex-cert.yaml's header). Advertising `https` without it reproduces the # 2026-10-04 outage exactly: Plex answers 32400 with its `*..plex.direct` cert, every # TLS-validating client rejects the hostname, and plex.tv can publish nothing. `http` works TODAY # and is MEASURED to: PiHole answers plex-vip.${BASE_DOMAIN} with ${PLEX_L4_VIP} (verified live # against pihole-0, 2026-10-04) and Plex serves 32400 there. # # THE COST, STATED: the remote leg is unencrypted. That is tolerable only because remote access # does not work yet at all (the WAN tcp/32400 forward is still absent), so today this carries LAN # traffic only. RESTORE `https` IN THE SAME COMMIT that re-applies the Certificate — the two are # one change, and splitting them is what broke this. # # HOW TO GET `https` BACK. The Certificate that would serve this name was applied on 2026-10-04 # and STALLED THE ENTIRE APP TIER; the file was DELETED rather than merely unregistered, because # `tls-lifecycle-automated` (R-TLS-02) fails any Certificate left in the tree that no Flux path # reconciles. Full post-mortem: decisions/2026-10-04-plex-unblock-app-tier.md. Three things must # land TOGETHER: # 1. EXEMPT cert-manager FROM `secret-provenance`. Its temporary private-key Secrets # (`plex-vip-`) are denied by `secrets-must-be-flux-managed`, which matches every # Secret in a namespace labelled `homelab.keithjackson.dev/app-namespace: true` — `plex` HAS # that label, `gateway` (where the working wildcard cert lives) does NOT, which is why this # never bit the existing Certificate. `secretTemplate` cannot fix it: those Secrets come from # the keymanager controller, not the Certificate's template. Narrow fix: an # `exclude.any[].subjects` entry for ServiceAccount `cert-manager`/`cert-manager` (confirmed # live as the controller's SA). `platform/kyverno/**` is a SENSITIVE path — non-author approval. # 2. GIVE THE CERTIFICATE A NON-GATING HOME. `apps-wave-0` is `wait: true`, so Flux health-checks # the Certificate OBJECT and a pending issuance stalls the whole tier # (`timeout waiting for: [Certificate/plex/plex-vip status: 'InProgress']`). `optional: true` # on the env refs below protects the POD, not the Kustomization — that distinction is the # whole bug. Use a separate Kustomization with `wait: false`, the shape # clusters/base/backup.yaml already uses for exactly this reason. # 3. Flip this value back to `https`. - name: ADVERTISE_IP value: http://plex-vip.${BASE_DOMAIN}:32400 # THE TLS MATERIAL FOR THE NAME ABOVE, as PEM text in env rather than a mounted volume. # `plex-vip-tls` is issued by cert-manager from ClusterIssuer `letsencrypt-prod` for exactly # `plex-vip.${BASE_DOMAIN}` (apps/wave-0/plex-cert.yaml, same wave => same reconcile as this # workload). The postStart hook converts it to the PKCS#12 bundle Plex requires. # # WHY ENV AND NOT A VOLUME, which would be the conventional choice. MEASURED 2026-10-04: # `schemas/servicedefinition.schema.json` `storage` accepts only # [class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`, # `catalog/render.py` emits NO secret volumes, and no rendered app mounts a Secret as a volume. # Mounting one therefore needs a schema + renderer change; `env.valueFrom.secretKeyRef` is # already in the schema and already used by PLEX_CLAIM above. PEM is text, so it survives an env # var intact (a raw PKCS#12 would not — it is binary and carries NUL bytes). # # COST, STATED PLAINLY: the private key is readable from this container's environment # (/proc/1/environ, `kubectl exec env`) by anything that can already exec into the pod. That is # the same audience that can read the converted bundle under /config, so the marginal exposure is # small — but a secret VOLUME would still be the better shape, and adding secret-volume support # to the schema is the clean follow-up that removes this note. # # `optional: true` IS LOAD-BEARING, NOT DEFENSIVE CLUTTER (schema 1.11.0, added for this). The # Certificate is issued INTO THIS NAMESPACE, and the namespace is created by the rendered # apps/wave-0/plex.yaml itself — so the cert CANNOT be issued in an earlier tier, and the # Certificate and this Deployment land in the SAME reconcile. On a cold bootstrap the Secret is # therefore briefly absent while cert-manager completes a DNS01 challenge. With a HARD ref, Plex # would sit in CreateContainerConfigError until a certificate issued — and because `apps-wave-0` # is `wait: true` with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE # APP TIER (the same hazard the `hardware` block below documents for the GPU resource). Optional # instead means: no cert -> Plex still starts and serves, the postStart hook logs that the # certificate was not installed, and the next start after issuance converges. Plex falls back to # its own `*.plex.direct` certificate in the meantime, so the degraded state is "advertised URL # not yet servable over TLS", not "media server down". - name: PLEX_TLS_CRT valueFrom: secretKeyRef: name: plex-vip-tls key: tls.crt optional: true - name: PLEX_TLS_KEY valueFrom: secretKeyRef: name: plex-vip-tls key: tls.key optional: true lifecycle: # LEVEL-TRIGGERED PLEX SETTINGS, re-asserted on EVERY container start — the same pattern and the # same reasoning as catalog/pihole.yaml's and catalog/qbittorrent.yaml's postStart: desired state # lives in git and is re-asserted unconditionally rather than typed into a web form once. Typing # settings into the web form is precisely what took this service down on 2026-10-04. # # IT EXISTS BECAUSE THE IMAGE HAS NO ENV FOR THESE SETTINGS. Measured 2026-10-04 across the whole # init surface (/etc/cont-init.d, /etc/services.d, /plex-common.sh, /usr/local/bin): the image # calls setPref for exactly SIX prefs — MachineIdentifier, ProcessedMachineIdentifier, # PlexOnlineToken, customConnections, allowedNetworks, TranscoderTempDirectory — and supports NO # generic PLEX_PREFERENCE* hook of any kind. The two things we need (a LAN definition and a custom # certificate) are reachable ONLY through Plex's own API, so this hook is the only GitOps path. # # IT MUST NEVER FAIL. A postStart hook that exits non-zero makes kubelet KILL the container, so # every step is bounded and the script ends with an unconditional `exit 0`. A missing token, an # unissued certificate or an unreachable API degrades to "not applied this start" and is retried # on the next one; it never crashloops Plex. postStart: exec: command: - /bin/bash - -c - | # NOTE — LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED. # This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a # brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD # Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be # replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` — bare. Only # BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be # substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell # variable written with braces would be blanked or would fail the whole Kustomization — # this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and # catalog/render.py now refuses to emit it (which is how this very comment got caught). # DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the # certificate has not issued yet they are genuinely UNSET, not empty — and under `set -u` # the first `[ -n "$PLEX_TLS_CRT" ]` would abort the script, which for a postStart hook # means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists # to prevent, so turning it back on here would defeat the whole design. (The alternative, # a brace-wrapped `:-` default, would need envsubst escaping — the trap the note above # describes.) Every value below is explicitly guarded instead. PX="/config/Library/Application Support/Plex Media Server/Preferences.xml" CERTDIR=/config/certs P12=$CERTDIR/plex-vip.p12 # The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it # in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a # container-format requirement, not an access control. P12PASS=plexvip LANNETS='10.0.0.0/8,192.168.0.0/16' CERTDOMAIN=plex-vip.${BASE_DOMAIN} API=http://127.0.0.1:32400 # (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so # a cert-manager RENEWAL is picked up with no extra moving part. if [ -n "$PLEX_TLS_CRT" ] && [ -n "$PLEX_TLS_KEY" ]; then mkdir -p $CERTDIR && chmod 700 $CERTDIR printf '%s' "$PLEX_TLS_CRT" > $CERTDIR/tls.crt printf '%s' "$PLEX_TLS_KEY" > $CERTDIR/tls.key chmod 600 $CERTDIR/tls.key if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \ -in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then mv -f $P12.tmp $P12 chown 1000:1000 $P12 2>/dev/null || true chmod 600 $P12 echo "[plex-settings] PKCS#12 bundle written" else rm -f $P12.tmp echo "[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle" >&2 fi rm -f $CERTDIR/tls.key else echo "[plex-settings] WARN: TLS env empty; certificate not installed this start" >&2 fi # (2) Wait, bounded (~90s), for the local API. /identity needs no auth. i=0 while [ "$i" -lt 45 ]; do if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)" = "200" ]; then break fi i=$(expr $i + 1); sleep 2 done # (3) The prefs API needs the server token. It already lives in Preferences.xml on this # PVC; it is never echoed here, and Plex redacts tokens in its own request log. An # UNCLAIMED server has none — a normal state on a fresh /config — so skip, do not fail. TOKEN=$(grep -o 'PlexOnlineToken="[^"]*"' "$PX" 2>/dev/null | head -1 | cut -d'"' -f2) if [ -z "$TOKEN" ]; then echo "[plex-settings] not claimed yet — skipping prefs, will retry next start" >&2 exit 0 fi # Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on # a PUT, so a raw '/' or ',' would be read as structure rather than data. enc() { printf '%s' "$1" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \ -e 's|:|%3A|g' -e 's| |%20|g' } setpref() { code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT \ -H "X-Plex-Token: $TOKEN" "$API/:/prefs?$1=$(enc "$2")" 2>/dev/null) if [ "$code" = "200" ]; then echo "[plex-settings] set $1" else echo "[plex-settings] WARN: $1 -> HTTP $code" >&2 fi } # (4) THE LAN DEFINITION — the reason this hook was written. Without it Plex has NO notion # of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests # logged "Allowed Network (WAN)" and ZERO "(LAN)", including Seerr from inside the # cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks # (the ALLOWED_NETWORKS env) only waives AUTH — it does NOT make a client local. Two # different settings; only one of them has an env hook. setpref LanNetworksBandwidth "$LANNETS" # (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only # if the bundle exists — pointing Plex at a missing file would break its TLS listener. if [ -s "$P12" ]; then setpref customCertificatePath "$P12" setpref customCertificateKey "$P12PASS" setpref customCertificateDomain "$CERTDOMAIN" else echo "[plex-settings] WARN: no bundle; leaving certificate prefs untouched" >&2 fi exit 0 # HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02). # INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an # Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource. # How the platform hands the device over is `catalog/render.py`'s business and is documented in # `schemas/renderer-contract.md` `### hardware` — read that for the mechanism, never this comment. # The payoff is proven, not theoretical: on 2026-10-01 the mechanism was swapped outright (hostPath # -> Intel GPU device plugin extended resource) and THIS BLOCK DID NOT MOVE — a `render.py` change # plus a re-render, zero catalog edits. Do not "simplify" it into a raw `/dev/...` path; that is the # whole point of the enum. # # THE DEVICE IS PRESENT ON EVERY PROD NODE, so nothing had to be installed and Plex can land # anywhere. Measured on all three nodes 2026-09-30: `/dev/dri/renderD128` present, `root:render`, # GID 992, mode 0660, driver `i915` bound (NOT `xe`), UHD Graphics 630 on an i7-9700T, media engines # `vcs0`/`vecs0` live, iGPU enabled in BIOS on each. Measured again 2026-10-01, one layer up: all # three nodes advertise `gpu.intel.com/i915: 1` in `.status.allocatable`. This field renders no # nodeSelector; placement is a separate concern with its own justification. # # PLEX'S SCHEDULABILITY IS NOW COUPLED TO THE `intel-gpu-plugin` DaemonSet, and that is a real cost, # not a footnote. The current mechanism is a hard scheduling constraint: if the resource is # unavailable (plugin broken, removed, or not yet reconciled) this pod is UNSCHEDULABLE, and because # `apps-wave-0` is `wait: true` with `apps-wave-1` `dependsOn` it, that STALLS THE WHOLE APP TIER. # The previous hostPath mechanism degraded silently to software transcoding instead — but it never # actually granted access (device-cgroup EPERM, measured 2026-10-01), so there was nothing to keep. # THE ESCAPE HATCH IS THIS BLOCK: delete these two lines and re-render, and Plex goes back to # software transcoding and the tier unblocks. One field, no code change. `runbooks/plex-quicksync.md` # carries that procedure for whoever needs it at 2am. # # NO `podSecurityContext.supplementalGroups`, AND ADDING ONE WOULD BE INERT — do not "fix" this. # The device is `root:render` 0660 and this process is uid/gid 1000, so the obvious move is to push # the host `render` GID in as a supplemental group. It cannot work here, and this survived the # mechanism swap unchanged. MEASURED IN THE RUNNING CONTAINER, 2026-09-30 and again 2026-10-01: the # image's own `/etc/cont-init.d/45-plex-hw-transcode-and-connected-tuner` stats every char device # under `/dev/dri`, `groupadd`s a matching group INSIDE the container when none exists, and # `usermod -a -G`s the `plex` user into it — every start, nothing hardcoded, which also makes a host # GID renumbering self-healing (no GID is pinned anywhere in this repo) and which works the same way # on a PLUGIN-INJECTED device as on a bind-mounted one. Measured live, `Plex Media Server` ran with # `Groups: 44 100 992 1000` — 992 IS the host `render` GID, i.e. the hook had already done the job. # The service execs via `s6-setuidgid plex`, and `s6-setuidgid` REPLACES the process group list from # `/etc/group` rather than appending to it, so root's gid `0` — which PID 1 (`s6-svscan`) does hold — # is GONE, and anything Kubernetes supplies reaches s6-svscan and is discarded before Plex ever # starts. The group is the image's job and it does it correctly. `render.py --selftest` asserts the # ABSENCE of a pod securityContext for this field precisely so nobody adds an inert one back later. # # THE LAST MILE IS A MANUAL OPERATOR STEP AND CANNOT LIVE IN THIS REPO. Handing over the device does # not enable hardware transcoding — Plex's own `HardwareAcceleratedCodecs` preference does, and it # is ABSENT from `Preferences.xml` today (the toggle has never been on). NO environment variable can # set it: a grep of the running image's entire init surface (`/etc/cont-init.d`, `/etc/services.d`, # `/etc/fix-attrs.d`, `/plex-common.sh`, `/usr/local/bin`) found ZERO `PLEX_PREFERENCE*` support, # and the six prefs the image does write (`MachineIdentifier`, `ProcessedMachineIdentifier`, # `PlexOnlineToken`, `customConnections`, `allowedNetworks`, `TranscoderTempDirectory`) are all # unrelated. So there is no GitOps path to the toggle; an operator clicks it once in the web UI. # `runbooks/plex-quicksync.md` is that procedure, plus how to verify it took. hardware: gpu: intel-quicksync exposure: public # edge port-forward + public DNS; clients + Seerr reach it (docs/09; R-DNS/R-NET) egress: internet # normal WAN egress (docs/03; R-NET-05) storage: - name: config size: 8Gi class: longhorn # Plex DB/metadata: replicated app state on the 3-replica tier (docs/04; R-STATE-01/02) mountPath: /config # plexinc/pms-docker writes its identity/claim + "Library" DB under /config, NOT the # renderer default /data/config. Without this the PVC sat unused and Plex wrote to the # container's EPHEMERAL layer -> unclaimed + all state lost on restart. The image inits as # root (s6-svscan PID 1), chowns /config to the plex user, then drops -> no fsGroup needed # (verified in-cluster: PID1=root, "Plex Media Server"=plex). Persisting /config here also # makes the claim survive restarts once claimed — which is what makes the PLEX_CLAIM env # above a ONE-SHOT: the token is exchanged for a PlexOnlineToken written here, and the # exchange is then skipped on every later start. (The older note here claimed a # placeholder claim Secret "would crashloop the pod"; upstream's first-run script says # otherwise — see the PLEX_CLAIM comment in `env` above. An ABSENT Secret is the hard # failure, and that is why one is committed.) # THE LIBRARY (docs/04 bulk tier). One READ-ONLY mount of the export ROOT, giving /media/movies, # /media/tv and /media/photos — the whole tree the layout actually has (`ls /mnt/Storage/plex`: # downloads/ movies/ photos/ tv/). # # WHY THE ROOT AND NOT THREE subPath MOUNTS. Nothing is gained by narrowing: the mount is `ro`, # so no subPath could remove a privilege that matters, while three mounts would triple the NFS # mounts per pod and mean a manifest change every time a top-level library is added. # # WHY READ-ONLY, when the export is `rw` and the other consumers write. Plex is a SERVER: it # indexes and streams. Every write on that tree belongs to Sonarr and Radarr. Marking it here # costs nothing and removes Plex from the set of things that can damage 8.33 T. Enforced by the # PV's nfs.readOnly (a real `ro` mount), not only by the volumeMount flag. # # UID: no change needed. The tree is 0755 owned 1000:3000 throughout, so it is world-readable and # world-traversable, and PLEX_UID/PLEX_GID above already make the process uid 1000 in any case. # (fsGroup would have been a no-op here regardless — the in-tree NFS plugin does no ownership # management. That is the trap the arrs' PUID/PGID exists to avoid; Plex never hits it.) - name: media class: bulk export: /mnt/Storage/plex # the ZFS dataset Storage/plex, on ${BULK_NFS_SERVER} mountPath: /media readOnly: true size: 9Ti # ADVISORY ONLY (nothing enforces a static NFS PV's capacity): the # dataset's 8.33 T used, rounded up, so `kubectl get pv` reports # something true. The real limit is the ZFS pool. dependencies: [] # default-deny: reaches only DNS; clients/Seerr reach IT (docs/03; R-NET-01/02) ingressRoutes: - host: plex.${BASE_DOMAIN} port: 32400 # Plex web UI + API served on 32400 (wildcard TLS at the Gateway; docs/08/09) auth: mode: native # Plex account auth — documented exception, NOT Keycloak (docs/13/15; R-IDP-02) update: channel: stable # R-UPD-01 (never pre-release) range: ">=1.40.0" # acceptable versions (advisory; image-automation off during onboarding) cadence: "0 4 * * 0" # weekly stable check/apply autoRollbackOnFailedHealth: true # R-UPD-03 observability: metrics: true slis: - up # R-OBS-01: service-down alert backup: # The config volume is on the longhorn (replicated, "irreplaceable") tier, so the L0 Kyverno # backup policy (R-STATE-03) forbids `reacquirable` here — it must reference an off-cluster # schedule. Plex's metadata DB (library index, thumbnails, watch state) is expensive to rebuild, # so it is backed up off-cluster. The bulk library itself, once attached, is the `reacquirable` # part (docs/04) and is NOT covered by this schedule. policy: daily-offcluster # off-cluster backup of the longhorn config volume (docs/04; R-STATE-03)