
A four-VM Plex lab: Plex Media Server + Tdarr + nginx + observability, from one prompt
May 17, 2026
Plex is the older sibling of Jellyfin — commercial, polished, with the best client ecosystem of any media server. The 2026 r/selfhosted survey shows it slipping to Jellyfin, but it's still the fifth-most-deployed app and the right pick if you live in the Plex client apps and trust plex.tv's remote-access wiring.
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.
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.
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.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. Plex pass and HW transcoding are why this stack stays low-power./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. Want Jellyfin's open-source equivalent? See the Jellyfin media stack post. And the Enterprise & GxP page covers fleet rollouts.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.