
A four-VM Plex lab: Plex Media Server + Tdarr + nginx + observability, from one prompt
March 19, 2026
Plex is the older sibling of Jellyfin: commercial, polished, with the best client ecosystem of any media server. It ships first-party, well-maintained native apps on essentially every TV, console, and streaming stick shipped in the last decade, and its Apple TV app plus the Plexamp music client are still the reason a lot of people stay. The 2026 r/selfhosted survey shows it slipping behind Jellyfin (which now holds ~51% of homelab media-server share to Plex's ~40%), but it remains the right pick if you live in the Plex client apps and want plex.tv's remote-access wiring to just work.
The asterisk is cost. In April 2025 Plex raised Plex Pass for the first time in over a decade, to $6.99/month, $69.99/year, or $249.99 lifetime. In the same move, it paywalled remote playback of your own media. Streaming from your server to a device outside your LAN now requires the server owner to hold a Plex Pass (which then covers everyone with access), or each remote viewer to buy a Remote Watch Pass. And the lifetime key climbs again to $749.99 on July 1, 2026. None of that breaks self-hosting, but it is the context every Plex build now starts from, and the reason a reproducible, versioned deployment is worth more than ever.
This post walks through the canonical Plex shape on OpenFactory: four buildable VMs (Plex Media Server, a Tdarr transcoding node, an nginx reverse proxy, and Prometheus observability) from one prompt, shipped as bootable ISOs. Like the Jellyfin lab, this is a preparation stack: real addresses, real ports, deployment-time config templates, and tiny Python stdlib stubs that prove the wiring before you drop in the real plexmediaserver and Tdarr binaries.
plex (10.74.0.10:32400): the media server with/var/lib/plex/{media,Library,transcode} pre-wired.tdarr (10.74.0.20:8265/8266): transcoder farm node pointed at Plex for library refreshes.nginx-proxy (10.74.0.30:80): reverse proxy for the Plex web UI.observability (10.74.0.40:9090/3000): Prometheus scraping Plex, Tdarr, and the proxy.Four Debian Trixie VMs on 10.74.0.0/24. nginx fronts Plex; Tdarr hits Plex for library refreshes after transcoding; observability scrapes everyone.
10.74.0.0/24: nginx fronts Plex on :32400; Tdarr calls Plex to refresh the library after a transcode; Prometheus scrapes all three.The point of the split is the same as in production: Plex stays small and responsive while the expensive work lives elsewhere. Plex Media Server listens on its native :32400; nginx is the single front door so you can later attach TLS and a public DNS name in one place instead of exposing Plex directly. Tdarr runs on its own node, walks the library, transcodes bulky files to space-efficient HEVC or AV1, then pings Plex on :32400 to refresh. Observability scrapes the /metrics endpoint each node exposes so you can watch transcode throughput and catch a stalled worker. One thing the diagram does not show, because it lives outside the LAN: Plex's remote-access path. If a direct connection on :32400 can't be established, Plex falls back to its Relay service, which caps every stream at 2 Mbps. That's exactly why fronting Plex with your own proxy and a real public name is worth the effort.
Choose Deploy to open this exact, registered prompt in Elster Terminal and start the build. Choose Copy prompt when you want to edit it first in build.openfactory.tech.
Build a compact multi-node lab named `plex-media-stack`.
Output discipline: keep the plan small. Use one startup script per node, about 25 shell lines or less. Do not install Plex Media Server, Tdarr, FFmpeg, NVENC/QSV drivers, or the `plexmediaserver` apt repo at build time. Write deployment-time config examples and tiny Python stdlib or shell compatibility stubs only. The goal is a buildable preparation lab, not a production deployment.
## Topology
Create 4 buildable `debian-trixie` nodes, all `x86_64`, SSH enabled, DHCP/default route intact with lab aliases, firewall disabled, DNS `1.1.1.1` and `8.8.8.8`, user `ops` password `plex-ops` in `sudo`. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`.
- `plex`: role `media-server`, 4 GB RAM, 32 GB disk, alias `10.74.0.10/24`, x `230`, y `60`
- `tdarr`: role `transcoder-farm`, 4 GB RAM, 24 GB disk, alias `10.74.0.20/24`, x `110`, y `220`
- `nginx-proxy`: role `reverse-proxy`, 1 GB RAM, 8 GB disk, alias `10.74.0.30/24`, x `350`, y `220`
- `observability`: role `observability`, 2 GB RAM, 16 GB disk, alias `10.74.0.40/24`, x `230`, y `380`
Connections: `nginx-proxy` to `plex:32400`; `tdarr` to `plex:32400` as library-refresh intent; `observability` to all nodes.
## Common Recipe Requirements
All nodes: features `headless`, `ssh`; packages `openssh-server`, `python3`, `curl`, `jq`, `iproute2`, `netcat-openbsd`, `ca-certificates`. Each startup script adds the alias with `IFACE=$(ip route show default | awk '{print $5; exit}')`, `ip link set "$IFACE" up || true`, and `ip addr add <alias> dev "$IFACE" || true`. If `os.startup_scripts[].after` is present, it must be the string `"network-online.target"`, not an array.
## Node Requirements
`plex`: features `headless`, `ssh`. Write `/etc/plex/plex.env` with `PLEX_PORT=32400`, `PLEX_MEDIA=/var/lib/plex/media`, `PLEX_PREFS=/var/lib/plex/Library`. Create `/var/lib/plex/{media,Library,transcode}` mode `0750 ops:ops`. Add a Python stdlib HTTP service on `0.0.0.0:32400` exposing:
- `GET /identity` -> `200 <?xml version="1.0"?><MediaContainer machineIdentifier="plex-compat-lab" version="compat-1.0"/>` with `Content-Type: text/xml`
- `GET /:/prefs` -> `200 {"machineIdentifier":"plex-compat-lab","friendlyName":"lab-plex"}`
- `GET /metrics` -> `plex_compat_up 1`
Register `plex-compat.service`. Write `/root/plex-runbook.md` noting that real PMS needs the `plexmediaserver` apt repo, a claim token from `plex.tv`, and HW transcoding drivers.
`tdarr`: features `headless`, `ssh`. Write `/etc/tdarr/tdarr.env` with `SERVER_PORT=8265`, `WEB_PORT=8266`, `NODE_NAME=tdarr-node-1`, `PLEX_HOST=http://10.74.0.10:32400`. Add a Python stdlib service exposing two ports: `0.0.0.0:8265` for `GET /api/v2/status` -> `200 {"status":"ok","node":"tdarr-node-1"}`, and `0.0.0.0:8266` for `GET /` -> `200 {"ui":"compat"}` and `GET /metrics` with `tdarr_compat_up 1`. Register `tdarr-compat.service`.
`nginx-proxy`: features `headless`, `ssh`, `nginx`; packages `nginx`. Write `/etc/nginx/sites-enabled/plex.conf` proxying `:80` to `http://10.74.0.10:32400` with appropriate `proxy_set_header` directives. Restart nginx.
`observability`: features `headless`, `ssh`, `prometheus`, `monitoring`; packages `python3`. Write `/etc/prometheus/prometheus.yml` scraping `10.74.0.10:32400`, `10.74.0.20:8266`, `10.74.0.30:80`, then enable and restart the installed Prometheus service so its native `:9090/-/healthy` endpoint is available. Do not bind a compatibility listener on port `9090`. Add only a tiny Python listener for `:3000/api/health` and register it as `grafana-compat.service`.
## Scenario
Emit exactly one group scenario named `plex-media-stack-validation`. Put `custom_tests[].assertions[]` inside the scenario entry; leave `scenarios[].tests` empty. Every assertion needs `on_vm`. Use only `port_listening`, `command_output`, and `http_responds`; do not emit `vm_boots`, `network_reachable`, or `service_running`.
- `Stack ports listen`: `port_listening` for `plex:32400`, `tdarr:8265`, `tdarr:8266`, `nginx-proxy:80`, `observability:9090`, `observability:3000`.
- `Plex identity`: on `plex`, `curl -fsS http://localhost:32400/identity | grep -q 'machineIdentifier="plex-compat-lab"' && echo plex-id`.
- `Tdarr status`: on `tdarr`, `curl -fsS http://localhost:8265/api/v2/status | jq -e '.status == "ok"' >/dev/null && echo tdarr-ok`.
- `Proxy reaches plex`: on `nginx-proxy`, `nc -z -w 5 10.74.0.10 32400 && echo upstream-reachable`.
- `Observability targets`: on `observability`, grep all three scrape targets in `/etc/prometheus/prometheus.yml` and echo `targets`; add `http_responds` for `http://localhost:9090/-/healthy` and `http://localhost:3000/api/health`.
Preserve warnings that real Plex Media Server, the Plex claim-token flow with plex.tv, HW-accelerated transcoding driver selection, Tdarr GPU/CPU node configuration, library scan scheduling, remote-access port mapping, real TLS on the proxy, and `10.74.0.0/24` aliasing are deployment-time concerns.Driving OpenFactory from an AI agent instead of the browser? The same flow is exposed through the OpenFactory MCP server: submit the prompt programmatically, get the build-plan preview back, and call create_build / start_vm on the resulting recipes. Single-image builds go straight through the openfactory CLI.
The prompt produces a buildable preparation lab: the right topology, the right ports listening, deployment-time config templates dropped in the right places, and tiny compatibility services that prove the wiring works. A few things still sit outside the recipe and need operator attention before this carries real load:
plexmediaserver apt repo and claim the server with a token from plex.tv./dev/dri/renderD128. Hardware transcoding is a Plex Pass feature, and it is what keeps this stack low-power: an Intel N100's QuickSync iGPU handles several 4K->1080p streams while the whole box draws roughly 8–12 W. Pass /dev/dri into the Plex VM and enable “Use hardware acceleration when available.”nvenc, cuda, or vaapi, so a misconfigured node silently does nothing.:32400 outbound, ideally forward it for a direct connection) or front the proxy with real TLS and a public DNS name. Skip this and you land on the 2 Mbps Relay fallback, which transcodes every remote stream down to fit the cap. Remember the April-2025 change: remote playback now needs a Plex Pass on the server (or a Remote Watch Pass per viewer)./var/lib/plex/Library holds watch history, library metadata, and user accounts. Worth snapshotting.If Plex is your front-end, you probably want the *arr suite feeding it. The *arr media automation post wires Sonarr + Radarr + Prowlarr + Bazarr into a parallel four-VM stack, and the Audiobookshelf library post does the same for audiobooks and podcasts. Want Jellyfin's open-source equivalent? See the Jellyfin media stack post. And the Enterprise & GxP page covers fleet rollouts; pricing has the per-seat details.
Is Plex still worth it after the 2025 pricing changes? If you genuinely use the polished native clients (the Apple TV app, the console apps, Plexamp) or you share with family who would never tolerate a rougher UI, yes. A lifetime Plex Pass at $249.99 (before the July 1, 2026 jump to $749.99) is a one-time cost against years of use. If you mostly watch on a phone, browser, or Android TV and resent paying to reach your own files, the Jellyfin lab gives you most of the experience for free.
Do I really need Tdarr? Only if you care about storage. Plex transcodes on demand for playback; Tdarr does the opposite: it re-encodes your libraryat rest into HEVC or AV1 so files are smaller and fewer playbacks need transcoding at all. On a large 4K library that can reclaim a meaningful fraction of your disk. Skip it if your library is small or already space-efficient.
Why build ISOs instead of running Docker Compose? The ISO already carries the spec. The 10.74.0.0/24 addressing, the nginx vhost, the Prometheus scrape targets, and the scenario assertions all ship inside a versioned, bootable image, identical on every node and validated before it serves a single byte. No Compose drift, no undocumented host tweaks.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.