From silicon to stream: the hardware acceleration pipeline
This page traces the full hardware acceleration path in the homelab — what has to line up between an Intel iGPU sitting on a NUC motherboard and Jellyfin transcoding a 4K HEVC file in real time without melting a CPU core.
Five layers are involved, each owned by a different part of the repo, and each able to break the pipeline on its own. The actors:
| Actor | Where it lives |
|---|---|
| Intel Iris Xe iGPU (in the i5-1340P NUCs) | hardware/talos |
Proxmox PCI passthrough (per-worker hostpci) | foundation/proxmox · foundation/tofu |
Talos i915 + intel-ucode system extensions | foundation/talos · talos/talos/talconfig.yaml |
Intel GPU device plugin (DaemonSet in kube-system) | platform/intel-gpu-plugin |
| Kyverno policy exceptions for the plugin | platform/kyverno |
| Jellyfin (on-demand transcoding) | apps/jellyfin |
| FileFlows (batch transcoding pipelines) | apps/fileflows |
The stack, bottom-up
One GPU per worker, three workers, and everything between the silicon and the container is declarative — the passthrough is OpenTofu, the kernel module is a Talos schematic line, the plugin is a Flux-managed DaemonSet, and the consumers are Kustomize patches.
Layer 1 — the silicon
Each of the three NUC 13 Pro nodes carries an Intel Core i5-1340P, whose integrated Iris Xe GPU includes Quick Sync fixed-function encode/decode blocks. That is the entire GPU fleet — there are no discrete cards. For a media-serving homelab this is the sweet spot: Quick Sync handles H.264/HEVC transcodes at a few watts, and the iGPU is already paid for.
The NUCs are Proxmox hosts, so the iGPU first belongs to the hypervisor — Kubernetes never sees the physical machine.
Layer 2 — Proxmox passes the GPU into the worker VM
Each Proxmox node runs one control-plane VM and one worker VM (see Cluster — talos). Only the workers get the GPU. In tofu/environment/production/talos.tf, the VM list carries a gpu flag — false for talos-cp-0{1,2,3}, true for talos-worker-0{1,2,3} — which drives a dynamic hostpci block:
dynamic "hostpci" {
for_each = each.value.gpu == true ? [1] : []
content {
device = "hostpci0"
id = "0000:00:02.0" # the iGPU's PCI address on every NUC
pcie = true
xvga = false
}
}
0000:00:02.0 is the iGPU's PCI address on all three (identical) hosts. xvga = false means the GPU is passed as a plain PCIe device, not as the VM's primary display — Talos has no use for a console framebuffer.
This is full passthrough, not SR-IOV or virtual functions: the worker VM owns the device exclusively, and the Proxmox host gives it up. The Proxmox page notes the operational caveat — after replacing a worker, re-confirm the IOMMU groups, because Proxmox device numbering can shift on kernel upgrades.
Layer 3 — Talos loads the driver
A passed-through PCI device is useless without a driver, and Talos ships no i915 module by default. The worker schematic in talos/talos/talconfig.yaml adds it:
worker:
schematic:
customization:
systemExtensions:
officialExtensions:
- siderolabs/i915 # intel quicksync
- siderolabs/intel-ucode # intel quicksync
- siderolabs/iscsi-tools # longhorn
- siderolabs/qemu-guest-agent
- siderolabs/util-linux-tools
siderolabs/i915 bundles the kernel module plus GPU firmware; siderolabs/intel-ucode keeps the CPU microcode current, which matters for the P-core/E-core hybrid. The control-plane schematic carries neither — no GPU, no driver. With the extension in place, the worker exposes /dev/dri (card + render nodes) like any ordinary Linux host. How schematics flow into machine config is covered on the Talos page.
Layer 4 — the device plugin makes it schedulable
Kubernetes doesn't know what /dev/dri is. The Intel GPU plugin bridges that gap: a raw DaemonSet (no Helm) in kube-system, defined in k8s/platform/base/controllers/intel-gpu-plugin/ and deployed by its own Flux Kustomization with wait: true. The cluster-wide infrastructure Kustomization lists it in dependsOn, so apps only reconcile once the plugin is healthy — Jellyfin never races the resource it wants to request.
The pod mounts four host paths, each with a job:
| Mount | Why |
|---|---|
/dev/dri (ro) | discover the GPU device nodes |
/sys/class/drm (ro) | read GPU properties |
/var/lib/kubelet/device-plugins | the kubelet gRPC socket it registers against |
/var/run/cdi | write CDI specs describing the devices to inject |
The plugin runs with readOnlyRootFilesystem, all capabilities dropped, and no privilege escalation — but hostPath mounts and root still violate the cluster's Kyverno baseline, so three PolicyExceptions in k8s/platform/talos/configs/kyverno/exceptions/ (require-run-as-nonroot, host-path, volume-types) carve it out by name.
Once registered, each GPU worker advertises exactly one unit of gpu.intel.com/i915 (the manifest passes no -shared-dev-num argument, so the plugin's default of one container per GPU applies). The DaemonSet's nodeSelector is only kubernetes.io/arch: amd64 — on nodes without /dev/dri the plugin simply finds nothing and advertises nothing.
Layer 5 — scheduling and the consumers
gpu.intel.com/i915 is an extended resource: integer-only, no overcommit, and requests must equal limits. The scheduler treats it like any other capacity — a pod requesting one lands only on a node with a free slot, and the kubelet injects the device nodes into the container at start.
Cluster-wide budget: three slots (one per worker). Two are spoken for.
Jellyfin — on-demand transcoding
The talos overlay patch (k8s/apps/talos/jellyfin/jellyfin-patch.yaml) pins the GPU alongside memory:
resources:
requests:
cpu: "135m"
memory: "4162Mi"
gpu.intel.com/i915: 1
limits:
memory: "4162Mi"
gpu.intel.com/i915: 1
Two adjacent details make this work:
fsGroup: 104in the pod securityContext puts the Jellyfin process in the render group, so it can actually open the injected/dev/drirender node.strategy: Recreatein the base Deployment — a rolling update's surge pod would need a second GPU slot on the same node (plus the RWO config PVC), so the old pod must release both before the new one starts.
Inside the container, Jellyfin's Playback → Hardware acceleration is set to VA-API; most clients direct-play anyway, so the GPU only spins up for the lowest-common-denominator ones.
FileFlows — batch pipelines
FileFlows claims the second slot the same way (k8s/apps/talos/fileflows/patch-fileflows.yaml, gpu.intel.com/i915: 1 in requests and limits) and uses it for hardware-accelerated H.264/H.265 encoding in its processing flows — bulk re-encodes of the media library on the NFS stash mount, where throughput matters far more than for a single live stream.
Who deliberately doesn't use it
Immich's machine-learning workload (immich-machine-learning) runs CPU-only — no gpu.intel.com/i915 request anywhere in its manifests. Same for Tube Archivist. Smart-search embeddings and thumbnailing are batch jobs that tolerate CPU speed, and the third GPU slot stays free — headroom that also lets Jellyfin or FileFlows reschedule when a worker is drained.
Verifying the pipeline end-to-end
A throwaway checker pod lives at tofu/environment/production/gpu-checker-intel.yaml: an ffmpeg image that requests gpu.intel.com/i915: 1, installs vainfo and the intel-media-va-driver-non-free (iHD) driver, and dumps the VA-API profile list. If vainfo shows the encode entrypoints, every layer below it — passthrough, i915 extension, plugin, scheduling, device injection — is working. It is applied ad hoc, not managed by Flux.
What can break, and where to look
| Symptom | Most likely cause | Where to look first |
|---|---|---|
Node advertises no gpu.intel.com/i915 capacity | Plugin pod not running, or /dev/dri missing in the VM | kubectl -n kube-system get ds intel-gpu-plugin; then the layers below |
/dev/dri missing on a worker | Passthrough lost after host maintenance (IOMMU renumbering), or the i915 extension fell out of the schematic | foundation/proxmox · talos/talos/talconfig.yaml |
Jellyfin/FileFlows pod stuck Pending on Insufficient gpu.intel.com/i915 | All slots taken (one per worker), or a stale pod still holds the device | kubectl describe node allocations; remember shared-dev-num is 1 |
| Jellyfin pod wedged mid-upgrade | Surge pod waiting on the GPU slot / RWO PVC the old pod still holds | base Deployment uses Recreate — check for a stuck terminating pod |
| Transcode falls back to software (CPU pegged) | GPU scheduled but VA-API init failed inside the container | run the gpu-checker pod; check Jellyfin playback settings |
| Plugin pod blocked by admission | Kyverno exception drifted (plugin renamed / moved namespace) | k8s/platform/talos/configs/kyverno/exceptions/ · platform/kyverno |
| Plugin image bump breaks registration | Renovate digest bump with plugin API change | topics/gitops-flow for the PR trail; plugin release notes |
Why this is a topic, not a platform page
The platform page documents the device plugin itself — one component, one layer. But hardware acceleration only exists when five layers agree, and four of them live outside k8s/platform/: a tofu hostpci block, a Talos schematic line, Kyverno exceptions, and per-app resource patches. When transcoding breaks, the question is never "is the plugin broken?" — it's "which layer of this stack stopped agreeing?", and that diagnosis needs the whole picture on one page.