# 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.