Plex has been transcoding in software on hardware that can do it in the iGPU. This opts its ServiceDefinition into the `hardware` capability from #27 and re-renders. catalog/plex.yaml gains four lines of YAML and the comment that explains them: hardware: gpu: intel-quicksync which renders exactly two things into apps/wave-0/plex.yaml and nothing else — a `dri` hostPath volume (/dev/dri, type: DirectoryOrCreate) and a read-write /dev/dri volumeMount on the app container. Whole-tree render diff: 6 added lines, one file. The comment records what is not obvious from the four lines: the device exists on ALL THREE prod nodes with i915 bound (so no nodeSelector is rendered and none is wanted); why `supplementalGroups` would be INERT rather than a hardening (the image's cont-init hook adds the plex user to the device's group inside the container, and s6-setuidgid then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied); and why `DirectoryOrCreate` is load-bearing (apps-wave-0 is wait:true with apps-wave-1 depending on it, so a failed mount would stall the whole app tier for a feature that is not). THE FEATURE IS INERT UNTIL A HUMAN CLICKS A BOX, and that is the part this commit is really about. Mounting the device does not enable hardware transcoding; Plex's `HardwareAcceleratedCodecs` preference does, and it is ABSENT from Preferences.xml — the toggle has never been on. It cannot be automated: 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, and the six setPref keys the image does write are all unrelated. So runbooks/plex-quicksync.md is the procedure, registered in the configMapGenerator (a runbook in git with no generator entry is reviewed, merged, and still a 404) and indexed in runbooks/README.md. It covers the GitOps/manual boundary, the toggle and the real preference keys (including the widely-cited one that does not exist and the upstream misspelling nobody should "fix"), verification via `Transcode (hw)` on the Plex dashboard — stated plainly as the ONLY proof, because Plex renders no ServiceMonitor and an alert could never fire — the device-cgroup failure to expect first with its Intel-device-plugin fallback, and the floating-`:latest` caveat. Stacked on #27 (feat/sd-hardware-capability), which is open and unmerged; that branch's commit is in this range by design and this must merge after it. NOT VERIFIED, and the handoff says so at length: hardware transcoding has not been observed working. Whether containerd's device cgroup lets a non-privileged pod open() a bind-mounted char device is an open question that only deploying settles, and the Plex-side toggle was still unset at the time of writing. A green gate proves the manifest renders; it proves nothing about transcoding. Gate (pinned toolbox, worktree): rc 3 — UNDETERMINED, not green. Passed: decisions-log, lint, l0, render-golden, l1, approval-policy. Skipped (the known local ceiling): spec-immutability (no sibling spec repo), gitea-settings (no SETTINGS_API_TOKEN), conformance (CLUSTER_AVAILABLE unset, so L2/L3 did not run). No fourth skip, nothing failed. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
16 KiB
plex-quicksync — making Plex actually use Intel Quick Sync, and proving it did
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 dri hostPath volume and the /dev/dri mount), catalog/render.py (the
mechanism), schemas/renderer-contract.md ### hardware (the contract).
Related: runbooks/plex-claim.md — the other Plex procedure with a manual last mile.
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 at /dev/dri |
GitOps. hardware.gpu: intel-quicksync in catalog/plex.yaml renders a dri hostPath volume (/dev/dri, type: DirectoryOrCreate) and a read-write /dev/dri volumeMount on the app container. Flux applies it. |
Done by merging that change. |
| Plex is told to use it | The operator, by hand, in the Plex web UI. | NOT done. HardwareAcceleratedCodecs was absent from Preferences.xml when this page was written — the toggle has never been on. |
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.
What the device mount does not do, so nobody reads more into a green gate than is there: it does
not enable hardware transcoding, it does not prove the device can be opened from inside a
non-privileged pod (see "Likely causes" #2), and it renders no nodeSelector — Plex can land on any
of the three nodes and all three carry the device.
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
1. Is the device mounted into the pod at all? (Proves Flux applied the change, not that it works.)
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].volumeMounts[*].mountPath}"; echo'
/dev/dri in that list ⇒ the GitOps half landed. Absent ⇒ the running pod predates the merge; 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.
2. Is the device actually visible inside the container?
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- ls -l /dev/dri'
Expect renderD128 (usually card0 too), mode crw-rw----, group render. An empty directory
here is the DirectoryOrCreate degradation: the kubelet created an empty /dev/dri because the node
had none. That is the designed-safe outcome — the app tier still reconciles — but it means software
transcoding, silently. Find which node the pod is on and ask why that node lost its iGPU:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex get pod -l app.kubernetes.io/name=plex -o wide'
3. Can Plex open it? This is the question the manifest cannot answer — see "Likely causes" #2.
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
bash -c "exec 3<>/dev/dri/renderD128 && echo RW-OPEN-OK || echo RW-OPEN-DENIED"'
RW-OPEN-DENIED (or Operation not permitted / Permission denied) with the device plainly visible
in step 2 is the device-cgroup failure, not a file-permission failure. The two look identical from
the outside and are not the same problem.
4. Is the toggle on? Named key only — never the whole file:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
grep -o "HardwareAcceleratedCodecs=\"[01]\"" \
"/config/Library/Application Support/Plex Media Server/Preferences.xml" || echo ABSENT'
ABSENT ⇒ never enabled (the state this page was written in). ="0" ⇒ explicitly off. ="1" ⇒ on,
and the remaining question is whether it works — go to "How to verify".
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. 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 — and why there is no alert
Start a playback that forces a transcode (a client that cannot direct-play the source, or pick a lower quality explicitly), then read Plex's own Dashboard → Activity:
Transcode (hw)on the session ⇒ hardware transcoding. This is the proof.- plain
Transcode⇒ software. The feature is not working, whatever the manifest says.
That dashboard is the only proof available, and nothing else will ever tell you.
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.
Corroborating, not a substitute — the server log on the config PVC:
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- \
sh -c "grep -i -m20 -e hardware -e vaapi -e hwdevice \
\"/config/Library/Application Support/Plex Media Server/Logs/Plex Media Server.log\""'
Treat those strings as advisory: they are Plex's, not this repo's, and they move between versions. Absence of a match is not evidence of software transcoding; a VA-API device open is good evidence of hardware. The dashboard remains the verdict.
Likely causes, most likely first
When the dashboard still says plain Transcode:
-
The toggle is off. Overwhelmingly the most likely answer, and the reason this page exists. First-command #4 returns
ABSENTor="0". Remedy: the manual step above. Check this before investigating anything technical. -
containerd's device cgroup is refusing the
open()— UNVERIFIED, and the largest remaining technical risk in this feature. Whether a non-privileged pod mayopen()a bind-mounted character device on this k3s/containerd build has not been established; it can only be settled by deploying and trying. The container inits as uid 0, which settles file permissions, but uid 0 does not bypass the cgroup device gate. Symptom: step 2 showsrenderD128present and correctly owned, step 3 returnsRW-OPEN-DENIED. The documented fallback is the Intel Device Plugin for Kubernetes (gpu.intel.com/i915as a resource request, no hostPath). Because the ServiceDefinition field expresses intent rather than a device path, that migration is acatalog/render.pychange plus a re-render — with no ServiceDefinition edit anywhere;catalog/plex.yamlkeeps saying exactly what it says today.privileged: trueis not an available escape hatch: Kyvernorestrict-host-namespacesis in Enforce mode and rejects it in app namespaces. Do not reach for it. -
The pod landed on a node without the device. Step 2 shows an empty
/dev/dri. All three prod nodes carriedrenderD128withi915bound when this was measured (2026-09-30), so this means a node changed — a rebuild, a BIOS reset, or a driver regression.DirectoryOrCreateis why the app tier still came up; the cost is that the loss is silent. Fix the node, do not tighten the mount:Directorywould stallapps-wave-0(wait: true, withapps-wave-1depending on it) for a feature that is not load-bearing. -
The image changed under you. See "The floating-image caveat". A
:latestpull can remove the mechanism this feature leans on with no commit in this repo. -
The session was never transcoding. Direct Play / Direct Stream never touches the transcoder, so
Transcode (hw)cannot appear. Confirm the session actually saysTranscodebefore treating it as a hardware failure. -
The source needs a codec Quick Sync on this generation cannot handle — e.g. AV1 decode, which Coffee Lake's UHD 630 has no engine for. Plex falls back to software per stream. Symptom: some sessions show
(hw)and others do not. Not a fault.
The floating-image 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).
The deployment runs plexinc/pms-docker:latest with imagePullPolicy: Always. A silent pull can
therefore change the behaviour this feature depends on with no commit in this repo, while the
manifest still looks identical. Symptom to watch for: hardware transcoding that worked and quietly
stopped, with no git change to point at.
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. 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
supplementalGroupsor apodSecurityContexttocatalog/plex.yaml. It would be inert, and it would mislead the next person. Measured in-container 2026-09-30: the image'scont-init.dhook stats each/dev/drichar device andusermod -a -Gs theplexuser into its group inside the container, and the service then execs vias6-setuidgid plex, which replaces the process group list from/etc/grouprather than appending —Plex Media Serverruns withGroups: 100 1000, root's gid0(which PID 1 does hold) gone. Anything Kubernetes supplies is discarded before Plex starts.render.py --selftestasserts that absence deliberately. - Do not reach for
privileged: true. Kyvernorestrict-host-namespacesis Enforce and will reject it. If the cgroup gate is the blocker, the answer is the Intel device plugin (cause #2). - Do not
kubectl edit/patchthe Deployment to add or change the device mount. Flux reconcilesmain; a hand-applied change is drift that the next reconcile reverts or, worse, adopts. Mutatingkubectlandflux reconcileare operator actions, and the durable change is a git change through a branch, a PR and a green gate. - Do not edit
Preferences.xmlon the PVC, nevercatit, and neversops -danything into a terminal. It holdsPlexOnlineToken. - Do not delete the
plex-configPVC 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 (cause #2 confirmed). The next step is a platform change — the Intel device 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 anodeSelectorfield the schema does not have), or give/transcodeamedium: MemoryemptyDir 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/
The node measurements (all three prod nodes) and the in-container image measurements cited here were
taken 2026-09-30 against plexinc/pms-docker digest sha256:e0ab27395614…
(plexmediaserver 1.43.4.10903). Hardware transcoding had not been observed working at the time
of writing, and the Plex-side toggle was still unset.