
A five-VM media lab: Jellyfin + Sonarr + Radarr + nginx + observability, from one prompt
February 24, 2026
Jellyfin has become the default self-hosted media server. In the r/selfhosted community survey of more than 2,100 homelabbers, Jellyfin took 51.2% of the media-server share — passing Plex (~40%) for the first time — with 84.2% of those deployments running on Linux. After Plex paywalled remote playback in April 2025, it became the obvious pick for anyone who wants to keep the library, the metadata, and the playback telemetry on their own hardware. It does what Plex does — serve movies, shows, and music to every device on the network — and it is fully open source (GPLv2), with no account, no tracking, and no subscription wall in front of your own files.
Jellyfin is also a moving target in the best way. The 10.10 release added HDR10, Dolby Vision, and Dolby AC-4 handling, rewrote Trickplay keyframe extraction (up to 100× faster scrub previews), and introduced Media Segments so the apps can offer real “skip intro” buttons. The trade-off versus Plex is the client story: Plex still ships polished first-party apps on every TV platform, while Jellyfin leans on excellent community clients — Swiftfin on Apple TV, Findroid on Android — with its own official Apple TV app still in public beta as of mid-2026. For most homelabs that gap closed long ago.
This post walks through building the full media-stack shape on OpenFactory: five buildable VMs — Jellyfin, Sonarr, Radarr, an nginx reverse proxy, and a Prometheus + Grafana observability node — all generated from a single prompt and shipped as bootable ISOs. The prompt is a preparation lab: real topology, real ports listening, deployment-time config templates dropped in the right places, and tiny Python stdlib compatibility services proving the wiring before you swap in real binaries. You get the shape of a production deployment — addresses, ports, vhosts, scrape targets — as a versioned artifact you can boot, test, and hand to the next node without a single manual install step.
jellyfin (10.70.0.10:8096) — the media-server VM with /var/lib/jellyfin/{media,cache,metadata} and an env file that real Jellyfin can adopt at deploy.sonarr (10.70.0.20:8989) — TV PVR pointing at the library.radarr (10.70.0.21:7878) — movie PVR alongside Sonarr.nginx-proxy (10.70.0.30:80) — one entry point fronting media.lab, tv.lab, and movies.lab.observability (10.70.0.40:9090/3000) — Prometheus targets all four services so scrape config is wired before deploy.Five Debian Trixie VMs on the 10.70.0.0/24 lab subnet. The nginx-proxy fronts the three user-facing services on a single port 80; the observability VM scrapes all of them on their native ports.
10.70.0.0/24: nginx fronts Jellyfin, Sonarr, and Radarr on port 80; the *arr nodes push library updates to Jellyfin; Prometheus scrapes every service.The split mirrors how you would actually run this in production. nginx is the only node a client touches — media.lab, tv.lab, and movies.lab all resolve to 10.70.0.30, and the proxy fans requests out to the right backend. The *arr nodes never face the user; they reach Jellyfin on :8096 to trigger a library refresh after a download lands. Observability sits off to the side scraping the /metrics endpoint each service exposes, so a missing stream or a wedged transcoder shows up on a dashboard instead of in a support ticket. Because every address, port, and vhost is fixed in the recipe, the diagram above is the deployment — there is no drift between what you drew and what boots.
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 `jellyfin-media-stack`.
Output discipline: keep the plan small. Use one startup script per node, about 25 shell lines or less. Do not install Jellyfin, Sonarr, Radarr, nginx upstream binaries, FFmpeg, or external `apt` repos. Do not pull large media packages or HW transcoding drivers. 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 5 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 `jellyfin-ops` in `sudo`. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`.
- `jellyfin`: role `media-server`, 4 GB RAM, 24 GB disk, alias `10.70.0.10/24`, x `230`, y `60`
- `sonarr`: role `tv-pvr`, 2 GB RAM, 16 GB disk, alias `10.70.0.20/24`, x `110`, y `220`
- `radarr`: role `movie-pvr`, 2 GB RAM, 16 GB disk, alias `10.70.0.21/24`, x `350`, y `220`
- `nginx-proxy`: role `reverse-proxy`, 1 GB RAM, 8 GB disk, alias `10.70.0.30/24`, x `230`, y `380`
- `observability`: role `observability`, 2 GB RAM, 16 GB disk, alias `10.70.0.40/24`, x `470`, y `380`
Connections: `nginx-proxy` to `jellyfin:8096`, `sonarr:8989`, `radarr:7878`; `sonarr` and `radarr` to `jellyfin:8096` as library-update intent; `observability` to every node.
## 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
`jellyfin`: features `headless`, `ssh`. Write `/etc/jellyfin/jellyfin.conf` with `HTTP_PORT=8096`, `HTTPS_PORT=8920`, `MEDIA_PATH=/var/lib/jellyfin/media`, and `CACHE_PATH=/var/lib/jellyfin/cache`. Create `/var/lib/jellyfin/{media,cache,metadata}` with mode `0750 ops:ops`. Add a compact Python stdlib HTTP service on `0.0.0.0:8096` exposing:
- `GET /health` -> `200 {"status":"ok","node":"jellyfin"}`
- `GET /System/Info/Public` -> `200 {"Id":"jellyfin-compat","ServerName":"jellyfin","Version":"compat-1.0","ProductName":"Jellyfin"}`
- `GET /metrics` -> Prometheus text with `jellyfin_compat_up 1`
Register `jellyfin-compat.service`. Write `/root/jellyfin-runbook.md` noting that real Jellyfin needs the `jellyfin-server` apt repo + HW transcoding drivers (VAAPI / NVENC / QSV) baked in at deployment time.
`sonarr`: features `headless`, `ssh`. Write `/etc/sonarr/sonarr.env` with `SONARR_PORT=8989`, `SONARR_DATA=/var/lib/sonarr`, `JELLYFIN_URL=http://10.70.0.10:8096`. Add a Python stdlib service on `0.0.0.0:8989` exposing `GET /ping`, `GET /api/v3/system/status` returning `{"appName":"Sonarr","version":"compat-1.0"}`, and `GET /metrics` with `sonarr_compat_up 1`. Register `sonarr-compat.service`.
`radarr`: features `headless`, `ssh`. Same shape as `sonarr` but on port `7878`, env file `/etc/radarr/radarr.env`, data `/var/lib/radarr`. `GET /api/v3/system/status` returns `{"appName":"Radarr","version":"compat-1.0"}`. Register `radarr-compat.service`.
`nginx-proxy`: features `headless`, `ssh`, `nginx`; packages also include `nginx`. Replace Debian's default nginx site with a single `/etc/nginx/sites-enabled/jellyfin-stack.conf` containing three `server` blocks listening on `80`: `media.lab` proxying `/` to `http://10.70.0.10:8096`, `tv.lab` proxying to `http://10.70.0.20:8989`, `movies.lab` proxying to `http://10.70.0.21:7878`. Each location sets `proxy_http_version 1.1`, `proxy_set_header Host $host`, `proxy_set_header X-Real-IP $remote_addr`, `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`. Run `nginx -t` and restart nginx.
`observability`: features `headless`, `ssh`, `prometheus`, `monitoring`; packages also include `python3`. Write `/etc/prometheus/prometheus.yml` scraping `10.70.0.10:8096`, `10.70.0.20:8989`, `10.70.0.21:7878`, and `10.70.0.30:80`. Add tiny Python placeholder listeners for `:9090/-/healthy` and `:3000/api/health`.
## Scenario
Emit exactly one group scenario named `jellyfin-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 `jellyfin:8096`, `sonarr:8989`, `radarr:7878`, `nginx-proxy:80`, `observability:9090`, `observability:3000`.
- `Jellyfin public info`: on `jellyfin`, `curl -fsS http://localhost:8096/System/Info/Public | jq -e '.ServerName == "jellyfin"' >/dev/null && echo jellyfin-info`.
- `Sonarr+Radarr status`: on `sonarr`, `curl -fsS http://localhost:8989/api/v3/system/status | jq -e '.appName == "Sonarr"' >/dev/null && echo sonarr-ok`; on `radarr`, the same check with `appName == "Radarr"`.
- `Proxy reaches every upstream`: on `nginx-proxy`, `for h in 10.70.0.10:8096 10.70.0.20:8989 10.70.0.21:7878; do nc -z -w 5 ${h%:*} ${h#*:}; done && echo upstreams-ok`.
- `Observability`: on `observability`, grep all four scrape targets from `/etc/prometheus/prometheus.yml` and `http_responds` for `http://localhost:9090/-/healthy` and `http://localhost:3000/api/health`, both status 200.
Preserve warnings that real Jellyfin server installation, hardware-accelerated transcoding (VAAPI/NVENC/QSV) driver selection, media library scanning, *arr import quality profiles, indexer configuration, SSL/TLS termination with real certificates, RBAC, backup of `/var/lib/jellyfin`, and `10.70.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:
jellyfin-server package from the upstream apt repo. The env file and library paths are already in place./dev/dri/renderD128 passthrough. Worth getting right — one N100 with QuickSync can handle several 4K->1080p streams at single-digit watts. Jellyfin's Intel guide now recommends QSV over VA-API on Skylake-and-newer iGPUs, and notes that Intel iGPUs and Arc dGPUs carry no concurrent-session cap (a real advantage over consumer NVENC, which is limited to 3–5 sessions on GTX cards). One caveat: Ice Lake / Jasper Lake and older chips are losing QSV on Linux as Intel retires MediaSDK — on that hardware, plan to fall back to VA-API./var/lib/jellyfin watch-history, metadata, and user accounts need off-host snapshots.This lab is the front door; the rest of a real media setup is a few prompts away. The *arr media automation post goes deeper on Sonarr + Radarr + Prowlarr + Bazarr as a parallel stack, the Audiobookshelf library post covers audiobooks and podcasts the same way, and the Plex media stack post builds the commercial counterpart if you need its client apps.
If media is step one, signed runtime evidence is step two. The runtime attestation post covers the dual-source kernel + systemd verification you can layer on any recipe, including this one. Want to wrap the stack in real TLS? See the nginx + TLS reverse-proxy build prompt. And when you're ready to ship to fleet, the Enterprise & GxP page is the way in.
Should I pick Jellyfin or Plex in 2026? For a brand-new self-hosted library, Jellyfin is the safe default: free, open source, and now the most-deployed media server in the homelab community. Stay on (or move to) Plex only when a specific client justifies it — its Apple TV app and Plexamp remain best in class — and weigh that against a Plex Pass that now runs $69.99/year or $249.99 for a lifetime key.
What hardware does the real Jellyfin node need? For 1080p direct-play and the occasional transcode, an Intel N100 mini-PC is plenty and sips ~10 W. For multiple simultaneous 4K->1080p transcodes, you want a 12th-gen Core iGPU or an Intel Arc card — both expose QuickSync with no session cap. Give the VM 4–8 GB RAM, point /var/lib/jellyfin at fast storage, and pass /dev/dri through to the guest.
Can I run this stack in Docker instead? You can — that is the usual route. The reason to build ISOs is that the topology, the nginx vhosts, and the Prometheus targets become a single versioned artifact you can boot identically on every node, with the scenario assertions proving the wiring before anything serves traffic. No Compose drift, no “works on my box.”
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.