Deploying a Kubernetes-Based Media Server
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:

Media and download volumes
The library, mounted read-write by the *arr apps and read by Jellyfin — nfs-media-pv-and-pvc.yaml:
apiVersion: v1kind: PersistentVolumemetadata: name: jellyfin-videosspec: 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: v1kind: PersistentVolumeClaimmetadata: name: jellyfin-videos namespace: mediaspec: accessModes: - ReadWriteOnce resources: requests: storage: 400Gi volumeName: jellyfin-videos storageClassName: ""kubectl apply -f nfs-media-pv-and-pvc.yamlAnd the download target, separate so an in-progress download is never inside the library — nfs-download-pv-and-pvc.yaml:
apiVersion: v1kind: PersistentVolumemetadata: name: qbitt-downloadspec: 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: v1kind: PersistentVolumeClaimmetadata: name: qbitt-download namespace: mediaspec: accessModes: - ReadWriteOnce resources: requests: storage: 400Gi volumeName: qbitt-download storageClassName: ""kubectl apply -f nfs-download-pv-and-pvc.yamlConfig volumes on Longhorn
One per app, 5Gi each. These hold the databases the *arr apps refuse to rebuild — app-config-pvc.yaml:
apiVersion: v1kind: PersistentVolumeClaimmetadata: name: app # radarr for example namespace: mediaspec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5Gikubectl apply -f app-config-pvc.yamlCaution
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/v1kind: Deploymentmetadata: name: jellyfin namespace: mediaspec: replicas: 1 selector: matchLabels: app: jellyfin template: metadata: labels: app: jellyfin18 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-videoskubectl apply -f jellyfin-deployment.yamlSonarr
apiVersion: apps/v1kind: Deploymentmetadata: name: sonarr namespace: mediaspec: replicas: 1 selector: matchLabels: app: sonarr template: metadata: labels: app: sonarr28 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-downloadRadarr
apiVersion: apps/v1kind: Deploymentmetadata: name: radarr namespace: mediaspec: replicas: 1 selector: matchLabels: app: radarr template: metadata: labels: app: radarr28 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-downloadProwlarr
apiVersion: apps/v1kind: Deploymentmetadata: name: prowlarr namespace: mediaspec: replicas: 1 selector: matchLabels: app: prowlarr template: metadata: labels: app: prowlarr18 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-configqBittorrent, 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/v1kind: Deploymentmetadata: name: qbittorrent namespace: mediaspec: replicas: 1 selector: matchLabels: app: qbittorrent template: metadata: labels: app: qbittorrent28 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-downloadqBittorrent 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/v1kind: Deploymentmetadata: name: qbittorrent namespace: mediaspec: replicas: 1 selector: matchLabels: app: qbittorrent template: metadata: labels: app: qbittorrent64 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/tunNote
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: v1kind: Servicemetadata: name: app # radarr for example namespace: mediaspec: type: ClusterIP ports: - port: 80 targetPort: 7878 selector: app: app # radarr for examplekubectl apply -f app-service.yamlTraefik middleware
The security headers, applied once and referenced by every route — default-headers-media.yaml:
apiVersion: traefik.io/v1alpha1kind: Middlewaremetadata: name: default-headers-media namespace: mediaspec: headers: browserXssFilter: true contentTypeNosniff: true forceSTSHeader: true stsIncludeSubdomains: true stsPreload: true stsSeconds: 15552000 customFrameOptionsValue: SAMEORIGIN customRequestHeaders: X-Forwarded-Proto: httpskubectl apply -f default-headers-media.yamlIngressRoutes
Then one per application, app-ingress-route.yaml:
apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: app # radarr for example namespace: media annotations: kubernetes.io/ingress.class: traefik-externalspec: 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 namekubectl apply -f app-ingress-route.yamlCaution
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.