Deploying a Kubernetes-Based Media Server

#kubernetes#homelab

How to deploy a full ARR stack — Jellyfin, Radarr, Sonarr, Prowlarr, and qBittorrent — on Kubernetes with NFS storage and Traefik ingress.

Warning

Archived. This setup ran on k3s, which I’ve since replaced with Talos + FluxCD GitOps. Keeping this post up as a reference for the manifest structure and NFS setup on Kubernetes.

This stack runs fine in Docker Compose. I moved it to Kubernetes because everything else already was, and running one thing differently from everything else costs more than the migration did — volumes, health checks and restarts all behave the same way as the rest of the cluster.

Two storage classes underneath: app configs on Longhorn, media on a Synology DS223 exported over NFS 4.1 as a PersistentVolume. Configs are small and want replication; 400GB of media does not.

The stack

Application Role
Jellyfin Media streaming
Radarr Movie library management
Sonarr TV show management
Prowlarr Torrent indexer manager
qBittorrent Download client
Gluetun VPN sidecar for qBittorrent

The NFS export

This has to exist before anything on the Kubernetes side tries to mount it. A PV pointing at an export that doesn’t permit the node stays Pending with no useful message. The Synology rule I use:

NFS rule configuration on Synology NAS


Media and download volumes

The library, mounted read-write by the *arr apps and read by Jellyfin — nfs-media-pv-and-pvc.yaml:

apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-videos
spec:
capacity:
storage: 400Gi
accessModes:
- ReadWriteOnce
nfs:
path: /volume1/server/k3s/media
server: storage.merox.cloud
persistentVolumeReclaimPolicy: Retain
mountOptions:
- hard
- nfsvers=4.1
storageClassName: ""
---
13 collapsed lines
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-videos
namespace: media
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 400Gi
volumeName: jellyfin-videos
storageClassName: ""
Terminal window
kubectl apply -f nfs-media-pv-and-pvc.yaml

And the download target, separate so an in-progress download is never inside the library — nfs-download-pv-and-pvc.yaml:

apiVersion: v1
kind: PersistentVolume
metadata:
name: qbitt-download
spec:
capacity:
storage: 400Gi
accessModes:
- ReadWriteOnce
nfs:
path: /volume1/server/k3s/media/download
server: storage.merox.cloud
persistentVolumeReclaimPolicy: Retain
mountOptions:
- hard
- nfsvers=4.1
storageClassName: ""
---
13 collapsed lines
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: qbitt-download
namespace: media
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 400Gi
volumeName: qbitt-download
storageClassName: ""
Terminal window
kubectl apply -f nfs-download-pv-and-pvc.yaml

Config volumes on Longhorn

One per app, 5Gi each. These hold the databases the *arr apps refuse to rebuild — app-config-pvc.yaml:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app # radarr for example
namespace: media
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
Terminal window
kubectl apply -f app-config-pvc.yaml
Caution

One PVC per application — Jellyfin, Sonarr, Radarr, Prowlarr and qBittorrent. Sharing one between two of them corrupts both databases, not just the second.


Deployments

Jellyfin

Create jellyfin-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: jellyfin
namespace: media
spec:
replicas: 1
selector:
matchLabels:
app: jellyfin
template:
metadata:
labels:
app: jellyfin
18 collapsed lines
spec:
containers:
- name: jellyfin
image: jellyfin/jellyfin
volumeMounts:
- name: config
mountPath: /config
- name: videos
mountPath: /data/videos
ports:
- containerPort: 8096
volumes:
- name: config
persistentVolumeClaim:
claimName: jellyfin-config
- name: videos
persistentVolumeClaim:
claimName: jellyfin-videos
Terminal window
kubectl apply -f jellyfin-deployment.yaml

Sonarr

apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr
namespace: media
spec:
replicas: 1
selector:
matchLabels:
app: sonarr
template:
metadata:
labels:
app: sonarr
28 collapsed lines
spec:
containers:
- name: sonarr
image: lscr.io/linuxserver/sonarr
env:
- name: PUID
value: "1057"
- name: PGID
value: "1056"
volumeMounts:
- name: config
mountPath: /config
- name: videos
mountPath: /tv
- name: downloads
mountPath: /downloads
ports:
- containerPort: 8989
volumes:
- name: config
persistentVolumeClaim:
claimName: sonarr-config
- name: videos
persistentVolumeClaim:
claimName: jellyfin-videos
- name: downloads
persistentVolumeClaim:
claimName: qbitt-download

Radarr

apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr
namespace: media
spec:
replicas: 1
selector:
matchLabels:
app: radarr
template:
metadata:
labels:
app: radarr
28 collapsed lines
spec:
containers:
- name: radarr
image: lscr.io/linuxserver/radarr
env:
- name: PUID
value: "1057"
- name: PGID
value: "1056"
volumeMounts:
- name: config
mountPath: /config
- name: videos
mountPath: /movies
- name: downloads
mountPath: /downloads
ports:
- containerPort: 7878
volumes:
- name: config
persistentVolumeClaim:
claimName: radarr-config
- name: videos
persistentVolumeClaim:
claimName: jellyfin-videos
- name: downloads
persistentVolumeClaim:
claimName: qbitt-download

Prowlarr

apiVersion: apps/v1
kind: Deployment
metadata:
name: prowlarr
namespace: media
spec:
replicas: 1
selector:
matchLabels:
app: prowlarr
template:
metadata:
labels:
app: prowlarr
18 collapsed lines
spec:
containers:
- name: prowlarr
image: lscr.io/linuxserver/prowlarr
env:
- name: PUID
value: "1057"
- name: PGID
value: "1056"
volumeMounts:
- name: config
mountPath: /config
ports:
- containerPort: 9696
volumes:
- name: config
persistentVolumeClaim:
claimName: prowlarr-config

qBittorrent, no VPN

Warning

qBittorrent v5 renamed the API endpoints /torrents/pause and /torrents/resume to /torrents/stop and /torrents/start. If you use any scripts or integrations that call the qBittorrent API directly, update them before upgrading from v4.

apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: media
spec:
replicas: 1
selector:
matchLabels:
app: qbittorrent
template:
metadata:
labels:
app: qbittorrent
28 collapsed lines
spec:
containers:
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent
resources:
limits:
memory: "2Gi"
requests:
memory: "512Mi"
env:
- name: PUID
value: "1057"
- name: PGID
value: "1056"
volumeMounts:
- name: config
mountPath: /config
- name: downloads
mountPath: /downloads
ports:
- containerPort: 8080
volumes:
- name: config
persistentVolumeClaim:
claimName: qbitt-config
- name: downloads
persistentVolumeClaim:
claimName: qbitt-download

qBittorrent behind Gluetun

Use this instead of the one above, not alongside it. Gluetun is a sidecar in the same pod, so it shares the network namespace — qBittorrent has no route to the internet except through the tunnel, which is the point. If Gluetun dies, qBittorrent loses connectivity rather than falling back to your real IP.

apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: media
spec:
replicas: 1
selector:
matchLabels:
app: qbittorrent
template:
metadata:
labels:
app: qbittorrent
64 collapsed lines
spec:
containers:
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent
resources:
limits:
memory: "2Gi"
requests:
memory: "512Mi"
env:
- name: PUID
value: "1057"
- name: PGID
value: "1056"
volumeMounts:
- name: config
mountPath: /config
- name: downloads
mountPath: /downloads
ports:
- containerPort: 8080
- name: gluetun
image: ghcr.io/qdm12/gluetun:v3.40.0
env:
- name: VPN_SERVICE_PROVIDER
value: "surfshark"
- name: VPN_TYPE
value: "wireguard"
- name: SERVER_COUNTRIES
value: "Netherlands"
- name: WIREGUARD_ADDRESSES
value: "10.14.0.2/16" # from SurfShark WireGuard config — Address field
- name: FIREWALL_INPUT_PORTS
value: "50413,8080" # torrent port + web UI port
- name: FIREWALL_OUTBOUND_SUBNETS
value: "10.0.0.0/8"
- name: DNS_KEEP_NAMESERVER
value: "on"
- name: DOT
value: "off"
- name: WIREGUARD_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: surfshark-secret
key: WIREGUARD_PRIVATE_KEY
securityContext:
capabilities:
add:
- NET_ADMIN
volumeMounts:
- name: tun
mountPath: /dev/net/tun
volumes:
- name: config
persistentVolumeClaim:
claimName: qbitt-config
- name: downloads
persistentVolumeClaim:
claimName: qbitt-download
- name: tun
hostPath:
path: /dev/net/tun
Note

I use SurfShark with WireGuard — faster than OpenVPN and natively supported by Gluetun. Generate your WireGuard key from the SurfShark dashboard under VPN → Manual Setup → WireGuard. Note: SurfShark does not support port forwarding, so peers cannot initiate inbound connections — downloads still work fine but may be slower without seeding peers.


Services and routing

Each app needs a ClusterIP service before Traefik has anything to route to. One per application, app-service.yaml — mind that targetPort differs per app while port stays 80:

apiVersion: v1
kind: Service
metadata:
name: app # radarr for example
namespace: media
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 7878
selector:
app: app # radarr for example
Terminal window
kubectl apply -f app-service.yaml

Traefik middleware

The security headers, applied once and referenced by every route — default-headers-media.yaml:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: default-headers-media
namespace: media
spec:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 15552000
customFrameOptionsValue: SAMEORIGIN
customRequestHeaders:
X-Forwarded-Proto: https
Terminal window
kubectl apply -f default-headers-media.yaml

IngressRoutes

Then one per application, app-ingress-route.yaml:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: app # radarr for example
namespace: media
annotations:
kubernetes.io/ingress.class: traefik-external
spec:
entryPoints:
- websecure
routes:
- match: Host(`movies.merox.cloud`) # change to your domain
kind: Rule
services:
- name: app # radarr for example
port: 80
- match: Host(`movies.merox.cloud`) # change to your domain
kind: Rule
services:
- name: app # radarr for example
port: 80
middlewares:
- name: default-headers-media
tls:
secretName: mycert-tls # change to your cert name
Terminal window
kubectl apply -f app-ingress-route.yaml
Caution

The hostname in the IngressRoute has to resolve before you apply it. Traefik will happily accept a route for a name nothing can look up, and the failure surfaces in the browser rather than in kubectl.


Manifests

All of them, ready to copy: media-stack manifests.

One thing to change if you build on this: the media PV is declared ReadWriteOnce while five deployments mount it. That works only as long as every pod lands on the same node, and it stops working the first time the scheduler disagrees. NFS is ReadWriteMany — declare it that way and the constraint disappears.