Skip to main content
  1. blog/

Deploying a Kubernetes-Based Media Server

·9 mins· loading · ·
Kubernetes Installation Tutorial Opensource
Robert Melcher
Author
Robert Melcher
„Sutor, ne ultra crepidam”
Table of Contents

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

ApplicationDescription
JellyfinAn open-source media system that provides a way to manage and stream your media library across various devices.
RadarrA movie collection manager for Usenet and BitTorrent users. It automates the process of searching for movies, downloading, and managing your movie library.
SonarrSimilar to Radarr but for TV shows. It keeps track of your series, downloads new episodes, and manages your collection with ease.
JackettActs 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.
qBittorrentA powerful BitTorrent client that handles your downloads. Paired with Jackett, it streamlines finding and downloading media content.
GluetunA lightweight, open-source VPN client for Docker environments, supporting multiple VPN providers to secure and manage internet connections across containerized applications. It ensures privacy and seamless network security with easy configuration and integration.

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.

In this tutorial, you’ll find the Kubernetes configuration for each necessary component to set up, install, and secure each service used by the media server.

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.

NFS_RULE

Let’s start step by step.

Configuring PVC and PV for NFS Share
#

Media
#

Create nfs-media-pv-and-pvc.yaml:

 1apiVersion: v1
 2kind: PersistentVolume
 3metadata:
 4  name: jellyfin-videos
 5spec:
 6  capacity:
 7    storage: 400Gi
 8  accessModes:
 9    - ReadWriteOnce
10  nfs:
11    path: /volume1/server/k3s/media
12    server: storage.merox.cloud
13  persistentVolumeReclaimPolicy: Retain
14  mountOptions:
15    - hard
16    - nfsvers=3
17  storageClassName: ""
18# Persistent Volume spec including capacity, access modes, NFS path, and server details follow
19---
20apiVersion: v1
21kind: PersistentVolumeClaim
22metadata:
23  name: jellyfin-videos
24  namespace: media
25spec:
26  accessModes:
27    - ReadWriteOnce
28  resources:
29    requests:
30      storage: 400Gi
31  volumeName: jellyfin-videos
32  storageClassName: ""
33# Persistent Volume Claim spec including access modes, resources requests, and storage class name follow
Apply with:

kubectl apply -f nfs-media-pv-and-pvc.yaml

Download
#

Create nfs-download-pv-and-pvc.yaml:

 1apiVersion: v1
 2kind: PersistentVolume
 3metadata:
 4  name: qbitt-download
 5spec:
 6  capacity:
 7    storage: 400Gi
 8  accessModes:
 9    - ReadWriteOnce
10  nfs:
11    path: /volume1/server/k3s/media/download
12    server: storage.merox.cloud
13  persistentVolumeReclaimPolicy: Retain
14  mountOptions:
15    - hard
16    - nfsvers=3
17  storageClassName: ""
18# Persistent Volume spec including capacity, access modes, NFS path, and server details follow
19---
20apiVersion: v1
21kind: PersistentVolumeClaim
22metadata:
23  name: qbitt-download
24  namespace: media
25spec:
26  accessModes:
27    - ReadWriteOnce
28  resources:
29    requests:
30      storage: 400Gi
31  volumeName: qbitt-download
32  storageClassName: ""
33# Persistent Volume Claim spec including access modes, resources requests, and storage class name follow

Apply with:

kubectl apply -f nfs-download-pv-and-pvc.yaml

Configuring Longhorn PVC for Each Application
#

Create app-config-pvc.yaml:

 1apiVersion: v1
 2kind: PersistentVolumeClaim
 3metadata:
 4  name: app # radarr for example
 5  namespace: media
 6spec:
 7  accessModes:
 8    - ReadWriteOnce
 9  storageClassName: longhorn
10  resources:
11    requests:
12      storage: 5Gi
13# Persistent Volume Claim spec including access modes, storage class name, and resources requests follow

Apply with:

kubectl apply -f app-config-pvc.yaml

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. Here’s how to deploy it

Create specific yaml for each file, for example: radarr-deployment.yaml Apply with

kubectl apply -f radarr-deployment.yaml
 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: jellyfin
 5  namespace: media
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: jellyfin
11  template:
12    metadata:
13      labels:
14        app: jellyfin
15    spec:
16      containers:
17      - name: jellyfin
18        image: jellyfin/jellyfin
19        volumeMounts:
20        - name: config
21          mountPath: /config
22        - name: videos
23          mountPath: /data/videos
24        ports:
25        - containerPort: 8096
26      volumes:
27      - name: config
28        persistentVolumeClaim:
29          claimName: jellyfin-config
30      - name: videos
31        persistentVolumeClaim:
32          claimName: jellyfin-videos

Sonarr
#

Sonarr automates TV show downloads, managing our series collection efficiently.

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: sonarr
 5  namespace: media
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: sonarr
11  template:
12    metadata:
13      labels:
14        app: sonarr
15    spec:
16      containers:
17      - name: sonarr
18        image: linuxserver/sonarr
19        env:
20        - name: PUID
21          value: "1057"
22        - name: PGID
23          value: "1056"
24        volumeMounts:
25        - name: config
26          mountPath: /config
27        - name: videos
28          mountPath: /tv
29        - name: downloads
30          mountPath: /downloads
31        ports:
32        - containerPort: 8989
33      volumes:
34      - name: config
35        persistentVolumeClaim:
36          claimName: sonarr-config
37      - name: videos
38        persistentVolumeClaim:
39          claimName: jellyfin-videos
40      - name: downloads
41        persistentVolumeClaim:
42          claimName: qbitt-download 

Radarr
#

Radarr works like Sonarr but focuses on movies, keeping our film library organized and up-to-date.

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: radarr
 5  namespace: media
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: radarr
11  template:
12    metadata:
13      labels:
14        app: radarr
15    spec:
16      containers:
17      - name: radarr
18        image: linuxserver/radarr
19        env:
20        - name: PUID
21          value: "1057"  
22        - name: PGID
23          value: "1056"  
24        volumeMounts:
25        - name: config
26          mountPath: /config
27        - name: videos
28          mountPath: /movies
29        - name: downloads
30          mountPath: /downloads
31        ports:
32        - containerPort: 7878
33      volumes:
34      - name: config
35        persistentVolumeClaim:
36          claimName: radarr-config
37      - name: videos
38        persistentVolumeClaim:
39          claimName: jellyfin-videos
40      - name: downloads
41        persistentVolumeClaim:
42          claimName: qbitt-download 

Jackett
#

Jackett acts as a bridge between torrent search engines and our media management tools, enhancing their capabilities.

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: jackett
 5  namespace: media
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: jackett
11  template:
12    metadata:
13      labels:
14        app: jackett
15    spec:
16      containers:
17      - name: jackett
18        image: linuxserver/jackett
19        env:
20        - name: PUID
21          value: "1057" 
22        - name: PGID
23          value: "1056" 
24        volumeMounts:
25        - name: config
26          mountPath: /config
27        ports:
28        - containerPort: 9117
29      volumes:
30      - name: config
31        persistentVolumeClaim:
32          claimName: jackett-config

qBittorrent
#

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: qbittorrent
 5  namespace: media
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: qbittorrent
11  template:
12    metadata:
13      labels:
14        app: qbittorrent
15    spec:
16      containers:
17      - name: qbittorrent
18        image: linuxserver/qbittorrent
19        resources:
20          limits:
21            memory: "2Gi"
22          requests:
23            memory: "512Mi"
24        env:
25        - name: PUID
26          value: "1057" 
27        - name: PGID
28          value: "1056"  
29        volumeMounts:
30        - name: config
31          mountPath: /config
32        - name: downloads
33          mountPath: /downloads
34        ports:
35        - containerPort: 8080
36      volumes:
37      - name: config
38        persistentVolumeClaim:
39          claimName: qbitt-config
40      - name: downloads
41        persistentVolumeClaim:
42          claimName: qbitt-download  

qBittorrent with Gluetun
#

 1apiVersion: apps/v1
 2kind: Deployment
 3metadata:
 4  name: qbittorrent
 5  namespace: media
 6spec:
 7  replicas: 1
 8  selector:
 9    matchLabels:
10      app: qbittorrent
11  template:
12    metadata:
13      labels:
14        app: qbittorrent
15    spec:
16      containers:
17        - name: qbittorrent
18          image: linuxserver/qbittorrent
19          resources:
20            limits:
21              memory: "2Gi"
22            requests:
23              memory: "512Mi"
24          env:
25           - name: PUID
26             value: "1057"
27           - name: PGID
28             value: "1056"
29          volumeMounts:
30            - name: config
31              mountPath: /config
32            - name: downloads
33              mountPath: /downloads
34          ports:
35            - containerPort: 8080
36
37        - name: gluetun
38          image: qmcgaw/gluetun
39          env:
40            - name: VPNSP
41              value: "protonvpn"
42            - name: OPENVPN_USER
43              valueFrom:
44                secretKeyRef:
45                  name: protonvpn-secrets
46                  key: PROTONVPN_USER
47            - name: OPENVPN_PASSWORD
48              valueFrom:
49                secretKeyRef:
50                  name: protonvpn-secrets
51                  key: PROTONVPN_PASSWORD
52            - name: COUNTRY
53              value: "Germany" 
54          securityContext:
55            capabilities:
56              add:
57                - NET_ADMIN
58          volumeMounts:
59            - name: gluetun-config
60              mountPath: /gluetun
61
62      volumes:
63        - name: config
64          persistentVolumeClaim:
65            claimName: qbitt-config
66        - name: downloads
67          persistentVolumeClaim:
68            claimName: qbitt-download
69        - name: gluetun-config
70          persistentVolumeClaim:
71            claimName: gluetun-config
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.

To set this up, we create a app-service.yaml for each application (taking Radarr as an example here):

create app-service.yaml

 1apiVersion: v1
 2kind: Service
 3metadata:
 4  name: app #radarr for example 
 5  namespace: media
 6spec:
 7  type: ClusterIP
 8  ports:
 9    - port: 80
10      targetPort: 7878
11  selector:
12    app: app #radarr for example 
kubectl apply -f app-service.yaml

Creating middleware for Traefik
#

For enhanced security and to ensure smooth functioning with Traefik, we define middleware:

  • The middleware, named default-headers-media, is configured in the media namespace.
  • It sets various security headers, including XSS protection and options to prevent MIME sniffing, among others.

Create default-headers-media.yaml

 1apiVersion: traefik.containo.us/v1alpha1
 2kind: Middleware
 3metadata:
 4  name: default-headers-media
 5  namespace: media
 6spec:
 7  headers:
 8    browserXssFilter: true
 9    contentTypeNosniff: true
10    forceSTSHeader: true
11    stsIncludeSubdomains: true
12    stsPreload: true
13    stsSeconds: 15552000
14    customFrameOptionsValue: SAMEORIGIN
15    customRequestHeaders:
16      X-Forwarded-Proto: https

Apply with:

kubectl apply -f default-headers-media.yaml

Creating Ingress Route for Each Application
#

To expose each application securely, we create IngressRoutes using Traefik:

  • An IngressRoute for the application (such as Radarr) is defined, which uses the traefik-external ingress class.
  • It listens on the websecure entry point and routes traffic based on the host (movies.merox.cloud in this example, replace with your domain).
  • The middleware default-headers-media is applied to enhance security.
  • TLS configuration is included, referencing a secret that contains the SSL/TLS certificate.

Create app-ingress-route.yaml

 1apiVersion: traefik.containo.us/v1alpha1
 2kind: IngressRoute
 3metadata:
 4  name: app #radarr for example 
 5  namespace: media
 6  annotations:
 7    kubernetes.io/ingress.class: traefik-external
 8spec:
 9  entryPoints:
10    - websecure
11  routes:
12    - match: Host(`movies.merox.cloud`) # change to your domain
13      kind: Rule
14      services:
15        - name: app #radarr for example 
16          port: 80
17    - match: Host(`movies.merox.cloud`) # change to your domain
18      kind: Rule
19      services:
20        - name: app #radarr for example 
21          port: 80
22      middlewares:
23        - name: default-headers-media
24  tls:
25    secretName: mycert-tls # change to your cert name

Apply with:

kubectl apply -f app-ingress-route.yaml

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 :)

This concludes the necessary steps and configurations to deploy a resilient media server in a Kubernetes cluster successfully.

Manifest files
#

Just copy and deploy all you need in no time

All manifest files 🔗