See full diagram gallery for interactive versions
GPU-accelerated media server stack in the media namespace. Full content automation from request to playback, with both a remote seedbox sync path and a local VPN-tunneled torrent client. Two media servers run side-by-side: Jellyfin (primary, HA, API-driven apps) and Plex (external clients — TV/phone apps).
| Property | Value |
|---|---|
| Namespace | media |
| PSS | privileged (Jellyfin requires /dev/dri DRM ioctls for Intel QSV — set in kubernetes/apps/media/namespace.yaml) |
| Component | Image | Port | Purpose | Docs |
|---|---|---|---|---|
| Jellyfin HA | harbor.k3s.internal.strommen.systems/production/jellyfin-ha:sha-<commit> |
8096 | Media server with QSV transcoding (HA fork, PostgreSQL backend) | Jellyfin |
| Plex | plexinc/pms-docker:1.43.0.10492-121068a07 |
32400 | Media server for external Plex apps (TV, mobile, desktop) | Plex |
| Jellyseerr | ghcr.io/seerr-team/seerr:v3.1.0 |
5055 | Media request management — routes to Radarr/Sonarr | Jellyseerr |
| Radarr | linuxserver/radarr:5.16.3 |
7878 | Movie download automation | Radarr |
| Sonarr | linuxserver/sonarr:4.0.17 |
8989 | TV download automation | Sonarr |
| Prowlarr | linuxserver/prowlarr:1.28.2 |
9696 | Indexer manager (TorrentLeech, etc.) | Prowlarr |
| Bazarr | linuxserver/bazarr:1.4.5 |
6767 | Automated subtitle downloads | Bazarr |
| qBittorrent | linuxserver/qbittorrent:5.0.3 |
8080 | Local torrent client (VPN-tunneled via gluetun) | qBittorrent |
| gluetun | ghcr.io/qdm12/gluetun:v3.40.0 |
8888 | OpenVPN sidecar — kill switch + HTTP proxy for Prowlarr | qBittorrent |
| FlareSolverr | ghcr.io/flaresolverr/flaresolverr:v3.3.21 |
8191 | Cloudflare bypass proxy for Prowlarr indexers | — |
| Tdarr Server | ghcr.io/haveagitgat/tdarr:2.58.02 |
8265/8266 | Distributed transcode job queue and web UI | Tdarr |
| Tdarr Worker | ghcr.io/haveagitgat/tdarr_node:2.58.02 |
— | GPU-accelerated transcode worker (DaemonSet on GPU nodes) | Tdarr |
| Media Controller | harbor.k3s.internal.strommen.systems/production/media-controller:sha-0bbd4a9 |
8080 | Automated media lifecycle: discovery, promotion, pruning of auto-managed library | Media Controller |
| Seedbox Sync | alpine:3.19 (rsync) |
— | rsync over SSH: RapidSeedbox → NAS (CronJob, every 4h) | Seedbox Sync |
| exportarr | ghcr.io/onedr0p/exportarr:v2.3.0 |
9707 | Prometheus metrics sidecar for Radarr/Sonarr/Prowlarr/Bazarr | — |
| Intel GPU Exporter | restreamio/intel-prometheus-gpu-exporter:0.0.3 |
8080 | GPU utilization metrics (DaemonSet) | — |
Image tag policy: Never deploy with
:latestalone. All Harbor images usesha-<shortsha>tags. Thejellyfin-ha:latesttag in some manifests is a known policy violation — TODO: pin tosha-<commit>.
| Service | URL | Access |
|---|---|---|
| Jellyfin | https://jellyfin.k3s.strommen.systems | Public — Jellyfin native accounts (no Authentik; TV/mobile apps incompatible) |
| Plex | https://plex.k3s.strommen.systems | Public — Built-in Plex account auth (no Authentik; external clients need direct access) |
| Jellyseerr | https://jellyseerr.k3s.strommen.systems | Public — Authentik SSO |
| Radarr | https://radarr.k3s.strommen.systems | Public — Authentik SSO |
| Sonarr | https://sonarr.k3s.strommen.systems | Public — Authentik SSO |
| Prowlarr | https://prowlarr.k3s.strommen.systems | Public — Authentik SSO |
| Bazarr | https://bazarr.k3s.strommen.systems | Public — Authentik SSO |
| qBittorrent | https://qbt.k3s.internal.strommen.systems | Internal only — Authentik forwardAuth |
| Tdarr | https://tdarr.k3s.strommen.systems | Public — Authentik SSO |
| Media Controller | https://media-controller.k3s.strommen.systems | Public — Authentik SSO |
Two parallel download paths feed the same NAS media library:
FlareSolverr sits between Prowlarr and Cloudflare-protected indexers (configured in Prowlarr: Settings → Indexers → FlareSolverr proxy at http://flaresolverr:8191).
gluetun HTTP proxy (port 8888, service qbittorrent-proxy) can also be used to route Prowlarr indexer traffic through the seedbox exit IP.
Seedbox Sync note: The CronJob uses rsync over SSH (not rclone). The manifest is named
rclone-sync.yamlfor historical reasons. See Seedbox Sync for full details.
The Media Controller (harbor.../production/media-controller:sha-0bbd4a9) automates lifecycle management of the auto-managed/ media tier:
auto-managed/ → permanent/ based on watch history| Property | Value |
|---|---|
| URL | https://media-controller.k3s.strommen.systems (Authentik forwardAuth) |
| Port | 8080 |
| DB | PostgreSQL 16-alpine (5Gi Longhorn PVC), DB: media_controller |
| DB backup | Daily 4:00 AM UTC → S3 postgres-backups/media/ |
Secrets required:
kubectl create secret generic media-controller-secrets -n media \
--from-literal=jellyfin-api-key=<key> \
--from-literal=jellyseerr-api-key=<key> \
--from-literal=radarr-api-key=<key> \
--from-literal=sonarr-api-key=<key> \
--from-literal=jellyfin-user-id=<user-guid> \
--from-literal=slack-webhook-url=<url>
kubectl create secret generic media-controller-postgres-credentials -n media \
--from-literal=username=media_controller \
--from-literal=password="$(openssl rand -base64 24)"
192.168.30.10 (Storage VLAN 30), 19TB usable volume/volume1/media to 192.168.20.0/24 with root_squashSee Storage Architecture for PV details and UID mapping.
All accounts are members of media-services group (GID 10000).
| Account | UID | Access |
|---|---|---|
| svc-jellyfin | 10010 | Read all media |
| svc-bazarr | 10011 | Read movies/ + tv/ |
| svc-rclone | 10012 | Read-write all (also used by qBittorrent — see TODO below) |
| svc-radarr | 10013 | Read-write movies/ + downloads/ |
| svc-sonarr | 10014 | Read-write tv/ + downloads/ |
| svc-prowlarr | 10015 | Config only |
| svc-tdarr | 10016 | Read-write all (transcode-in-place) |
| svc-media-ctrl | 10017 | Read-write auto-managed/ + permanent/ |
| svc-plex | 10018 | Read all media |
TODO: qBittorrent currently runs as
svc-rclone(UID 10012). Create dedicatedsvc-qbittorrent(UID 10019) for proper isolation. Updateqbittorrent.yamlrunAsUser/fsGroupand NAS ACLs.
/volume1/media/
movies/ (mode 2775, owner: svc-rclone, group: media-services)
tv/ (mode 2775)
music/ (mode 2755)
downloads/
movies/ (mode 2775, staging for Radarr import)
tv/ (mode 2775, staging for Sonarr import)
staging/ (mode 2775, rsync destination from seedbox)
auto-managed/
movies/ (auto-delete tier — managed by Media Controller)
tv/
permanent/
movies/ (keep-forever tier)
tv/
/dev/dri)preferredDuringScheduling for gpu=intel-uhd-630 node labelpreferredDuringScheduling for gpu=intel-uhd-630 — tolerates NoSchedule taint; falls back to CPU if GPU unavailablenodeSelector: gpu: intel-uhd-630 — one worker per GPU nodeintel-media-va-driver-non-free, i965-va-driver-shadersmedia namespace runs at privileged enforcement level specifically to allow /dev/dri DRM ioctls. This is the minimum required for hardware transcoding.See dedicated page: Jellyfin HA
Jellyfin runs as a 2-replica StatefulSet from the jellyfin-ha fork image (Harbor), backed by PostgreSQL and Redis session cache.
| Component | Image | Purpose |
|---|---|---|
| jellyfin-ha (×2) | production/jellyfin-ha:sha-<commit> |
Media server (StatefulSet) |
| PostgreSQL | postgres:16.13-alpine |
Metadata + library database (5Gi Longhorn PVC) |
| Redis | redis:7.4.2-alpine3.21 |
Session cache (no persistence — reconstructable) |
| PDB | — | Ensures at least 1 replica during disruptions |
Valkey migration staged:
jellyfin-valkey.yamlexists as a drop-in Redis replacement but is NOT applied by default. Promoted once validation passes.
See dedicated page: Plex Media Server
plexinc/pms-docker:1.43.0.10492-121068a07 (official Docker Hub — not Harbor)plex-config)plex-nfs-pvc (NFS ROX, shared read-only with Jellyfin)emptyDir (30Gi limit)maxUnavailable: 0 — kubectl drain blocks until Plex is manually scaled downplex-library-scan every 6h (sections 1, 2, 4)See dedicated page: qBittorrent
Pod network namespace (shared by both containers):
gluetun establishes OpenVPN tunnel -> 45.128.27.65:1194/UDP
All pod egress exits via seedbox IP (kill switch active)
Cluster access (bypasses VPN — allowed via FIREWALL_OUTBOUND_SUBNETS):
Radarr/Sonarr -> qbittorrent:8080 (ClusterIP)
Prowlarr -> qbittorrent-proxy:8888 (gluetun HTTP proxy)
VPN credentials: secret/qbittorrent-vpn -n media (create out-of-band)
Keys: vpn-username, vpn-password
TODO: Enable Prometheus exporter sidecar (
esanchezm/prometheus-qbittorrent-exporter) once a permanent WebUI password is set.
| Secret | Namespace | Keys | Purpose |
|---|---|---|---|
jellyfin-postgres-credentials |
media | POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DATABASE_URL |
Jellyfin PostgreSQL access |
qbittorrent-vpn |
media | vpn-username, vpn-password |
gluetun OpenVPN credentials (RapidSeedbox) |
seedbox-ssh |
media | SSH_HOST, SSH_PORT, SSH_USER, SSH_PASS |
rsync-over-SSH credentials for seedbox sync CronJob |
arr-api-keys |
media | RADARR_API_KEY, SONARR_API_KEY |
Consumed by seedbox-sync CronJob to trigger imports |
exportarr-api-keys |
media | BAZARR_API_KEY, RADARR_API_KEY, SONARR_API_KEY, PROWLARR_API_KEY |
exportarr sidecar Prometheus scraping |
media-controller-secrets |
media | jellyfin-api-key, jellyseerr-api-key, radarr-api-key, sonarr-api-key, jellyfin-user-id, slack-webhook-url |
Media Controller API integrations |
media-controller-postgres-credentials |
media | username, password |
Media Controller PostgreSQL access |
Legacy:
seedbox-sftpsecret still exists in the namespace but is no longer used (replaced byseedbox-sshwhen switching from rclone to rsync).
Bootstrap commands:
# Jellyfin PostgreSQL
PW="$(openssl rand -base64 32)"
kubectl create secret generic jellyfin-postgres-credentials -n media \
--from-literal=POSTGRES_USER=jellyfin \
--from-literal=POSTGRES_PASSWORD="${PW}" \
--from-literal=POSTGRES_DB=jellyfin \
--from-literal=DATABASE_URL="postgresql://jellyfin:${PW}@jellyfin-postgres.media:5432/jellyfin"
# Seedbox SSH (rsync-over-SSH)
kubectl create secret generic seedbox-ssh -n media \
--from-literal=SSH_HOST=<seedbox-ip> \
--from-literal=SSH_PORT=2222 \
--from-literal=SSH_USER=<username> \
--from-literal=SSH_PASS=<password>
# arr API keys (for seedbox-sync import triggers + exportarr)
kubectl create secret generic arr-api-keys -n media \
--from-literal=RADARR_API_KEY=<key> \
--from-literal=SONARR_API_KEY=<key>
kubectl create secret generic exportarr-api-keys -n media \
--from-literal=BAZARR_API_KEY=<key> \
--from-literal=RADARR_API_KEY=<key> \
--from-literal=SONARR_API_KEY=<key> \
--from-literal=PROWLARR_API_KEY=<key>
kubernetes/apps/media/grafana-dashboard-media-stack.yaml — Jellyfin HA pods, PostgreSQL replication, Redis, NAS storagekubernetes/apps/media/grafana-dashboard.yaml — Intel GPU metricsJellyfinDown, JellyseerrDown, PlexDown, GPUTranscodeOverload, MediaNFSUnavailable, RcloneSyncFailing, JellyfinHighMemory, MediaControllerDown, MediaControllerPruneFailed, MediaControllerDiscoveryDry, MediaControllerAPIErrorshttp://flaresolverr:8191)/media/movies, note API key/media/tv, note API keyqbittorrent-vpn secretmedia-controller-secrets; configure watch thresholds in ConfigMapAll manifests in kubernetes/apps/media/:
| File | Purpose |
|---|---|
namespace.yaml |
Namespace (sets privileged PSS — required for GPU transcoding) |
nfs-pv.yaml |
NFS PersistentVolumes + PVCs for all services |
jellyfin.yaml |
StatefulSet (2 replicas), services, IngressRoute |
jellyfin-postgres.yaml |
PostgreSQL StatefulSet + PVC |
jellyfin-redis.yaml |
Redis session cache (production) |
jellyfin-valkey.yaml |
Valkey (staged Redis replacement, not yet active) |
jellyfin-cache-service.yaml |
Stable cache service alias |
jellyfin-networkpolicy.yaml |
Network isolation rules |
jellyfin-pdb.yaml |
PodDisruptionBudget |
jellyfin-pg-backup.yaml |
Nightly DB backup CronJob (3:30 AM UTC) |
plex.yaml |
Deployment, PVC, Service, IngressRoute, Certificate, PDB |
plex-library-scan.yaml |
CronJob (library refresh every 6h via Plex API) |
jellyseerr.yaml |
Deployment (chown init container), PVC, Service, Certificate |
radarr.yaml |
Deployment, PVC, IngressRoute, exportarr sidecar |
sonarr.yaml |
Deployment, PVC, IngressRoute, exportarr sidecar |
prowlarr.yaml |
Deployment, PVC, IngressRoute, exportarr sidecar |
bazarr.yaml |
Deployment, PVC, IngressRoute, exportarr sidecar |
qbittorrent.yaml |
Deployment (qBittorrent + gluetun), Services, IngressRoute |
qbittorrent-netpol.yaml |
Network policy for VPN pod |
flaresolverr.yaml |
Deployment, ClusterIP service |
tdarr.yaml |
Server Deployment + Worker DaemonSet, Services, IngressRoute |
media-controller.yaml |
PostgreSQL StatefulSet + media-controller Deployment, Service, IngressRoute |
media-controller-postgres-backup.yaml |
Daily backup CronJob (4:00 AM UTC) |
rclone-sync.yaml |
CronJob (rsync SSH → NAS, every 4h) — misnamed; uses rsync not rclone |
gpu-exporter.yaml |
Intel GPU metrics DaemonSet |
alerts.yaml |
PrometheusRules |
grafana-dashboard.yaml |
Grafana ConfigMap — Intel GPU metrics dashboard |
grafana-dashboard-media-stack.yaml |
Grafana ConfigMap — Jellyfin HA + arr suite overview |