This is the setup I use to run a full media automation stack on my Kubernetes cluster. It covers Jellyfin for streaming, Radarr and Sonarr for media management, Jackett for indexers, qBittorrent for downloads, and Gluetun for VPN tunneling. Config lives on Longhorn; media is stored on a Synology NAS DS223 mounted via NFS 4.1 as a PersistentVolume.
Components
| Application | Role |
|---|---|
| Jellyfin | Media streaming |
| Radarr | Movie library management |
| Sonarr | TV show management |
| Jackett | Torrent indexer proxy |
| 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=3 storageClassName: ""---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=3 storageClassName: ""---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.yamlDanger
You need a separate PVC for each application: Jellyfin, Sonarr, Radarr, Jackett, 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: jellyfin 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: sonarr spec: containers: - name: sonarr image: 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: radarr spec: containers: - name: radarr image: 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-downloadJackett
apiVersion: apps/v1kind: Deploymentmetadata: name: jackett namespace: mediaspec: replicas: 1 selector: matchLabels: app: jackett template: metadata: labels: app: jackett spec: containers: - name: jackett image: linuxserver/jackett env: - name: PUID value: "1057" - name: PGID value: "1056" volumeMounts: - name: config mountPath: /config ports: - containerPort: 9117 volumes: - name: config persistentVolumeClaim: claimName: jackett-configqBittorrent (standalone)
apiVersion: apps/v1kind: Deploymentmetadata: name: qbittorrent namespace: mediaspec: replicas: 1 selector: matchLabels: app: qbittorrent template: metadata: labels: app: qbittorrent spec: containers: - name: qbittorrent image: 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: qbittorrent spec: containers: - name: qbittorrent image: 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: qmcgaw/gluetun env: - name: VPNSP value: "protonvpn" - name: OPENVPN_USER valueFrom: secretKeyRef: name: protonvpn-secrets key: PROTONVPN_USER - name: OPENVPN_PASSWORD valueFrom: secretKeyRef: name: protonvpn-secrets key: PROTONVPN_PASSWORD - name: COUNTRY value: "Germany" securityContext: capabilities: add: - NET_ADMIN volumeMounts: - name: gluetun-config mountPath: /gluetun
volumes: - name: config persistentVolumeClaim: claimName: qbitt-config - name: downloads persistentVolumeClaim: claimName: qbitt-download - name: gluetun-config persistentVolumeClaim: claimName: gluetun-configNote
I use ProtonVPN — no-logs policy, solid speeds, and reasonably priced.
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.containo.us/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.containo.us/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.yamlDanger
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: