Files
home-network-k3s-impl/catalog/plex.yaml
jakman85andClaude Opus 5 874c6703f0
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
fix(plex): advertise a resolver-independent plex.direct name again (LAN-wide outage)
I took Plex down for the whole LAN. ADVERTISE_IP was
http://plex-vip.${BASE_DOMAIN}:32400 -- a name that exists ONLY in PiHole's
split-horizon answers and in no public zone.

A Plex client resolves the advertised host ITSELF, with whatever resolver it
uses, and on a real LAN that is frequently not PiHole: browsers ship DoH on by
default, phones have "private DNS", guest/IoT devices take the router's DNS.
Those clients could not resolve the name, the router answered the failed lookup
with ITSELF, and they all landed on its web UI -- hence the %D certificate.

Measured 2026-10-05:
  192.168.2.1:443      -> C=ZZ, O=%D342e7e6b, CN=%D   (router, LAN side)
  136.51.115.252:443   -> the same %D cert            (router, WAN side)
  pihole-0 AND pihole-1 -> plex -> .20, plex-vip -> .24   (both CORRECT)
  gateway .20:443      -> CN=*.home.keithjackson.dev, valid
  http://192.168.2.24:32400/identity -> 200
  Plex's published connections: all port 32400, nothing on 443

The DNS was right; the DEPENDENCY on it was the bug. And since nothing Plex
advertises listens on 443, the clients had left the Plex path entirely.

Fix: <dashed-ip>.<serverhash>.plex.direct, which is RESOLVER-INDEPENDENT -- a
public zone returning the private address embedded in the label, so every
resolver (PiHole, the router, 1.1.1.1, a DoH browser) answers it with
192.168.2.24. No split-horizon entry, no Cloudflare record and no certificate
work needed: the wildcard cert Plex already serves on 32400 covers that name.
Re-verified today with full validation (no curl -k): 200, TLS verified.

The nicer hostname cannot be made correct for clients that bypass PiHole -- a
public record resolves to the WAN address and there is no NAT hairpin, so it is
right only for PiHole users and wrong for the rest. There is no configuration of
it that is right for both. PiHole's plex-vip answer is KEPT; it is just not what
Plex advertises.

Cost restated, not buried: the value embeds the VIP and the cert hash as a
literal and nothing checks they still agree. Accepted because an unresolvable
name is a worse failure -- silent, LAN-wide, and it points users at the router.

UNVERIFIED: unreconciled, and the client-side resolver path is INFERRED -- my
public-resolver probe was broken (NXDOMAIN even for a name that resolves) so I
discarded it. The chain is the only explanation consistent with the
measurements, but the NXDOMAIN-hijack step was not observed directly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 20:58:55 -05:00

414 lines
28 KiB
YAML

# 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 connection Plex publishes to plex.tv.
#
# THE VALUE MUST BE RESOLVABLE BY EVERY CLIENT, NOT JUST BY CLIENTS THAT USE OUR DNS. That single
# property is what this field keeps getting wrong, so it is the first thing to check before
# changing it. A Plex client resolves the advertised host ITSELF, with whatever resolver it
# happens to use — and on a real LAN that is frequently NOT PiHole: browsers ship DoH on by
# default, phones have "private DNS", guest and IoT devices often take the router's DNS, and some
# clients hard-code a public resolver.
#
# THAT IS NOT THEORETICAL — IT TOOK PLEX DOWN FOR THE WHOLE LAN ON 2026-10-05. This field briefly
# advertised `plex-vip.${BASE_DOMAIN}`, a name that exists ONLY in PiHole's split-horizon answers
# (catalog/pihole.yaml) and in no public zone. Clients that did not use PiHole could not resolve
# it; the SOHO router answered the failed lookup with ITSELF, and every one of them landed on the
# router's own web UI and reported a certificate error for `CN = %D`. Measured 2026-10-05: the
# router serves that self-signed cert (`C = ZZ, O = %D342e7e6b, CN = %D`) on BOTH `192.168.2.1:443`
# and the WAN address, while PiHole itself was answering perfectly — both replicas returned
# `plex-vip -> ${PLEX_L4_VIP}` and `plex -> ${GATEWAY_VIP}`. The DNS was right; the DEPENDENCY on
# it was the bug. Nothing Plex advertises listens on 443, which is how we know the clients had left
# the Plex path entirely and were talking to the router.
#
# SO: `<dashed-ip>.<serverhash>.plex.direct`, WHICH IS RESOLVER-INDEPENDENT. This is Plex's own
# mechanism and the reason it exists: `plex.direct` is a PUBLIC zone that returns the private
# address embedded in the label, so EVERY resolver on earth — PiHole, the router, 1.1.1.1, a DoH
# browser — answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no
# split-horizon entry, no Cloudflare record, and no certificate work, because the wildcard cert
# Plex already serves on 32400 covers exactly this name. Re-verified live 2026-10-05 with FULL
# validation (no `curl -k`): resolves to 192.168.2.24 and `/identity` returns **200, TLS verified**.
#
# WHY NOT THE NICER HOSTNAME, since a readable name in our own zone is obviously more pleasant:
# it cannot be made to work for clients that bypass PiHole. A public record for it would resolve
# to the WAN address, and with no NAT hairpin on this router a LAN client sent there fails too —
# so the hostname is correct ONLY for clients using PiHole, and wrong for the rest. There is no
# configuration of that name that is right for both. `plex.direct` is right for both.
# (The PiHole `plex-vip` answer is KEPT — it is harmless, costs nothing, and is genuinely useful
# for reaching the VIP by name from a LAN shell. It is simply not what Plex advertises.)
#
# THE TWO EMBEDDED VALUES, and the honest cost of this choice. The dashed IP must track
# ${PLEX_L4_VIP} and `<serverhash>` is this server's certificate identity — read it from the live
# listener with
# openssl s_client -connect <vip>:32400 </dev/null | openssl x509 -noout -subject
# Nothing in this tree can derive either, and nothing checks that they still agree: envsubst
# cannot dash an IP, so this is a literal. That fragility is real and was the reason the hostname
# was tried in the first place — it is accepted here because an unresolvable name is a WORSE
# failure (silent, LAN-wide, and it points users at the router) than a stale literal, which at
# least fails loudly and only after someone changes the VIP.
#
# 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, with its `/.firstRunComplete` marker 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.
- name: ADVERTISE_IP
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct: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)