For a long time, I’ve been on the hunt for a comprehensive and well crafted tutorial to deploy a media server on my Kubernetes cluster. This media server stack includes Jellyfin, Radarr, Sonarr, Jackett, and qBittorrent. Let’s briefly dive into what each component brings to our setup.
Components Overview
| Application | Description |
|---|---|
| Jellyfin | An open-source media system that provides a way to manage and stream your media library across various devices. |
| Radarr | A movie collection manager for Usenet and BitTorrent users. It automates the process of searching for movies, downloading, and managing your movie library. |
| Sonarr | Similar to Radarr but for TV shows. It keeps track of your series, downloads new episodes, and manages your collection with ease. |
| Jackett | Acts as a proxy server, translating queries from other apps (like Sonarr or Radarr) into queries that can be understood by a wide array of torrent search engines. |
| qBittorrent | A powerful BitTorrent client that handles your downloads. Paired with Jackett, it streamlines finding and downloading media content. |
| Gluetun | A lightweight, open-source VPN client for Docker environments, supporting multiple VPN providers to secure and manage internet connections across containerized applications. |
The configuration for these applications is hosted on Longhorn storage, ensuring resilience and ease of management, while the media (movies, shows, books, etc.) is stored on a Synology NAS DS223. The NAS location is utilized as a Persistent Volume (PV) through NFS 4.1 by Kubernetes.
Synology NAS NFS Setup for Kubernetes
If you use Synology NAS, this is the rule I created for my NFS share which will be mounted on kubernetes side.

Configuring PVC and PV for NFS Share
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: ""Apply with:
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: ""Apply with:
kubectl apply -f nfs-download-pv-and-pvc.yamlConfiguring Longhorn PVC for Each Application
Create app-config-pvc.yaml:
apiVersion: v1kind: PersistentVolumeClaimmetadata: name: app # radarr for example namespace: mediaspec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5GiApply with:
kubectl apply -f app-config-pvc.yamlDanger
This type of configuration needs to be generated for each application: Jellyfin, Sonarr, Radarr, Jackett, qBittorrent.
Deploying Each Application
Jellyfin
Jellyfin serves as our media streaming platform, providing access to movies, TV shows, and other media across various devices.
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-videosApply with:
kubectl apply -f jellyfin-deployment.yamlSonarr
Sonarr automates TV show downloads, managing our series collection efficiently.
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
Radarr works like Sonarr but focuses on movies, keeping our film library organized and up-to-date.
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
Jackett acts as a bridge between torrent search engines and our media management tools, enhancing their capabilities.
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
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
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’ve chosen to use ProtonVPN due to their security policy and because they do not collect/store data, but also because of the speeds and diverse settings, all at a very good price.
Creating ClusterIP Services
For our media server applications to communicate efficiently within the Kubernetes cluster without exposing them directly to the external network, we utilize ClusterIP services.
Create app-service.yaml for each application:
apiVersion: v1kind: Servicemetadata: name: app # radarr for example namespace: mediaspec: type: ClusterIP ports: - port: 80 targetPort: 7878 selector: app: app # radarr for exampleApply with:
kubectl apply -f app-service.yamlCreating Middleware for Traefik
For enhanced security and to ensure smooth functioning with Traefik, we define 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: httpsApply with:
kubectl apply -f default-headers-media.yamlCreating Ingress Route for Each Application
To expose each application securely, we create IngressRoutes using Traefik:
Create app-ingress-route.yaml:
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 nameApply with:
kubectl apply -f app-ingress-route.yamlDanger
Don’t forget: You must create the host declared in your IngressRoute in your DNS server(s).
Q&A
Q: Why use a ClusterIP service?
A: Because we will be using Traefik as an ingress controller to expose it to the local network/internet with SSL/TLS certificates.
Q: Can I download all manifest files from anywhere?
A: SURE! The link is at the end of this page :)
Manifest Files
Just copy and deploy all you need in no time!
This concludes the necessary steps and configurations to deploy a resilient media server in a Kubernetes cluster successful