OpenFactory Jellyfin media lab with Sonarr, Radarr, an nginx reverse proxy and a Prometheus observability VM

Build a Jellyfin Media Stack on OpenFactory

A five-VM media lab: Jellyfin + Sonarr + Radarr + nginx + observability, from one prompt

May 5, 2026

← Back to Blog

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.

What you'll build

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

Why build it on OpenFactory

  • The ISO is the spec. Cache paths, env files, the nginx vhosts, and the Prometheus scrape targets are baked into bootable images. Boot the same ISO on every new node.
  • Scenario assertions ride along. The build group fails closed if the wrong port doesn't listen, if the proxy can't reach an upstream, or if a service stops responding. You don't deploy and hope.
  • Five VMs from one prompt. No copy-pasting Docker Compose files between four directories.
  • Reproducible across machines. Same ISO, same lab, whether you're running on a single Intel N100 or a Proxmox cluster.

Topology

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.

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

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 Jellyfin server. Replace the Python stdlib compatibility service with the actual jellyfin-server package from the upstream apt repo. The env file and library paths are already in place.
  • Hardware-accelerated transcoding. VAAPI / NVENC / QSV drivers and a working /dev/dri/renderD128 passthrough. Worth getting right — one N100 with QuickSync can handle several 4K->1080p streams at single-digit watts.
  • *arr integration. API keys, download-client setup (qBittorrent / SABnzbd), indexer credentials, and quality profiles all live outside the recipe.
  • Real TLS. The proxy serves plain HTTP. Layer the nginx + TLS reverse proxy pattern on top, or swap nginx for Caddy with automatic ACME.
  • Backups. /var/lib/jellyfin watch-history, metadata, and user accounts need off-host snapshots.

Where to go next

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.

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.