Table of Contents
Runbook 16: Plex Media Server
Getting Plex onto Fedora bootc with GPU-accelerated transcoding. This is the odd one out in the fleet: the only workload I run as a native RPM instead of a container, because it needs the NVIDIA card and the tidiest path to hardware transcoding. That single decision drives most of the gotchas below. Notes-to-self that double as a blog draft; verify the exact snippets against the itg-bootc repo before publishing, the decisions and gotchas are the real value.
Key facts I keep needing: the host is itg-prd-plex, old pet was vmid 202 (staged and deleted after cutover). The card is an RTX 2070 (10de:1f07), passed through from Proxmox via a resource mapping, driver 610.43.03. Media comes over NFS read-only from itg (10.10.10.2:/slow-media/media) mounted at /var/mnt/media. Because Plex is native, its config and database live at /var/lib/plexmediaserver on a local data disk, not in a bind-mounted container volume.
Why native RPM, not a container
Everything else in the fleet is a Quadlet. Plex is not. Two reasons: hardware transcoding is simplest when the Plex process talks to the driver directly on the host, and the official RPM repo tracks releases cleanly. The cost of that choice is the thing to internalize, because it explains the migration pain later: with native Plex, the library database stores real filesystem paths. When a media mount point moves, Plex's database still points at the old path and you have to re-point the library and rescan. Contrast the container workloads (see the arr runbook), where the Quadlet maps host to container (Volume=/var/mnt/media:/media), so the app keeps seeing /media no matter where the host puts it and the database never notices. Native Plex has no such indirection. That is the whole difference.
The %pre systemd trap
The plexmediaserver RPM will not install in a podman build. Its %pre scriptlet reads /proc/1/comm and aborts with "Plex Media Server requires systemd" unless PID 1 is init. During an image build PID 1 is never systemd, so the scriptlet always fails. Creating /run/systemd/system does not help, because the check is on PID 1, not that directory.
The fix is to skip the scriptlets and do their necessary work myself:
RUN dnf install -y --setopt=tsflags=noscripts plexmediaserver
tsflags=noscripts installs the files but runs no %pre/%post. The only thing the scriptlet did that I actually need is create the plex service user, so I declare it the same way I declare worker and ansible, via sysusers in the Containerfile (uid 985). No reason to let a scriptlet own something I already manage declaratively everywhere else. This keeps the user creation reproducible and in git instead of hidden in an RPM's %pre.
NVIDIA driver: bake the akmod at build
The driver is baked into the image, not installed at runtime. akmod-nvidia is compiled against the image's kernel during the build, so the module is present the moment the box boots and survives every bootc upgrade (a new image rebuilds the module against whatever kernel it ships). Confirmed working: nvidia-smi reports the RTX 2070 and driver 610.43.03 on first boot, no runtime kmod dance.
GPU passthrough: the Proxmox resource-mapping ladder
This is where I lost the most time, and every step was a 403 that told me exactly what was missing if I read it. The root cause is that the provisioner uses a scoped, privilege-separated API token (ansible@pve!provisioner), not root, and passing a PCI device to a VM as a non-root user is locked down. The mechanics of the token itself live in Runbook 12; here is the Plex-specific climb, in the order the errors came:
- "only root can set hostpci for non-mapped devices." A non-root user cannot pass a raw PCI address. You must pass a resource mapping instead. Create the mapping in Datacenter, Resource Mappings, PCI Devices.
- "Permission check failed (Mapping.Use)." The token needs
Mapping.Useon the mapping. Granted it on the token. - Still failing. Because the token is privilege-separated, its effective rights are the intersection of the user's rights and the token's rights. So
Mapping.Usehas to be granted on bothansible@pvethe user and the!provisionertoken. Granting it only on the token is not enough. This is the one that is easy to miss. - "missing iommugroup." The mapping entry has to carry the IOMMU group, not just the path. Added
iommugroup=6. - "missing subsystem-id." It also wants the subsystem id. Added
subsystem-id=3842:2172.
So the complete mapping for this card carries all four: path=0000:42:00.0, id=10de:1f07, iommugroup=6, subsystem-id=3842:2172. Note the host path is 0000:42:00.0; inside the guest the card shows up at 01:00.0, which is normal and not a mismatch. Once all four fields were present and Mapping.Use was on both the user and the token, the VM started with the card attached.
Getting the values: lspci -nn for the vendor:device id, lspci -n -s <slot> and the IOMMU group under /sys/kernel/iommu_groups/, and the subsystem id from lspci -vnn on the device. pvesh get /cluster/mapping/pci shows what Proxmox has stored so you can diff intent against reality.
NFS media and the "not canonical" trap
Plex mounts the library read-only over NFS. The obvious place is /media, and that is exactly the trap. On bootc, /media is an ostree compatibility symlink to /run/media, and systemd refuses a .mount unit whose Where= contains a symlink: "Where= path is not canonical." Two rules fall out of this, and both bit me:
- The
Where=must be a real, symlink-free path. I used/var/mnt/media. - The unit filename must match the mount path, escaped. Mount at
/var/mnt/mediaand the file must bevar-mnt-media.mount. A file namedmedia.mountpointingWhere=/var/mnt/mediawill not enable and will not start.
The unit: What=10.10.10.2:/slow-media/media, Where=/var/mnt/media, Type=nfs, Options=ro,_netdev, After/Wants=network-online.target, WantedBy=remote-fs.target.
This trap masqueraded as a Plex problem for a while: plexmediaserver.service kept dying with a dependency failure, because a drop-in has it Requires= the media mount (and the config-disk mount) plus network-online.target, and the media mount was silently failing on the canonical/filename issue. Once the mount unit was named and pathed correctly, the dependency resolved and Plex came up. Lesson: when "Start Plex" fails on a dependency, systemctl status var-mnt-media.mount before you touch anything Plex.
Re-path the library and rescan (the native-Plex tax)
Because the old box had the library at /media and the new one serves it at /var/mnt/media, and because native Plex stores real paths in its database, the migrated library pointed at a path that no longer existed. The fix is not clever, it is correct: in the Plex UI, edit each library's folders from the old path to /var/mnt/media/..., then scan. I let it re-scan fully rather than fight the database. This is a one-time cost of the native design and I would make the same call again; doing it right beat hacking paths.
On ownership: I re-chowned the media tree on itg from the old 995 to 1001 for the container workloads, and it does not affect Plex. Plex runs as uid 985 and mounts read-only, so it was never the owner and has always read the tree through the "other" permission bits. chown moves owner and group, not the mode bits, so Plex's access is unchanged. The proof was that the scan ran fine after the chown.
The migration play
migrate-plex.yml, run by hand from the Mac, with no literals. Old-box coordinates (its ansible_host, and its vmid) live in a temporary host_vars/itg-prd-plex-old.yml, and the play reads the old vmid via hostvars['itg-prd-plex-old'].vmid rather than hardcoding 202 anywhere. It stages the old /var/lib/plexmediaserver (the whole config and database) to the depot, stands up the new bootc VM through site.yml, restores the config on top, and then the re-path and rescan above finish it. The old box had no rsync, so that was a dnf install -y rsync on the pet before staging (new bootc boxes get it from base). After cutover, delete vmid 202, remove the staging dir, and drop host_vars/itg-prd-plex-old.yml and the plex_old group.
Verify
- Driver present:
sudo nvidia-smishows the RTX 2070 at idle (low power, P8). - Media mounted:
systemctl status var-mnt-media.mountis active,plexmediaserver.serviceis running. - Hardware transcode, the real test: hardware transcoding needs Plex Pass and Settings, Transcoder, "Use hardware acceleration when available" checked. It only engages on a video transcode, so play a 4K or 1080p title in Plex Web and force the quality below source (for example 720p). Leaving quality on Original lets the client direct-stream the video and only the audio transcodes, which runs on CPU and never touches the card. When forced, the dashboard flips to "Transcode (hw)" and
nvidia-smishows aPlex Transcoderprocess with the encoder off zero. That combination is the end-to-end proof. - LAN awareness: set Settings, Network, "LAN Networks" to
10.10.10.0/24so Plex stops tagging local10.10.10.xclients as Remote and applying remote bandwidth limits to them. Cosmetic to transcoding, real for bandwidth.
Gotchas, in the order they bit
- The RPM refuses to build.
%prechecks PID 1 for systemd; use--setopt=tsflags=noscriptsand declare theplexuser via sysusers yourself. - Non-root PCI passthrough needs a mapping, and
Mapping.Useon both the user and the token. Privilege-separated tokens are the intersection of user and token rights. - The mapping needs all four fields: path, id, iommugroup, subsystem-id. Each missing one is its own 403.
/mediais a symlink on bootc. Mount at/var/mnt/media, and name the unit file to match the escaped path or it will not enable.- A failing mount looks like a failing Plex. Check the
.mountunit before blaming the service. - Native Plex stores real paths. Moving the mount means re-pathing the library and rescanning; there is no container indirection to save you.
- chown on the media tree does not affect Plex. It reads read-only as a non-owner through the "other" bits.
Runbooks
The build
- Image Mode & Base
- Storage
- Hostnames & DNS
- Registry
- Actions Runner
- The Pipeline
- Nightly Auto-Deploy
- First Workload
- VSCode Cockpit
- Hugo Auto-Deploy
- Cloudflare & Kobo
- Runner Provisioning
- Tailscale Router
- SWAG & Website
- Split-DNS
- Plex
- Media Library Support
- The Day After
- Self-Hosted Media
- Hypervisor Joins the Fleet
- The Matrix Homeserver
Reference