
A five-VM media lab: Jellyfin + Sonarr + Radarr + nginx + observability, from one prompt
May 5, 2026
Jellyfin won the 2026 r/selfhosted poll for most-deployed self-hosted app. After Plex's monetization pivot, it's the default media server 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's fully open source.
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.
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.
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./var/lib/jellyfin watch-history, metadata, and user accounts need off-host snapshots.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.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.