Jellyfin Media Server Setup on Kubernetes

Install the k8s cluster if not alreadly installed

1
2
microk8s enable dns storage helm3
microk8s status

Add the Truecharts Repo

1
microk8s helm repo add truecharts https://charts.truecharts.org/

Pull the helm chart

1
microk8s helm pull truecharts/jellyfin 

To enable DLNA we need to enable access to the host network.

Host Access addon

1
microk8s enable host-access:ip=A.B.C.D

Modify the values.yaml file to set the configuration

1
2
3
tar -xvf ./jellyfin-*.tgz
cd jellyfin
cat values.yamls

Make the data directory

APPLICATION_ROOT_DIRECTORY: Path where the application config and data files are stored

1
2
3
4
mkdir -p APPLICATION_ROOT_DIRECTORY/config
mkdir -p APPLICATION_ROOT_DIRECTORY/data
sudo chown -R 777 APPLICATION_ROOT_DIRECTORY

Create Persistent Volumes and Persistent Volume Claims

Persistent Volumes

Config

jellyfin-config-pv.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-config-pv
spec:
capacity:
storage: 100Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: APPLICATION_ROOT_DIRECTORY/config # This must exist on the host
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- dionysus
1
microk8s kubectl apply -f jellyfin-config-pv.yaml

Data

jellyfin-data-pv.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: v1
kind: PersistentVolume
metadata:
name: jellyfin-data-pv
spec:
capacity:
storage: 5Ti
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: APPLICATION_ROOT_DIRECTORY/data # This must exist on the host
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- dionysus
1
microk8s kubectl apply -f jellyfin-data-pv.yaml
1
microk8s kubectl get pv 
1
2
3
NAME                               CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                                       STORAGECLASS    REASON   AGE
jellyfin-config-pv 100Gi RWO Retain Bound default/jellyfin-config-pvc local-storage 98s
jellyfin-data-pv 5Ti RWO Retain Bound default/jellyfin-data-pvc local-storage 78s

Persistent Volume Claims

Config

jellyfin-config-pvc.yaml
Bonds to jellyfin-config-pv

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-config-pvc
spec:
storageClassName: local-storage # Empty string must be explicitly set otherwise default StorageClass will be set
accessModes:
- ReadWriteOnce
volumeName: jellyfin-config-pv
resources:
requests:
storage: 100Gi
1
microk8s kubectl apply -f jellyfin-config-pvc.yaml

Data

jellyfin-data-pvc.yaml
Bonds to jellyfin-data-pv

1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-data-pvc
spec:
storageClassName: local-storage # Empty string must be explicitly set otherwise default StorageClass will be set
accessModes:
- ReadWriteOnce
volumeName: jellyfin-data-pv
resources:
requests:
storage: 5Ti
1
microk8s kubectl apply -f jellyfin-data-pvc.yaml
1
microk8s kubectl get pvc
1
2
3
NAME                                STATUS   VOLUME                             CAPACITY   ACCESS MODES   STORAGECLASS    AGE
jellyfin-config-pvc Bound jellyfin-config-pv 100Gi RWO local-storage 26s
jellyfin-data-pvc Bound jellyfin-data-pv 5Ti RWO local-storage 12s

Modify the values.yaml file to set the configuration

We’re going to set the jellyfin configuration within the container.

1
2
cd jellyfin
nano values.yaml

DLNA_PORT: Port the dlna server will listen on
AUTO_DISCOVER_PORT: DLNA autodiscover port
WEB_GUI_PORT: Port to access the web gui

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
image:
repository: tccr.io/truecharts/jellyfin
pullPolicy: IfNotPresent
tag: v10.8.10@sha256:d2c377ee7ea463110a1dda7eb1b231424ad05245aaf95744e76164bd2e593377
broadcastProxyImage:
repository: tccr.io/truecharts/socat
pullPolicy: IfNotPresent
tag: v1.7.4.4@sha256:2417f121a5fd08012c927bfb7cdedb00ce97e0ec53fbf38352be8c6f197f7294
service:
main:
ports:
main:
port: WEB_GUI_PORT
targetPort: WEB_GUI_PORT
autodiscovery:
enabled: true
ports:
autodiscovery:
enabled: true
protocol: udp
port: AUTO_DISCOVER_PORT
hostPort: AUTO_DISCOVER_PORT
hostIP: A.B.C.D
dlna:
enabled: true
ports:
dlna:
enabled: true
protocol: udp
port: DLNA_PORT
hostPort: DLNA_PORT
hosstIP: A.B.C.D

persistence:
config:
enabled: true
mountPath: "/config"
type: pvc
existingClaim: jellyfin-config-pvc
cache:
enabled: true
mountPath: "/cache"
type: "emptyDir"
transcode:
enabled: true
mountPath: "/config/transcodes"
type: "emptyDir"
media:
enabled: true
mountPath: /media
type: pvc
existingClaim: jellyfin-data-pvc
portal:
open:
enabled: true
securityContext:
container:
readOnlyRootFilesystem: false
workload:
main:
podSpec:
hostNetwork: true
containers:
main:
env:
JELLYFIN_PublishedServerUrl: "{{ $.Values.chartContext.APPURL }}"
broadcastproxy:
enabled: false
type: DaemonSet
podSpec:
hostNetwork: true
# Proxy doesn't seem to respect the TERM signal, so by default
# this ends up just hanging until the default grace period ends.
# This is unnecesary since this workload only proxies autodiscovery
# messages.
terminationGracePeriodSeconds: 3
containers:
broadcastproxy:
enabled: true
primary: true
imageSelector: broadcastProxyImage
securityContext:
readOnlyRootFilesystem: true
command: ["/bin/sh"]
# Quite a lot going on here:
# - Resolve Jellyfin's autodiscovery service IP from its FQDN via getent hosts
# - Export the IP to `$TARGET_IP`
# - Check `$TARGET_IP` is not empty (so we can crash if it is - will help to detect templating errors)
# - Touch `/tmp/healty` to use with the readiness, liveness and startup probes
# - Start socat in proxy mode
# - On exit remove `/tmp/healthy`
args: ["-c", "export TARGET_IP=$(getent hosts '{{ printf \"%v-autodiscovery\" (include \"tc.v1.common.lib.chart.names.fullname\" $) }}' | awk '{ print $1 }') && [[ ! -z $TARGET_IP ]] && touch /tmp/healthy && socat UDP-LISTEN:AUTO_DISCOVER_PORT,fork,reu>
probes:
readiness:
enabled: true
type: exec
command:
- cat
- /tmp/healthy
liveness:
enabled: true
type: exec
command:
- cat
- /tmp/healthy
startup:
enabled: true
type: exec
command:
- cat
- /tmp/healthy

# -- enable Jellyfin autodiscovery on LAN
autodiscovery:
enabled: true

Install the helm chart with our values.yaml file

1
microk8s helm install jellyfin truecharts/jellyfin --values ./values.yaml 

Check the status of the installed application.

1
2
microk8s status
microk8s kubectl describe pods

Common Errors

Error from server (BadRequest): container in pod is waiting to start: ContainerCreating
You probably need to change the permissions on the PV directory. This path is what is written in the PersistentVolume in the path variable. A quick chmod -R 777 to this path will most likely fix the issue. The conatiner should update the permissions once it runs.

Service yaml file

Since we are using the hostnetwork we don’t need the an additional service file to route information.

More information here
https://medium.com/swlh/kubernetes-external-ip-service-type-5e5e9ad62fcd

Firewall Rules

This assumes you are using ufw.
ufw is bascally a wrapper for IPTABLES. If you have ever used IPTABLES before you understand why ufw exists.
https://docs.syncthing.net/users/firewall.html

1
sudo ufw default allow routed 
1
2
3
sudo ufw allow from A.B.C.0/24 to any port WEB_GUI_PORT proto tcp
sudo ufw allow from A.B.C.0/24 to any port AUTO_DISCOVER_PORT proto udp
sudo ufw allow from A.B.C.0/24 to any port DLNA_PORT proto udp
1
sudo ufw status

References

https://kubernetes.io/docs/concepts/services-networking/service/