The media server is the one service in the lab with users: people who didn’t sign up for a hobby and just want the thing to play when they press play. That changes the engineering calculus completely, which is why this app gets a full page.
Plex vs Jellyfin: the honest version
I wanted to want Jellyfin. It’s open source, it doesn’t phone home, nobody can paywall a feature I depend on, and the server itself is genuinely good. I run open source everywhere else it’ll go.
I still run Plex, and the reason is the part people undersell: the client fleet. My users watch on a mix of smart TVs, phones, a games console, and one ancient streaming stick. The Plex client on every one of those platforms is mature, resumes properly, handles subtitle quirks, and looks like a product. Jellyfin’s clients have improved a lot, but across that specific zoo the experience is still uneven, and I don’t operate the TVs. When playback stutters at someone else’s house, the support request comes to me. Plex costs me money; Jellyfin would cost me support calls. I picked the one I can afford.
The rest of the ledger, for honesty’s sake. In Plex’s favor: brokered remote access means people outside the network just work without me exposing anything (more below), watch-state and profiles are effortless, and Plexamp is the best music client I’ve used, period. Against it: the features that matter need Plex Pass (hardware transcoding chief among them; lifetime is the only subscription math that works), the company keeps shuffling features between tiers (the 2025 remote-streaming changes were a warning shot), and auth rides their cloud, so a plex.tv outage degrades login to your own local server. That last one genuinely bothers me.
So: why not Jellyfin YET. I re-evaluate roughly yearly. The media is named to open standards (below), so nothing locks me in, and the day the client gap closes on my actual devices, migration is an afternoon. That’s the posture I’d recommend: choose for your users, keep the exit cheap.
Step 1: the compose file
Plex gets its own box: professor, a repurposed desktop whose retired
gaming GPU now earns its keep transcoding. The library lives on
nibbler, the storage box, exported over NFS. One file,
compose/professor/services/plex.yml, per post 3’s conventions:
services:
plex:
image: lscr.io/linuxserver/plex:latest
container_name: plex
restart: unless-stopped
network_mode: host # earns it, see below
runtime: nvidia # the repurposed GPU, see step 2
environment:
- PUID=1000
- PGID=1000
- TZ=America/Toronto
- VERSION=docker
- PLEX_CLAIM=${PLEX_CLAIM} # first boot only, via sops
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,video,utility
volumes:
- ${USERDIR}/appdata/plex:/config
- /mnt/nibbler/data/media:/data/media # nibbler's NFS export
- /dev/shm:/transcode # transcode in RAM
Three choices worth defending:
network_mode: host. I run almost nothing with host networking;
the proxy-network pattern is the whole point of posts 6 and 7. Plex
is the exception that earns it: GDM discovery (how TVs find the
server), DLNA, and a pile of client behaviors work properly when Plex
sees the real LAN. You can run it bridged and forward 32400 plus a
handful of UDP ports. I did, for a while. Every odd “server not
found” complaint went away when I stopped fighting it.
RAM transcode. Pointing the transcoder at /dev/shm keeps
constant scratch writes off the disks; transcode chunks live exactly
as long as a play session, which is what RAM is for. Doubly true here,
where the alternative would be scratch writes over NFS.
The claim token ties a fresh server to your account. It expires in minutes, so grab it from plex.tv/claim right before first boot. It goes through sops like every secret, mostly so the compose file stays paste-able.
Step 2: make hardware transcoding actually engage
A retired mid-range Nvidia card chews through several 4K-to-1080p NVENC streams without noticing, IF the pieces line up. The checklist, in the order I got each one wrong:
- Plex Pass. Hardware transcoding is a paid feature. No pass, no NVENC, no matter how correct the rest is.
- The host can see the card. Driver installed,
nvidia-smiprints the GPU. No smi, no transcode, stop here and fix it. - Docker can hand it to containers. Install the NVIDIA Container
Toolkit and check with a throwaway container:
docker run --rm --runtime=nvidia --gpus all ubuntu nvidia-smi. - The compose wiring.
runtime: nvidiaplus theNVIDIA_VISIBLE_DEVICES/NVIDIA_DRIVER_CAPABILITIESenv vars from step 1. Thevideocapability is the one transcoding needs; it’s also the one generic toolkit examples leave out. - The checkbox. Settings, Transcoder, “Use hardware acceleration when available.” Yes, I have shipped a “broken” transcode setup that was this checkbox.
Check it worked: force a transcode (set a client to 720p), then look
at the dashboard. The stream should say (hw) next to Transcode.
No (hw), no hardware. The other tell is nvidia-smi on professor
showing a plex process under the enc/dec columns while the CPU stays
flat.
One footnote that used to matter: consumer GeForce cards historically shipped with a driver-imposed cap on concurrent NVENC sessions, and a well-known community patch lifted it. Recent drivers raised the cap high enough that a homelab won’t hit it. If an older setup mysteriously refuses a third or fourth simultaneous transcode, that cap is what you’re remembering.
Step 3: the /data convention (the most important section here)
If I could teach one thing about media servers it would be this, and it isn’t even a Plex setting. Every container in the pipeline mounts the same storage root at the same internal path:
nibbler:/data/ # ONE filesystem, exported over NFS
+-- torrents/ # download clients (bender) write here
+-- usenet/
+-- media/ # the library, Plex (professor) reads here
+-- movies/
+-- tv/
# on bender, the SAME mount in qbittorrent, sabnzbd, sonarr, radarr:
volumes:
- /mnt/nibbler/data:/data
# on professor, plex needs only the library half:
- /mnt/nibbler/data/media:/data/media
Site B splits the roles across boxes (bender downloads, nibbler stores, professor serves), and the convention is what makes that split free: everything mounts nibbler’s ONE exported filesystem, so an import is still a rename on the same fs, even though the *arr asking for it and the disks doing it live in different machines.
Why it matters: when Sonarr imports a finished download, it moves
/data/torrents/... to /data/media/tv/.... If both paths live on
one filesystem inside one consistent mount, that move is a hardlink:
instant, free, and the original keeps seeding with no duplicate copy.
If instead each container mounts its own slice at its own path
(/downloads here, /tv there, the way every quick-start does it),
the same import becomes a full copy across mount boundaries: slow,
doubles disk usage during a backlog, breaks seeding.
I ran the fragmented version for years and just accepted slow imports as the weather. The fix was one afternoon of remapping volumes. The TRaSH guides hammer this point, and they are right to.
The companion rule: path consistency beats path cleverness. Same
/data prefix in every container, no exceptions. Sonarr tells
qBittorrent “the file is at /data/torrents/x,” and that sentence must
be true in both containers’ worlds.
Step 4: naming the scanner loves
Plex’s scanner is good the way a tired librarian is good: give it ambiguity and someone else’s poster ends up on your home videos. The structure that never misfires:
media/movies/The Thing (1982) {imdb-tt0084787}/
The Thing (1982) {imdb-tt0084787} [Bluray-1080p].mkv
media/tv/Futurama (1999) {tvdb-73871}/
Season 01/
Futurama (1999) - S01E01 - Space Pilot 3000 [WEBDL-1080p].mkv
The load-bearing parts: the year in the folder (disambiguates the
forty movies named Crash), a provider ID in braces ({imdb-...},
{tvdb-...}) which removes matching guesswork entirely, and SxxEyy
markers. The quality tag in brackets is for humans; Plex ignores it.
And the rule that keeps it correct: you never name anything by hand. The *arrs own renaming. Configure the template once (post 16 has my exact templates) and every import lands right forever. The moment a human “fixes” filenames in the library, you have two naming authorities, and they will fight.
Step 5: library settings worth changing
Defaults are mostly fine. The ones I change:
- Scan my library automatically: on, but know its limit: it rides inotify, and inotify events do not cross an NFS mount. Plex on professor will never hear about a file bender imported onto nibbler. The fix is an autoscan sidecar: the *arrs call its webhook on import and it pokes Plex about exactly the changed directory. With split storage this isn’t an optimization, it’s the only thing that works.
- Periodic scan: off once event-driven pickup works. A full scan of a big library is an I/O storm that buys nothing the events didn’t.
- Intro/credit detection, thumbnails: per-library, not global. Real CPU cost and gigabytes of preview data on first run. TV skip-intro is worth it; turning it on everywhere because the checkbox existed is not.
- Agents: barely matter once IDs are in the folder names, which is the point. Metadata follows the ID.
Step 6: remote access without exposing anything
Plex’s brokered remote access (the server registers with plex.tv, clients connect via NAT traversal) is the rare phone-home feature I keep: zero exposed ports for full remote streaming, which fits “one machine faces the internet” better than self-hosting the ingress would. url routes nothing for Plex.
One subtlety for your own devices on the overlay: Settings, Network,
Custom server access URLs, add http://10.99.0.22:32400
(professor’s overlay IP). Devices on the overlay then connect direct
instead of falling back to Plex’s relay at degraded quality from
hotel wifi, even though you have a perfectly good tunnel home.
The integration map
request portal (post 10) -> *arrs (post 16) -> clients in the VPN
^ | namespace (post 10)
people ask | rename + hardlink into /data/media
v
autoscan -> Plex (this post) -> TVs, phones
Every arrow is a webhook or a filesystem event. Nothing polls, and after the one-time setup, nothing needs me. The best media server is the one whose admin forgets it’s running.
Next: the other half of the pipeline. Naming templates, quality profiles that stop the churn, and recyclarr keeping it all in git.