1 16 Plex Media Server
Eric the IT Guy edited this page 2026-08-12 12:49:28 -05:00

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:

  1. "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.
  2. "Permission check failed (Mapping.Use)." The token needs Mapping.Use on the mapping. Granted it on the token.
  3. 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.Use has to be granted on both ansible@pve the user and the !provisioner token. Granting it only on the token is not enough. This is the one that is easy to miss.
  4. "missing iommugroup." The mapping entry has to carry the IOMMU group, not just the path. Added iommugroup=6.
  5. "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/media and the file must be var-mnt-media.mount. A file named media.mount pointing Where=/var/mnt/media will 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-smi shows the RTX 2070 at idle (low power, P8).
  • Media mounted: systemctl status var-mnt-media.mount is active, plexmediaserver.service is 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-smi shows a Plex Transcoder process 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/24 so Plex stops tagging local 10.10.10.x clients 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. %pre checks PID 1 for systemd; use --setopt=tsflags=noscripts and declare the plex user via sysusers yourself.
  • Non-root PCI passthrough needs a mapping, and Mapping.Use on 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.
  • /media is 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 .mount unit 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.