Deploying a Kubernetes-Based Media Server
How to deploy a full ARR stack — Jellyfin, Radarr, Sonarr, Jackett, 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.
Running a media stack in Docker Compose is easy enough, but once you’re already managing a Kubernetes cluster it makes sense to move it there — persistent volumes, health checks, and restarts all handled the same way as everything else. This is the full ARR stack: Jellyfin for streaming, Radarr and Sonarr for media management, Prowlarr for indexers, qBittorrent for downloads, and Gluetun as a VPN sidecar. Config lives on Longhorn; media is stored on a Synology DS223 mounted via NFS 4.1 as a PersistentVolume.
Components
| 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 |
Synology NAS NFS Setup
If you’re using a Synology NAS, this is the NFS share rule I use — applied before mounting on the Kubernetes side.

PersistentVolumes and PVCs
Media Storage
Create 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.yamlDownload Storage
Create 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.yamlLonghorn PVC for App Configs
Create app-config-pvc.yaml (repeat for each app):
apiVersion: v1kind: PersistentVolumeClaimmetadata: name: app # radarr for example namespace: mediaspec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5Gikubectl apply -f app-config-pvc.yamlCaution
You need a separate PVC for each application: Jellyfin, Sonarr, Radarr, Prowlarr, and qBittorrent.
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 (standalone)
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 with Gluetun
If you want to route qBittorrent traffic through a VPN, use this version instead — Gluetun runs as a sidecar and shares the network namespace.
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.
ClusterIP Services
Each app needs a ClusterIP service so Traefik can route to it internally. Create app-service.yaml per application:
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
Create 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
Create app-ingress-route.yaml per application:
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
Add the hostname declared in your IngressRoute to your DNS server before applying.
Manifest Files
All manifests are available here — copy and deploy what you need: