Skip to main content
Overview

Deploying a Kubernetes-Based Media Server

5 min read

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

ApplicationRole
JellyfinMedia streaming
RadarrMovie library management
SonarrTV show management
JackettTorrent indexer proxy
qBittorrentDownload client
GluetunVPN 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.

NFS rule configuration on Synology NAS

PersistentVolumes and PVCs

Media Storage

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

apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-videos
spec:
capacity:
storage: 400Gi
accessModes:
- ReadWriteOnce
nfs:
path: /volume1/server/k3s/media
server: storage.merox.cloud
persistentVolumeReclaimPolicy: Retain
mountOptions:
- hard
- nfsvers=3
storageClassName: ""
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-videos
namespace: media
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 400Gi
volumeName: jellyfin-videos
storageClassName: ""
Terminal window
kubectl apply -f nfs-media-pv-and-pvc.yaml

Download Storage

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

apiVersion: v1
kind: PersistentVolume
metadata:
name: qbitt-download
spec:
capacity:
storage: 400Gi
accessModes:
- ReadWriteOnce
nfs:
path: /volume1/server/k3s/media/download
server: storage.merox.cloud
persistentVolumeReclaimPolicy: Retain
mountOptions:
- hard
- nfsvers=3
storageClassName: ""
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: qbitt-download
namespace: media
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 400Gi
volumeName: qbitt-download
storageClassName: ""
Terminal window
kubectl apply -f nfs-download-pv-and-pvc.yaml

Longhorn PVC for App Configs

Create app-config-pvc.yaml (repeat for each app):

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app # radarr for example
namespace: media
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
Terminal window
kubectl apply -f app-config-pvc.yaml
Danger

You need a separate PVC for each application: Jellyfin, Sonarr, Radarr, Jackett, and qBittorrent.

Deployments

Jellyfin

Create jellyfin-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: jellyfin
namespace: media
spec:
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-videos
Terminal window
kubectl apply -f jellyfin-deployment.yaml

Sonarr

apiVersion: apps/v1
kind: Deployment
metadata:
name: sonarr
namespace: media
spec:
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-download

Radarr

apiVersion: apps/v1
kind: Deployment
metadata:
name: radarr
namespace: media
spec:
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-download

Jackett

apiVersion: apps/v1
kind: Deployment
metadata:
name: jackett
namespace: media
spec:
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-config

qBittorrent (standalone)

apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: media
spec:
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-download

qBittorrent 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/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: media
spec:
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-config
Note

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: v1
kind: Service
metadata:
name: app # radarr for example
namespace: media
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 7878
selector:
app: app # radarr for example
Terminal window
kubectl apply -f app-service.yaml

Traefik Middleware

Create default-headers-media.yaml:

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: default-headers-media
namespace: media
spec:
headers:
browserXssFilter: true
contentTypeNosniff: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 15552000
customFrameOptionsValue: SAMEORIGIN
customRequestHeaders:
X-Forwarded-Proto: https
Terminal window
kubectl apply -f default-headers-media.yaml

IngressRoutes

Create app-ingress-route.yaml per application:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: app # radarr for example
namespace: media
annotations:
kubernetes.io/ingress.class: traefik-external
spec:
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 name
Terminal window
kubectl apply -f app-ingress-route.yaml
Danger

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:

All manifest files 🔗

Share this post

Loading comments...