--- name: jellyfin-proxmox-deployment description: This skill should be used when deploying (or redeploying) Jellyfin on a Proxmox host as an unprivileged LXC with Intel QuickSync hardware transcoding passthrough — including the VM-vs-LXC decision for a shared iGPU, the manual device-passthrough steps Terraform can't do declaratively, the media-volume backup-exclusion pattern, and the Tailscale-Serve-only exposure model. Trigger phrases include "deploy jellyfin", "reinstall jellyfin", "jellyfin quicksync passthrough", "jellyfin proxmox LXC", "renderD128 jellyfin", "jellyfin hardware transcoding setup", "redeploy jellyfin from scratch". --- # Jellyfin on Proxmox — LXC deployment with Intel QuickSync passthrough Full recipe for deploying Jellyfin as a native (non-Docker) service in an unprivileged Debian 13 LXC on a Proxmox host with a single shared Intel iGPU, exposed only over Tailscale Serve. First built 2026-07-16 in a homelab with one Proxmox host (Beelink N100, Alder Lake-N iGPU) and a Terraform+Ansible-managed fleet; the mechanics below generalize to any single-iGPU Proxmox host. Decommissioned 2026-07-18 at Will's request (a deliberate choice, not a resource-pressure one) — this skill exists so a future redeploy doesn't have to rediscover any of the following. **⚠️ The iGPU is currently claimed by something else.** The same day Jellyfin was decommissioned, its freed iGPU was passed through **exclusively** to the Immich VM via `vfio-pci` (full PCI passthrough — this hardware has no GVT-g/mdev support, so it can't be split). A future Jellyfin LXC redeploy **cannot** get `/dev/dri` back without first reverting that passthrough (see the homelab CLAUDE.md item 17 entry, "iGPU passthrough added 2026-07-18", for the exact revert steps) — check whether the GPU is still exclusively bound to a VM before assuming the simple LXC bind-mount path below will just work. ## The core architectural decision: LXC, not VM, despite the general rule This homelab's standing rule is "GPU passthrough need → VM" (hardware passthrough generally wants a VM for clean device isolation). **Deliberately override that rule for Jellyfin** when the host has only one iGPU and other guests might want it too: a VM would hand Jellyfin *exclusive* ownership of the only GPU on the box, permanently taking it away from Proxmox itself and everything else. An **unprivileged LXC with device passthrough** shares the iGPU instead (the host and the container both see the same `/dev/dri/renderD128`) — this is current Proxmox/Jellyfin community best practice for Intel QuickSync specifically, because Intel iGPUs in this class (no GVT-g/SR-IOV support, e.g. Alder Lake-N) can't be split between multiple VMs the way a real virtualization-capable GPU can. Confirm this trade-off with whoever owns the box before building — it's a real, named exception to a general rule, not an oversight. ## Terraform: what it can and can't do here Bring the LXC up via the `bpg/proxmox` provider (`proxmox_virtual_environment_container`) same as any other host in the fleet — but **device passthrough itself cannot be expressed in Terraform if using a scoped, non-root API token**. `device_passthrough` blocks (and any LXC `features` flag other than `nesting`) are hardcoded server-side to `root@pam` only; a scoped token gets a real 403. Don't try to work around this by widening the token's privileges for one resource. Instead: ```hcl resource "proxmox_virtual_environment_container" "jellyfin" { node_name = "pve" vm_id = 142 unprivileged = true started = true start_on_boot = true operating_system { template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst" type = "debian" } disk { datastore_id = "local-zfs" size = 20 } # Separate media volume -- see "Backup exclusion" section below. mount_point { volume = "local-zfs" size = "500G" path = "/mnt/media" backup = false } cpu { cores = 2 } memory { dedicated = 4096; swap = 512 } features { nesting = true } # nesting alone is fine for a scoped token network_interface { name = "eth0"; bridge = "vmbr0" } initialization { hostname = "jellyfin" ip_config { ipv4 { address = "dhcp" } } user_account { keys = [ /* ansible + personal pubkeys */ ] } } } ``` `terraform apply` this first (fresh create, no import needed), then do the passthrough as a **manual step over root SSH to the Proxmox host** (`pct stop 142`, edit `/etc/pve/lxc/142.conf`, `pct start 142`): ``` lxc.cgroup2.devices.allow: c 226:128 rwm lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file ``` (`226:128` is `renderD128`'s major:minor — confirm with `ls -la /dev/dri/renderD128` on the host if a different card/node shows up.) This is the same established pattern as TUN passthrough (`/dev/net/tun`, needed if this LXC also runs Tailscale) — always a full `pct stop`/`pct start` cycle, restarting the service from inside the container is not enough for either device. ## The permissions gotcha device passthrough always hits A device node bind-mounted from the host keeps the **host's** uid/gid, which does not map into an unprivileged LXC's shifted uid/gid namespace — `/dev/dri/renderD128` shows up owned by `nobody:nogroup` inside the container even though the major:minor is correct. Fix on the **host** (not inside the container): ```bash chmod 666 /dev/dri/renderD128 ``` ...and persist it, since Proxmox recreates `/dev/dri` fresh on every host reboot: ``` # /etc/udev/rules.d/70-jellyfin-quicksync.rules on the Proxmox host KERNEL=="renderD128", GROUP="render", MODE="0666" ``` Inside the container, add the `jellyfin` service user to both `video` and `render` groups (the Ansible playbook below does this) — belt-and-suspenders alongside the `0666` mode. ## Ansible: package install + VAAPI driver Native apt-repo install, not Docker — matches this fleet's general Docker-in-unprivileged-LXC avoidance (cgroup quirks). Two non-obvious package facts, both confirmed live rather than assumed: - **Jellyfin's official repo (`repo.jellyfin.org`) already serves Debian 13 (`trixie`) packages** directly — don't assume you need a `bookworm` compat fallback. - Use the **free** `intel-media-va-driver` package (Debian `main` component), not `intel-media-va-driver-non-free`. The non-free variant lives in Debian's `non-free` component (not enabled by default) and isn't needed anyway — the free iHD driver fully covers Gen8+ Intel iGPUs, including Alder Lake-N. ```yaml - name: Install and configure Jellyfin hosts: jellyfin become: false tasks: - name: Install prerequisite packages ansible.builtin.apt: name: [curl, gnupg, apt-transport-https] state: present update_cache: true - name: Ensure /etc/apt/keyrings exists ansible.builtin.file: path: /etc/apt/keyrings state: directory mode: '0755' - name: Fetch and dearmor Jellyfin GPG key ansible.builtin.shell: | curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key | gpg --dearmor -o /etc/apt/keyrings/jellyfin.gpg args: creates: /etc/apt/keyrings/jellyfin.gpg - name: Add Jellyfin apt repository (deb822) ansible.builtin.copy: content: | Types: deb URIs: https://repo.jellyfin.org/debian Suites: trixie Components: main Architectures: amd64 Signed-By: /etc/apt/keyrings/jellyfin.gpg dest: /etc/apt/sources.list.d/jellyfin.sources mode: '0644' register: jellyfin_repo - name: Install Jellyfin + Intel VAAPI userspace driver + rsync ansible.builtin.apt: name: [jellyfin, intel-media-va-driver, rsync, vainfo] state: present update_cache: "{{ jellyfin_repo.changed }}" - name: Add jellyfin service user to video and render groups ansible.builtin.user: name: jellyfin groups: video,render append: true notify: Restart jellyfin - name: Ensure jellyfin service is enabled and running ansible.builtin.service: name: jellyfin enabled: true state: started handlers: - name: Restart jellyfin ansible.builtin.service: name: jellyfin state: restarted ``` Verify hardware transcode capability for real, don't assume from general N100 knowledge: ```bash vainfo ``` Confirmed live output for an Alder Lake-N iGPU: H.264 and HEVC (8-bit + 10-bit) decode *and* encode (`EncSliceLP` entrypoints), VP9 decode+encode too. **No AV1 hardware encode** — not in the profile list at all; don't advertise AV1 hardware support without re-checking `vainfo` on the actual hardware in use. ## The "binds 0.0.0.0 by default" gotcha, Jellyfin's flavor This fleet has hit "binds all interfaces by default" three times now (Next.js's `HOSTNAME` env var doing nothing, Docker's short-form `ports:` syntax, and this one) — Jellyfin's mechanism is its own XML config, not an env var or CLI flag. `/etc/jellyfin/network.xml`'s `LocalNetworkAddresses` element controls bind address ("if empty, all interfaces will be used" — per Jellyfin's own bundled API docs): ```xml 127.0.0.1 ``` **This file does not exist until Jellyfin's first web-based config write** — it has to be hand-created *before* the first-run setup wizard runs, not edited after the fact, if the goal is never letting the wizard bind wide open even briefly. Verify the fix with a **positive and negative** check, not just "the Tailscale URL works": confirm reachable over `https://jellyfin..ts.net`, then confirm the plain LAN IP on Jellyfin's port is refused from another LAN host. ## Exposure: Tailscale Serve only, no Funnel ```bash tailscale serve --bg --https=443 http://127.0.0.1:8096 ``` Tailnet-only — no public ingress need for a personal media server. Verify both directions (see above). ## Storage: separate media volume, deliberately excluded from backups Two Proxmox storage volumes, not one: - `rootfs` (small, ~20GB) — OS, app, Jellyfin's own metadata DB (posters, `.trickplay` files, watch state). **This is what nightly PBS backups actually protect.** - A dedicated `mp0` mount at `/mnt/media` — the media library itself, sized generously (e.g. 500GB) but usually mostly empty. Set `backup = false` in Terraform's `mount_point` block. **Real gotcha found in production**: the `bpg/proxmox` provider's `mount_point.backup = false` attribute does not reliably write a `backup=0` flag into the actual `/etc/pve/lxc/.conf` `mpN` line — Terraform's state can say `backup: false` while the live container config has no flag at all, and Proxmox's *default* for a volume-backed mount point (as opposed to a bind-mount of a host path) is backed-up-by-default when the flag is absent. This would silently vzdump the entire media library nightly. **Always verify directly after apply**: ```bash grep mp0 /etc/pve/lxc/.conf # must show ,backup=0 ``` If missing, fix with a direct `pct set -mp0 ,backup=0` and confirm with a real manual `vzdump ` run — the log should say *"excluding volume mount point mp0 (...) from backup (disabled)"* and the resulting archive size should roughly match the rootfs alone, not the whole library. **The rationale for excluding media from backup is "presumed reproducible from its original source."** That's a real assumption, not a guarantee — if any content in the library genuinely has no other copy (home movies, personal recordings, event footage someone captured themselves), it needs an explicit, separate preservation plan before relying on this pattern. Don't let "media is excluded from backup" become "media has no backup at all" without that being a deliberate, confirmed decision — see the companion decommission note this skill's project attached this lesson to. ## Ingestion pattern (optional, if using a Mac-side drop folder) If media arrives via a sync pipeline from a source machine (rather than direct download to the LXC), that source machine's rsync client matters: macOS's built-in `rsync` is openrsync, which preserves local uid/gid/mode verbatim with no `--chown` flag — files can land unreadable by the `jellyfin` service account. See the `jellyfin-media-permissions` skill for the full diagnostic and fix (a post-transfer `chown`/`chmod` step on the receiving end, not a client-side rsync flag change). ## Redeploying from scratch (checklist) 1. `terraform apply` the LXC (Terraform block above) — fresh create, no import. 2. Manual device passthrough on the Proxmox host: TUN (if Tailscale needed) + `renderD128`, both via `.conf` edit + full `pct stop`/`start`. 3. Host-side `chmod 666` + persistent udev rule for `renderD128`. 4. Run the Ansible install playbook above. 5. Hand-create `/etc/jellyfin/network.xml` with `LocalNetworkAddresses` = `127.0.0.1` *before* visiting the web UI for the first time. 6. `tailscale serve --bg --https=443 http://127.0.0.1:8096`. 7. Verify `mp0`'s `backup=0` actually landed in the live `.conf`, not just Terraform state. 8. **Interactive, needs a human at the browser** — the first-run wizard: admin account, add a library pointed at `/mnt/media`, then Dashboard → Playback → Hardware Acceleration → Intel QuickSync (QSV), device `/dev/dri/renderD128`. HDR tone-mapping needs OpenCL (heavier) — skip unless a specific title needs it. 9. Add to Homepage, Pi-hole Local DNS Records, Prometheus scrape targets, and the nightly PBS backup schedule — standard closing steps for any new host in this fleet. 10. DHCP reservation on the router — always a manual step if there's no router API access.