OpenFactory Plex media lab with Plex Media Server, Tdarr transcoding farm, nginx reverse proxy and observability

Build a Plex Media Stack on OpenFactory

A four-VM Plex lab: Plex Media Server + Tdarr + nginx + observability, from one prompt

March 19, 2026

← Back to Blog

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 — and, in the same move, 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.

What you'll build

  • 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.
  • A two-host split you can actually use. Plex gets 4 GB / 32 GB and Tdarr gets its own 4 GB / 24 GB node, so the heavy CPU/GPU transcode workers live away from the server that has to stay responsive for playback — the shape you want when Tdarr later moves onto a GPU box.

Why build it on OpenFactory

  • The ISO is the spec. Library and transcode paths, env files, and scrape targets bake into bootable images.
  • Tdarr split out. Run the transcoder on a GPU host while Plex stays on the mini-PC, without rebuilding the topology.
  • Reverse proxy ready. nginx fronts the web UI for layering real TLS on top later.
  • Scenario assertions ride along. The build fails closed if Plex's identity endpoint doesn't expose its machine ID, if Tdarr isn't talking, or if the proxy can't reach Plex.

Topology

Four Debian Trixie VMs on 10.74.0.0/24. nginx fronts Plex; Tdarr hits Plex for library refreshes after transcoding; observability scrapes everyone.

Plex four-VM media stack on the 10.74.0.0/24 lab subnetClientsPlex apps / webplex10.74.0.10:32400nginx-proxy10.74.0.30:80tdarr10.74.0.20:8265/8266observability10.74.0.40:9090/3000proxyrefreshscrape
Request flow on 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 — which is exactly why fronting Plex with your own proxy and a real public name is worth the effort.

The prompt

Paste this verbatim into the chat builder at console.openfactory.tech. Nothing above or below it — the builder expects the prompt body to start at the “Build a compact multi-node lab…” line.

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`. Add tiny Python listeners for `:9090/-/healthy` and `:3000/api/health`.

## 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.

Running it

  1. Open the chat builder at console.openfactory.tech and paste the prompt into a new conversation.
  2. Review the streamed build plan. You'll see the topology, per-node recipes, and the scenario assertions that will run after boot. Edit the prompt and re-run if anything is off.
  3. Click Build group. OpenFactory fans the plan out to per-node ISO builds. When every ISO reaches built, boot the group on the runner network from the same UI.
  4. Exercise the stack. The scenario assertions run automatically against the live VMs. From the host you can also hit the service ports directly to confirm end-to-end behavior.

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.

What's still your responsibility

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:

  • Real Plex Media Server. Install from the plexmediaserver apt repo and claim the server with a token from plex.tv.
  • HW transcoding drivers. VAAPI / NVENC / QSV plus a working /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.”
  • Tdarr node config. Worker count, priority, and GPU assignment all happen in the Tdarr UI after the real binary ships. Decide up front whether a node runs CPU or GPU workers — a CPU worker refuses any FFmpeg job that names nvenc, cuda, or vaapi, so a misconfigured node silently does nothing.
  • Library health checks. Tdarr can run quick (header-only) or thorough (frame-by-frame) health checks to flag corrupt files. Thorough checks are GPU- or CPU-bound and slow over a large library — schedule them, don't run them on every scan.
  • Remote access. Either trust plex.tv's NAT-traversal (open :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).
  • Backups. /var/lib/plex/Library holds watch history, library metadata, and user accounts. Worth snapshotting.

Where to go next

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.

Common questions

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? Because the ISO is 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, validated before it serves a single byte. No Compose drift, no undocumented host tweaks.

Ready to ship this in production?

OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.