Gateway target -> fc81938b-...cfargotunnel.com, --cloudflare-proxied globally, L4 service publishers (unifi, crafty-mc) pinned DNS-only, Plex web UI and Headlamp disowned (LAN/VPN-only). L0 internal-services-publish-no-public-dns gains assertion (9) tying the Gateway target to the tunnel UUID, requiring cloudflare-proxied=false on every service-source publisher, and keeping the tunnel's 404 list and the unpublished set consistent. L2 dns-exposure fixture opts out of proxying. Co-Authored-By: Claude Opus 5.5 <[email protected]>
421 lines
29 KiB
YAML
421 lines
29 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:1.43.6.11085-d1e52e169
|
|
# 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: internal (operator decision 2026-11-08, Cloudflare Tunnel cut-over). The plex.${BASE_DOMAIN}
|
|
# web UI is LAN/VPN-only: `internal` renders `external-dns ... controller: none` on the HTTPRoute, so
|
|
# external-dns DELETES the public plex record and its TXT, and platform/cloudflared/config.yaml maps the
|
|
# name to http_status:404 so it can never be served through the tunnel either. REMOTE STREAMING IS NOT
|
|
# AFFECTED: it never used this name. It rides plex.tv + the advertised plex.direct URL (ADVERTISE_IP
|
|
# above) to the raw tcp/32400 L4 VIP (${PLEX_L4_VIP}, apps/wave-0/plex-remote.yaml) via the WAN
|
|
# tcp/32400 forward — none of which this field touches. See runbooks/plex-remote-access.md.
|
|
exposure: internal # web UI LAN/VPN-only; remote streaming = plex.tv + WAN tcp/32400 (docs/09; R-DNS/R-NET)
|
|
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
|
storage:
|
|
- name: config
|
|
size: 16Gi
|
|
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)
|