# plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did **Status: hardware transcoding is CONFIRMED WORKING on prod as of 2026-10-02** — VAAPI for both decode *and* encode, zero-copy, measured in Plex's own server log (see "How to verify"). The manual toggle has been ticked. This page is therefore now mostly a **verification and regression** procedure, and its old verification section was **wrong** — see the correction at the top of "How to verify". **Alert:** none, and none is possible — see "How to verify" below. This is an **operator procedure**, not an incident page. It is indexed here because the feature it completes is **inert until a human clicks one button**, and a switched-off feature that looks shipped is exactly what this page exists to prevent. **Files:** `catalog/plex.yaml` (`hardware.gpu: intel-quicksync`), `apps/wave-0/plex.yaml` (the rendered golden — the `resources.limits: gpu.intel.com/i915: 1` block), `catalog/render.py` (the mechanism), `schemas/renderer-contract.md` `### hardware` (the contract), `platform/intel-gpu-plugin/` (the DaemonSet that advertises the resource). **Related:** `runbooks/plex-claim.md` — the other Plex procedure with a manual last mile. ## STOP HERE IF PLEX IS `Pending` / `Unschedulable` That is this feature's **one serious failure mode** and it **stalls the whole app tier** (`apps-wave-0` is `wait: true`; `apps-wave-1` `dependsOn` it). Since 2026-10-01 Plex requests the extended resource `gpu.intel.com/i915: 1`, which is a **hard scheduling constraint** — if the `intel-gpu-plugin` DaemonSet is not advertising it on at least one schedulable node, Plex does not start at all. Go to **"Plex will not schedule"** below; it is the first troubleshooting section on purpose. **The escape hatch, in one sentence:** delete the two `hardware:` lines from `catalog/plex.yaml`, re-render, and merge — Plex returns to software transcoding and the tier unblocks. Exact commands are in **"Plex will not schedule" → remedy B**. ## What this means here Plex transcodes in **software** by default. On the UHD Graphics 630 iGPU of an i7-9700T, Quick Sync moves that work off the CPU — which matters on a three-node cluster where the CPU is also running everything else. Two independent things have to be true before that happens, and **only one of them is in git**: | Half | Who does it | State | |---|---|---| | The render device is inside the container **and openable** | **GitOps.** `hardware.gpu: intel-quicksync` in `catalog/plex.yaml` renders `resources.limits: {gpu.intel.com/i915: 1}` on the app container. The `intel-gpu-plugin` DaemonSet (`platform/intel-gpu-plugin/`) injects the device **and the matching device-cgroup allow rule**. Flux applies it. | Done by merging that change. | | Plex is *told* to use it | **The operator, by hand, in the Plex web UI.** | **Done, 2026-10-02**, and confirmed from the **log** rather than from a preference key. `HardwareAcceleratedCodecs` is **still absent** from `Preferences.xml` with hardware transcoding working, so its absence proves nothing — see first-command #4. | **There is no GitOps path to the second half, and that is verified rather than assumed.** A bounded grep across 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. Every `setPref` the image performs is one of six hardcoded keys (`MachineIdentifier`, `ProcessedMachineIdentifier`, `PlexOnlineToken`, `customConnections`, `allowedNetworks`, `TranscoderTempDirectory`), none of them the transcode toggle. No environment variable, no ConfigMap and no `postBuild` substitution can set it. So do not go looking for the clever way to automate this; there isn't one, and the search has already been done. ### Why a resource request and not a `/dev/dri` hostPath Because the hostPath was **tried and measured not to work**, and knowing that is the only thing that will stop someone "simplifying" it back. Merged 2026-09-30, measured in the live pod on k3s-prod-2 on **2026-10-01**: the device node was plainly **visible** in the container (`crw-rw---- root video2 226,128 renderD128`) and **could not be opened** — | attempt | result | |---|---| | `uid 0`, `O_RDWR` | **`EPERM`** "Operation not permitted" | | `uid 1000`, `O_RDWR` | **`EPERM`** "Operation not permitted" | `EPERM` is the **device cgroup** refusing, not `EACCES` from file permissions. The two look identical from the outside and are not the same problem. Proven three ways: root on the **host** opened the same device `rc=0`; the node is unified `cgroup2fs`; and the container's runc `config.json` read `linux.resources.devices: [{allow: False, access: 'rwm'}]` with `linux.devices: None` — **one deny-all rule, no allow entry for major 226**. A hostPath is a *filesystem* operation: it binds the device node in and adds **no cgroup allow rule**, because Kubernetes cannot infer one from a volume. Two non-workarounds, so nobody spends an evening on them: **`uid 0` failed too**, so running as root does not bypass the cgroup gate; and **`privileged: true` is not available** — Kyverno `restrict-host-namespaces` is **Enforce** on app namespaces and rejects it. The device plugin is what produces the allow rule — in the v0.37.0 source, `createDeviceSpecsFromDrmFiles` emits `DeviceSpec{…, Permissions: "rw"}`, which the kubelet turns into both the device injection and the matching cgroup allow. **The failure mode that trade bought, stated plainly.** Before, a missing device meant the pod ran and **silently software-transcoded** — invisible, but harmless to everything else. Now, a missing *resource* means Plex is **Unschedulable** and the app tier stalls. That is strictly worse in blast radius and it is **unavoidable**: an extended resource is a hard scheduling constraint, and it is also the only thing that produces the cgroup allow rule. There is no graceful-degradation variant. The one improvement is that the loss is no longer silent. What this does **not** do, so nobody reads more into a green gate than is there: it does not enable hardware transcoding (that is the manual toggle below — done 2026-10-02), a green gate only proves the **manifest renders**, and it writes no `nodeSelector` — the resource itself constrains Plex to nodes the plugin advertises, which is the scheduler's doing and not a selector in this repo. ## Running the commands in this runbook `kubectl` does not work on the operator workstation; the toolchain lives only in the pinned dev container. Every command below runs as: infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes' Always pass `--context k3s-prod` explicitly. `dev.sh` merges both clusters into one kubeconfig and picks a current-context **per invocation**, and that default has already changed once. Do not run two `dev.sh` invocations at once. **Never `cat` `Preferences.xml`.** It contains `PlexOnlineToken`, a plex.tv account credential. Every read of that file below greps a single named key — do not widen one. ## First commands **0. Is Plex running at all?** Ask this first now — the current mechanism can stop it starting. ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide' ``` `Pending` ⇒ go straight to **"Plex will not schedule"**. Running ⇒ carry on. **1. Do the nodes advertise the resource, and is the plugin Ready?** This replaces the old "is the hostPath mounted" check, and it is now the first thing that can be wrong. ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod get nodes -o json \ | jq -r ".items[] | \"\(.metadata.name) i915=\(.status.allocatable[\"gpu.intel.com/i915\"] // \"ABSENT\")\""' ``` Expect `i915=1` on each of the three prod nodes (measured 2026-10-01: all three). Any `ABSENT` ⇒ that node cannot run Plex. Then the DaemonSet behind it: ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n intel-gpu-plugin get daemonset intel-gpu-plugin' ``` Expect **3 desired / 3 ready / 3 available** (measured 2026-10-01). Anything less is the cause, not a symptom — fix it there, in `platform/intel-gpu-plugin/`, not here. **2. Did Flux apply the current manifest?** `gpu.intel.com/i915` on the app container means the GitOps half landed: ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \ -o jsonpath="{.items[0].spec.containers[0].resources.limits}"; echo' ``` A `/dev/dri` **volumeMount** instead ⇒ the running pod predates the 2026-10-01 merge and is on the mechanism that does not work. Compare ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n flux-system get kustomization apps-wave-0 \ -o jsonpath="{.status.lastAppliedRevision}"; echo' ``` against the merge commit before blaming anything else. **3. Is the device visible inside the container, and can Plex *open* it?** This is the decisive test of the whole mechanism, and **it must be run as uid 1000**, because that is what Plex runs as. ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex exec deploy/plex -- ls -l /dev/dri' ``` Expect `renderD128`, mode `crw-rw----`. The plugin injects it; there is no longer any `DirectoryOrCreate` empty-directory case — if the resource were missing the pod would not be running to exec into. Then the open, **as the app user**: ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex exec deploy/plex -- \ s6-setuidgid plex sh -c "exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED"' ``` `RW-OPEN-OK` **as uid 1000** is the pass condition. **Passing as `uid 0` is weaker evidence and must not be accepted as proof** — Plex runs as 1000, and the whole point of the 2026-10-01 change was a cgroup rule that uid 0 could not buy its way past either. `RW-OPEN-DENIED` with the device plainly visible is the **device-cgroup** failure — i.e. the plugin's allow rule did not arrive; see "Likely causes" #2. **4. Is the toggle on?** **You cannot answer this from `Preferences.xml` alone.** That mistake is on this page's record: it told readers to treat a missing `HardwareAcceleratedCodecs` as "never enabled", and on 2026-10-02 that key was **absent from all 27 attributes** while hardware transcoding was demonstrably working. Named keys only — never the whole file, it holds `PlexOnlineToken`: ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex exec deploy/plex -- \ grep -o -e "HardwareAcceleratedCodecs=\"[01]\"" -e "HardwareDevicePath=\"[^\"]*\"" \ "/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo NEITHER-KEY' ``` | Reading | What it actually means | |---|---| | `HardwareDevicePath="…"` **present** | **Positive signal.** Plex writes this when hardware acceleration is configured; it was present on 2026-10-02 alongside a working VAAPI pipeline. | | `HardwareAcceleratedCodecs` **absent** | **Nothing. Neither on nor off.** Measured absent with the feature working (2026-10-02). Do not diagnose from it, and do not write it into the file to "fix" it. | | `HardwareAcceleratedCodecs="0"` | explicitly **off** — the only conclusive reading, and only in the negative. | | `HardwareAcceleratedCodecs="1"` | on. | **The log settles it, this file does not.** Go to "How to verify"; the `hardware transcoding: enabled` and `final decoder/final encoder` lines are the verdict. ## The one manual step Plex web UI → **Settings → Transcoder → "Use hardware acceleration when available"** → tick → Save. (It sits under *Show Advanced*.) A transcode already in flight is unaffected; the next one picks it up. The underlying preference key is **`HardwareAcceleratedCodecs`**. That name was confirmed by extracting strings from the 23 MB `Plex Media Server` binary in the running container — **not** from recall and not from a forum post. **But do not expect to see it in `Preferences.xml` after ticking the box:** on 2026-10-02, with the toggle on and hardware transcoding working, the key was absent and `HardwareDevicePath` was what had been written. The key exists in the binary; Plex does not necessarily persist it. Real neighbouring keys from the same binary, so you can recognise them and not invent them: | Key | What it is | |---|---| | `HardwareAcceleratedCodecs` | the toggle this page is about | | `HardwareAcceleratedEncoders` | hardware **encode**, as distinct from decode | | `HardwareDevicePath` | how Plex is pointed at a *specific* render node — relevant only if a host ever exposes more than one | | `TranscoderToneMapping`, `TranscoderToneMappingAgorithm` | HDR→SDR tone mapping. **That misspelling of "Algorithm" is upstream's, in the binary. It is not a typo in this page — do not "fix" it** here or anywhere else; the corrected spelling is not a key Plex reads. | | `TranscoderHEVCEncoding` | HEVC output | **`HardwareAcceleratedCodecsEnableHEVC` does not exist.** It is widely cited and it is not in the binary. Recorded explicitly so nobody spends an evening hunting for it or, worse, writes it into `Preferences.xml` and concludes hardware transcoding is broken. Editing `Preferences.xml` by hand is **not** the procedure. It is app state on the replicated Longhorn tier, it is in no git commit, and the UI writes the key correctly. Use the UI. ## How to verify — the log, not the dashboard **Correction, 2026-10-02.** Until today this page said the Plex dashboard showing `Transcode (hw)` was "the *only* proof available, and nothing else will ever tell you". **That was false and it cost the operator real time:** hardware transcoding was fully working — VAAPI decode *and* encode, with zero-copy — while **Dashboard → Activity showed nothing of the sort**. A check that is silent while the feature works does not just fail to prove anything; it sends someone hunting a fault that does not exist. The authoritative check is **Plex's own server log**. ### The primary check — run this one ``` ./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n plex exec deploy/plex -- \ grep -aiE "hardware transcoding: (final|using)" \ "/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log" \ | sed -E "s/(X-Plex-Token|token)=[^&[:space:]]*/\1=REDACTED/g" | tail -5' ``` | Line | Verdict | |---|---| | `hardware transcoding: final decoder: vaapi, final encoder: vaapi` | **PASS.** Hardware in **both** directions. This is the pass condition. | | `hardware transcoding: using zero-copy transcoding` | Best case — frames never round-trip through the CPU. Observed on prod 2026-10-02. | | `hardware transcoding: final decoder: , final encoder: ` (both empty) | It **fell back to software** for that session. | | no matching line at all | No transcode has been attempted since the log rolled — force one (below), then look again. Absence is not a failure. | The 2026-10-02 reading on prod, in full, for the shape to recognise: ``` Codecs: hardware transcoding: testing API vaapi for device '/dev/dri/renderD128' (Intel CoffeeLake-S GT2 [UHD Graphics 630]) hardware transcoding: using hardware decode accelerator vaapi hardware transcoding: zero-copy support present hardware transcoding: using zero-copy transcoding hardware transcoding: final decoder: vaapi, final encoder: vaapi ``` To make a line appear, start a playback that **forces a transcode**: a client that cannot direct-play the source, or an explicitly lower quality. Direct Play / Direct Stream never reaches the transcoder and logs nothing. ### Three things to get right about that command 1. **Always keep the `sed`.** `Plex Media Server.log` carries API URLs containing **`X-Plex-Token=…`**, a live plex.tv credential. Any output that might land in a ticket, a transcript or a chat window must be redacted — CLAUDE.md §6, and a credential has already been leaked into a transcript in this repo exactly this way. The redacting form above is the form to copy; do not "simplify" the pipe out of it. 2. **`Plex Transcoder Statistics.log` is the WRONG FILE.** The name is irresistible and someone will reach for it. It holds XML `` session records — **not** ffmpeg/VAAPI output. There is no decoder/encoder verdict anywhere in it. The file you want is `Plex Media Server.log`. 3. `grep -a` is deliberate: the log carries occasional non-UTF-8 bytes, and without `-a` GNU grep reports "binary file matches" and prints nothing — which reads exactly like "no hardware transcoding". ### Secondary and unreliable: the Plex dashboard **Dashboard → Activity** *may* label a transcoding session `Transcode (hw)`. If it does, it agrees with the log and you are done. **If it does not, that proves nothing at all** — measured 2026-10-02: hardware transcoding active and zero-copy, dashboard silent. Never conclude "software transcoding" from the dashboard and never open an investigation on its say-so. Kept here only because it is the fastest glance available; it is a convenience, **not evidence**. ### Process-level, while a transcode is in flight: the transcoder's argv This shows Plex *requesting* hardware, as distinct from reporting it afterwards. Get a shell in the pod (if `-it` has no TTY under `dev.sh run`, wrap each line in `sh -c "…"` instead): ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex exec -it deploy/plex -- sh' ``` then, inside the pod: ``` ps -eo pid,user,comm | grep -i transcoder tr '\0' ' ' < /proc//cmdline | grep -oE '(-hwaccel[a-z_]*|vaapi|renderD[0-9]+)' ``` `-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128` present ⇒ Plex is asking for hardware. Keep the `grep -oE`: the raw argv contains media paths and session identifiers you do not want in a transcript. This only works while a session is actually transcoding. ### Documented false lead: scanning `/proc/*/fd` for a holder of `renderD128` **It returns nothing even when hardware transcoding is working.** Measured 2026-10-02: a `/proc/*/fd` sweep inside the pod found **no** process holding `/dev/dri/renderD128`, while the log showed `final decoder: vaapi, final encoder: vaapi` for sessions either side of the scan. Transcode sessions are **short** and the scan landed in a gap between two of them. An empty fd scan is a **timing artefact, not a finding** — do not report it as evidence of software transcoding, and do not escalate on it. Use the argv check above (which has the same timing constraint but tells you something when it does catch a session), or just read the log, which is retrospective and therefore does not care about timing. ### Why there is no alert — this half was always right `catalog/plex.yaml` declares no `metricsEndpoint`, so Plex renders **no ServiceMonitor at all** — there is no scrape, no transcode-mode metric, and therefore no alert that could fire. Do not open work to "alert on software-transcode fallback": with no metric behind it such a rule would be permanently inert, and this repo has already been burned at scale by rules that evaluated against nothing while looking like coverage (`runbooks/README.md`, "Alerts you should not trust the silence of"). If that gap is ever worth closing, the honest order is a real exporter first and a rule second. ## Likely causes, most likely first When the **log** says it fell back to software — `final decoder: , final encoder: ` with both fields empty. (Not when the dashboard is quiet: the dashboard's silence means nothing, see "How to verify".) 1. **The toggle is off.** Historically the most likely answer and the reason this page exists. It was ticked on 2026-10-02 and should stay ticked, but a `/config` restore or a stray UI save can unset it. Remedy: the manual step above. **Do not diagnose this from a missing `HardwareAcceleratedCodecs`** — see first-command #4; only `="0"` is conclusive, and the log line `hardware transcoding: enabled` is the real signal that the toggle took. 2. **The device cannot be `open()`ed — the plugin's cgroup allow rule did not arrive.** Symptom: first-command #3 shows `renderD128` present and correctly owned, and the open returns `RW-OPEN-DENIED` / `Operation not permitted`. This is `EPERM` from the **device cgroup**, not `EACCES` from file permissions. **This is no longer a design risk — it is the measured reason the design is what it is** (2026-10-01; see "Why a resource request and not a `/dev/dri` hostPath"). If it recurs *under the current mechanism*, the plugin is the suspect, not the manifest: confirm the pod really did consume the resource ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \ -o jsonpath="{.items[0].spec.containers[0].resources}"; echo' ``` and then check the plugin pod on Plex's node (first-command #1). **Do not reach for `privileged: true`** — Kyverno `restrict-host-namespaces` is Enforce and rejects it, and uid 0 failed the same open in 2026-09-30's measurement anyway. **Do not re-add a `/dev/dri` hostPath**; that is the mechanism this replaced, and it was measured not to work. 3. **The pod landed on a node whose GPU went away.** Under the current mechanism this does **not** present as an empty `/dev/dri` — the node simply stops advertising `gpu.intel.com/i915` and Plex either never schedules there or gets evicted off. So this cause now shows up as **`Pending`**, not as silent software transcoding: see "Plex will not schedule". All three prod nodes carried `renderD128` with `i915` bound when measured (2026-09-30) and all three advertised the resource on 2026-10-01, so this means a node changed — a rebuild, a BIOS reset, or a driver regression. **Fix the node.** 4. **The image changed under you.** See "The image-version caveat". A pms-docker version bump (a Renovate PR, possibly auto-merged) can remove the mechanism this feature leans on. 5. **The session was never transcoding.** Direct Play / Direct Stream never reaches the transcoder, so no `hardware transcoding:` line is written for it at all. An *absent* line is this, not a failure — confirm a transcode actually happened before treating anything as a hardware fault. 6. **The *source* codec may be one this iGPU cannot decode — HYPOTHESIS, not a confirmed diagnosis.** The symptom that points here is fallback on **particular titles** while other sessions are hardware. Measured on prod 2026-10-02, *between* two fully hardware sessions, one session logged: ``` hardware transcoding: enabled, but no hardware decode accelerator found hardware transcoding: final decoder: , final encoder: ``` The platform was demonstrably working either side of it, so the likely explanation is the **file**, not the cluster: UHD 630's media engine decodes H.264, HEVC 8-bit and 10-bit, and VP9 — but **not AV1**, and not 10-bit 4:2:2 HEVC. **This was NOT verified against that particular title.** It is inferred from the hardware's capability set and is written here as the lead to check first, not as a finding. So: fallback on *all* files is a platform problem (causes 1-4); fallback on *some* files, check the source codec before suspecting the device, and expect occasional legitimate per-title fallbacks to be normal rather than a fault. ## Plex will not schedule **Symptom:** the Plex pod sits `Pending`, and `apps-wave-0` does not go Ready, which holds `apps-wave-1` behind it. This is the cost of the 2026-10-01 mechanism and the section you want at 2am. ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex describe pod -l app.kubernetes.io/name=plex | tail -20' ``` `0/3 nodes are available: 3 Insufficient gpu.intel.com/i915` (or similar) confirms it: **nothing is advertising the resource Plex requires**. Plex's schedulability is coupled to the `intel-gpu-plugin` DaemonSet, deliberately and unavoidably — the plugin is also the only thing that produces the device-cgroup allow rule, so there is no variant of this feature that degrades gracefully. **Remedy A — fix the plugin (preferred).** Run first-command #1. If the DaemonSet is not 3/3, or the nodes show `i915=ABSENT`, the fault is in `platform/intel-gpu-plugin/` or on the node, and that is where to fix it. Check its Flux Kustomization reconciled: ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n flux-system get kustomization intel-gpu-plugin' ``` A plugin that is merely **not yet reconciled** resolves itself; one that is failing does not. **Remedy B — the escape hatch: give up hardware transcoding and unblock the tier.** One field, no code change. In a branch (never on `main`, never `kubectl edit` — Flux reconciles `main` and would revert you): 1. delete these two lines from `catalog/plex.yaml`: ```yaml hardware: gpu: intel-quicksync ``` 2. re-render the golden: ``` python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation ``` The only change must be `apps/wave-0/plex.yaml`, losing its `resources` block. 3. gate, PR, merge. Plex schedules anywhere and **software-transcodes**; the app tier unblocks. Re-adding the field later is the same edit in reverse. Nothing about the Plex-side toggle or the `/config` PVC is affected either way — do **not** delete the PVC as part of this. ## The image-version caveat Everything this page states about the image's behaviour — the `cont-init.d` hook that adds the app user to the device's group, the absence of `PLEX_PREFERENCE*`, the six `setPref` keys, the `HardwareAcceleratedCodecs` string — was read from digest **`sha256:e0ab27395614…`** (`plexmediaserver 1.43.4.10903`). **Since 2026-10-30 that digest is pinned:** `catalog/plex.yaml` names `plexinc/pms-docker:1.43.4.10903-e5521bd8c`, which resolved to exactly `sha256:e0ab27395614…` on Docker Hub. Before that the deployment ran the floating `:latest` (implicitly `imagePullPolicy: Always`), and a silent pull could change this behaviour with no commit in the repo. That is no longer the failure mode: the image now changes only through a **Renovate PR** bumping that tag (patch/minor ones auto-merge on a green gate, and the gate does not exercise QuickSync). So the symptom to watch for is hardware transcoding that quietly stopped **after a `chore(deps)` pms-docker bump** — check `git log -- catalog/plex.yaml` first. ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex \ -o jsonpath="{.items[0].status.containerStatuses[0].imageID}"; echo' ``` If that digest is not the one above, **re-verify the hook before trusting this page**: ``` infra/devcontainer/dev.sh run bash -c \ 'kubectl --context k3s-prod -n plex exec deploy/plex -- ls /etc/cont-init.d/' ``` `45-plex-hw-transcode-and-connected-tuner` still present ⇒ the group mechanism is still the one described here: the hook reads the injected device's **own** ownership, which under the device plugin is `root:root`, so it resolves GID `0` (see "When to escalate or stop"). Gone or renamed ⇒ stop and re-measure. In particular the "no `supplementalGroups` needed" conclusion in `catalog/plex.yaml` and `schemas/renderer-contract.md` is only true while that hook exists. ## When to escalate or stop - **Do not add `supplementalGroups` or a `podSecurityContext` to `catalog/plex.yaml`.** It would be **inert**, and it would mislead the next person. The image's `cont-init.d` hook stats each `/dev/dri` char device and `usermod -a -G`s the `plex` user into **that device's** group, inside the container; the service then execs via `s6-setuidgid plex`, which **rebuilds** the process group list from `/etc/group` rather than appending to it. Anything Kubernetes supplies is therefore discarded before Plex starts. `render.py --selftest` asserts that absence deliberately. - **GID `992` is a hostPath-era number — do not expect it, and do not read the root group as a defect.** Under the device plugin the injected `/dev/dri/renderD128` arrives **`root:root`**, so the hook resolves GID **`0`** and adds `plex` to the **root group**. Measured 2026-10-02: `Plex Media Server` runs with `Groups: 0 100 1000`, and `id plex` reports `groups=1000(plex),0(root),100(users)`. Under the old hostPath the device was `root:render` (GID 992 on these nodes) and the reading was `Groups: 44 100 992 1000`. **992 is now irrelevant**: if any page, comment or older handoff tells you to expect it, that text is stale and the system you are looking at is working. The root-group membership is an inherent property of the plugin's `DeviceSpec` — it injects the host device with its host ownership — **not** a defect, and **not** a reason to reintroduce `supplementalGroups`, which would still be a no-op for the reason in the bullet above. - **Do not reach for `privileged: true`.** Kyverno `restrict-host-namespaces` is Enforce and will reject it — and it would not help: `uid 0` failed the same `open()` in the 2026-09-30 measurement, because root does not bypass the cgroup device gate. - **Do not re-add a `/dev/dri` hostPath, and do not "simplify" the resource request into one.** That was the original mechanism and it was measured non-functional on 2026-10-01 (device-cgroup `EPERM` with the device plainly visible). The device plugin's `DeviceSpec{… Permissions: "rw"}` is what grants access; a volume cannot. - **Do not `kubectl edit`/`patch` the Deployment to add or change the device mount.** Flux reconciles `main`; a hand-applied change is drift that the next reconcile reverts or, worse, adopts. Mutating `kubectl` and `flux reconcile` are **operator** actions, and the durable change is a git change through a branch, a PR and a green gate. - **Do not edit `Preferences.xml` on the PVC**, never `cat` it, and never `sops -d` anything into a terminal. It holds `PlexOnlineToken`. - **Do not delete the `plex-config` PVC to "start clean".** It is the library database, metadata and watch state (`policy: daily-offcluster`). The transcode toggle is one preference, not a reason to lose the DB. - **Stop and get the operator** if the device cannot be opened from the pod as uid 1000 (cause #2 confirmed) *under the current mechanism* — note that this open **passed** on 2026-10-02 (`RW-OPEN-OK` as uid 1000 via `s6-setuidgid plex`), so a failure now is a regression. The next step is a platform change in `platform/intel-gpu-plugin/`, with its own PR, not more poking at this workload. ## Known gaps, deliberately not fixed here Recorded so they are findable, and so nobody assumes they were overlooked: - **Transcode scratch is on the wrong medium, and it is invisible.** The image sets `TranscoderTempDirectory=/transcode`, which is **not a volume** — it is the container's writable overlay, i.e. the node's `/data`. That is NVMe on prod-2 but **SATA on prod-1 and prod-3**, so transcode scratch performance changes silently whenever the pod is rescheduled. Both candidate fixes need a **schema** change and are therefore out of scope here: pin Plex to prod-2 (needs a `nodeSelector` field the schema does not have), or give `/transcode` a `medium: Memory` emptyDir and transcode in RAM (~31 GiB per node; needs an emptyDir field the schema does not have). The RAM option is the better one, because it removes the node asymmetry outright instead of working around it. - **GuC/HuC firmware is not loaded** (`i915.enable_guc=0`). The cost is low-power H.264 **encode** quality only — decode, and the feature on this page, are unaffected. Changing it needs a kernel parameter and a **serialized rolling reboot**, which is an operator action (CLAUDE.md §5), and this cluster has a documented history of that going badly (a prod node hung on a warm reboot on 2026-09-03). Not worth coupling to this change. --- Source of truth: `runbooks/plex-quicksync.md`. Index: `/runbooks/` **Hardware transcoding is CONFIRMED WORKING on prod, measured 2026-10-02.** From `Plex Media Server.log` in the running pod: API `vaapi` on `/dev/dri/renderD128` (`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`, `zero-copy support present`, `using zero-copy transcoding`, and `final decoder: vaapi, final encoder: vaapi`. Also measured in the same pod: `RW-OPEN-OK` opening `/dev/dri/renderD128` as uid 1000 via `s6-setuidgid plex`; the pod spec carrying `{"limits":{"gpu.intel.com/i915":"1"},"requests":{"gpu.intel.com/i915":"1"}}`; `Groups: 0 100 1000` and `id plex` → `groups=1000(plex),0(root),100(users)`; and `HardwareDevicePath` present in `Preferences.xml` while `HardwareAcceleratedCodecs` was absent from all 27 attributes. Earlier readings, kept for provenance and **superseded** where they conflict: the node measurements (all three prod nodes) and the in-container image measurements taken **2026-09-30** against `plexinc/pms-docker` digest `sha256:e0ab27395614…` (`plexmediaserver 1.43.4.10903`); the hostPath `EPERM` open results and the `Groups: 44 100 992 1000` / GID-992 group reading, **2026-10-01, under the hostPath mechanism that no longer exists**; and the `gpu.intel.com/i915: 1` allocatable and `intel-gpu-plugin` DaemonSet readings, **2026-10-01**. What this page still does **not** establish: that any particular title's software fallback was caused by its source codec (cause 6 is an unverified hypothesis), and that Plex's dashboard *ever* labels a session `Transcode (hw)` on this deployment — it has not been observed doing so, which is exactly why the log is now the primary check.