13 KiB
Jellyfin on Kubernetes --- Deployment & Operations SOP
Scope: Deploy Jellyfin with Helm on Kubernetes, pin it to the dedicated storage node, persist config/cache on Longhorn, expose it through NodePort/Nginx Proxy Manager, and upload media through a multi-hop SSH path.
1. Final Architecture
User / Browser
|
| HTTPS: media.jutsugen.top
v
Nginx Proxy Manager
|
| HTTP -> Kubernetes NodeIP:31096
v
NodePort Service
|
v
Jellyfin Pod :8096
|
+-- /config -> Longhorn PVC (5Gi)
+-- /cache -> Longhorn PVC (10Gi)
+-- /media -> storage-node hostPath (read-only)
+-- /downloads -> storage-node hostPath
Dedicated node:
kurasuta-storage-vm-exp
Internal IP: 10.20.30.32
Label: kubernetes.io/hostname=kurasuta-storage-vm-exp
Taint: storage=true:NoSchedule
2. Prerequisites
- Working Kubernetes cluster and Helm.
- Longhorn StorageClass available (
longhorn). - Storage node labelled and tainted as shown above.
- Nginx Proxy Manager can reach a Kubernetes node IP.
- DNS record for
media.jutsugen.toppoints to the NPM entry point. - A
jellyfinLinux user exists on the storage node with UID/GID1001:1001.
3. Storage Node Directories
On kurasuta-storage-vm-exp, use:
/home/jellyfin/jellyfin/
├── config/
├── downloads/
└── media/
├── movies/
└── series/
Verify identity and directory ownership:
id jellyfin
ls -ld /home/jellyfin/jellyfin /home/jellyfin/jellyfin/*
Expected Jellyfin identity:
uid=1001(jellyfin) gid=1001(jellyfin)
Host media/download directories should be accessible to that user.
4. Storage Design
Use the following split:
Container path Backend Purpose
/config Longhorn PVC, 5Gi Jellyfin DB, users,
settings, metadata
state
/cache Longhorn PVC, 10Gi Cache/transcoding
working data
/media hostPath Movies and series
/downloads hostPath Downloader staging area
Do not define duplicate config or media volumes. The chart's
built-in persistence configuration and custom volumes must not produce
two volumes with the same name or two mounts on the same path.
5. Pod Security Context
The official Jellyfin image is used:
docker.io/jellyfin/jellyfin:latest
Run Jellyfin as the storage user's UID/GID:
podSecurityContext:
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
fsGroup: 1001 is important for writable Longhorn PVCs. Without it,
Jellyfin may fail with an error similar to:
System.UnauthorizedAccessException: Access to the path '/config/log' is denied.
Permission denied
6. Scheduling on the Storage Node
Pin Jellyfin to the storage node:
nodeSelector:
kubernetes.io/hostname: kurasuta-storage-vm-exp
The node has this taint:
storage=true:NoSchedule
Therefore the pod requires a matching toleration:
tolerations:
- key: storage
operator: Equal
value: "true"
effect: NoSchedule
Without this toleration, the node selector can match but the pod will remain unschedulable.
7. Media and Download hostPaths
The final hostPath mapping is:
volumes:
- name: media
hostPath:
path: /home/jellyfin/jellyfin/media
type: Directory
- name: downloads
hostPath:
path: /home/jellyfin/jellyfin/downloads
Container mounts:
volumeMounts:
- name: media
mountPath: /media
readOnly: true
- name: downloads
mountPath: /downloads
Keeping /media read-only protects source media from accidental
modification/deletion by Jellyfin.
8. Network Exposure
Kubernetes Ingress is not required because Nginx Proxy Manager is the external reverse proxy.
Expose Jellyfin as NodePort:
Service type: NodePort
Service port: 8096
NodePort: 31096
Expected service:
jellyfin NodePort <ClusterIP> <none> 8096:31096/TCP
Traffic path:
NPM -> http://10.20.30.32:31096 -> Service -> Jellyfin Pod:8096
Do not use HTTPS directly against port 31096; this backend is HTTP.
TLS is terminated by Nginx Proxy Manager.
9. Helm Validation Before Deployment
From the chart directory, validate values/chart syntax:
helm lint -f override_values.yaml .
Expected:
1 chart(s) linted, 0 chart(s) failed
Render the chart before installation and inspect it:
helm template jellyfin -f override_values.yaml . > jellyfin.yaml
Verify the rendered Deployment contains:
image: docker.io/jellyfin/jellyfin:latest
runAsUser: 1001
runAsGroup: 1001
fsGroup: 1001
/config -> jellyfin-config PVC
/cache -> jellyfin-cache PVC
/media -> /home/jellyfin/jellyfin/media
/downloads -> /home/jellyfin/jellyfin/downloads
nodeSelector -> kurasuta-storage-vm-exp
toleration -> storage=true:NoSchedule
Also verify there are no duplicate config/media volume names or
mount paths.
10. Deploy Jellyfin
Create the namespace:
kubectl create namespace jellyfin
Install/upgrade the release:
helm upgrade --install jellyfin \
--namespace jellyfin \
-f override_values.yaml \
.
Helm release status must include the namespace:
helm status jellyfin --namespace jellyfin
A Helm status of deployed only confirms resource submission; always
verify the pod separately.
11. Runtime Verification
Check the pod:
kubectl get pods -n jellyfin -o wide
Expected:
READY STATUS RESTARTS
1/1 Running 0
Confirm it is scheduled on:
kurasuta-storage-vm-exp
Check PVCs:
kubectl get pvc -n jellyfin
Both config and cache claims should be Bound.
If the pod crashes:
kubectl describe pod -n jellyfin <pod-name>
kubectl logs -n jellyfin <pod-name>
The permission issue encountered during this deployment was fixed by
ensuring the Longhorn volume had appropriate group access through the
pod security context (fsGroup).
12. Service and NodePort Verification
Check Service:
kubectl get svc -n jellyfin
Check EndpointSlice:
kubectl get endpointslices.discovery.k8s.io -n jellyfin
The Jellyfin endpoint should point to the pod on port 8096.
Test ClusterIP:
curl -v --max-time 5 http://<JELLYFIN_CLUSTER_IP>:8096/
Test NodePort:
curl -v --max-time 5 http://10.20.30.32:31096/
A successful Jellyfin root request can return:
HTTP/1.1 302 Found
Server: Kestrel
Location: web/
NodePort was also verified through the other cluster node IPs, confirming kube-proxy forwarding was functional.
13. kube-proxy Notes
The cluster's kube-proxy reported:
Using iptables proxy
Using iptables Proxier
The ConfigMap had mode: "", but runtime logs confirmed iptables mode.
Historical kube-proxy logs also contained RBAC forbidden errors for
watching Services, EndpointSlices, Nodes, and ServiceCIDRs. Because
current ClusterIP and NodePort forwarding worked, these errors were not
the Jellyfin blocker, but they should be audited separately as cluster
maintenance.
14. Nginx Proxy Manager
Create a Proxy Host with:
Domain: media.jutsugen.top
Scheme: http
Forward IP: 10.20.30.32
Forward Port: 31096
WebSockets: enabled
First verify HTTP proxying. After the dashboard works through the domain, configure the SSL certificate in NPM and force HTTPS as appropriate.
Final path:
https://media.jutsugen.top
|
v
Nginx Proxy Manager (TLS termination)
|
| HTTP
v
10.20.30.32:31096
|
v
Jellyfin
15. Jellyfin Library Layout
Recommended host layout:
/home/jellyfin/jellyfin/media/
├── movies/
│ ├── Interstellar (2014)/
│ │ └── Interstellar (2014).mkv
│ └── Inception (2010)/
│ └── Inception (2010).mkv
└── series/
└── Breaking Bad/
└── Season 01/
├── Breaking Bad S01E01.mkv
└── Breaking Bad S01E02.mkv
Inside Jellyfin, use container paths---not host paths:
Movies library -> /media/movies
Shows library -> /media/series
Do not configure /home/jellyfin/... in the Jellyfin UI because that
path exists on the Kubernetes node, not inside the container.
16. Manual Media Upload Through Multi-Hop SSH
Network path:
Windows PC
|
v
Jump Server: 36.255.71.187:22001
|
v
Master: 103.209.42.105:22002
|
v
Storage: 10.20.30.32:22
The first jump requires password + MFA. The master is reachable only through the first jump and uses a private key already present on that jump server.
A standard multi-hop ProxyJump from Windows failed because the Windows
SSH client tried to authenticate to the second hop itself. The chosen
pattern was to let Jump-1 execute the second SSH hop and use its
existing key.
Example Windows SSH config:
Host jump1
HostName 36.255.71.187
User mosiur
Port 22001
Host storage-node
HostName 10.20.30.32
User root
Port 22
ProxyCommand ssh jump1 "ssh -p 22002 -W %h:%p mosiur@103.209.42.105"
Test:
ssh storage-node
Once authentication is working, copy a movie:
scp "C:\Movies\movie.mkv" storage-node:/home/jellyfin/jellyfin/media/movies/
Or a directory:
scp -r "C:\Movies\Interstellar (2014)" storage-node:/home/jellyfin/jellyfin/media/movies/
SSH troubleshooting notes
Permission denied (publickey) on the second hop means the network
connection reached the server but authentication failed.
Host key verification failed means server identity verification
failed, not MFA/password validation. Verify host-key fingerprints before
accepting changed/new host keys.
For storage root login, PermitRootLogin yes alone does not guarantee
password authentication. Effective SSH configuration, PAM, password
state, Match blocks, and PasswordAuthentication can still affect
login.
17. Adding Media to Jellyfin
After the file exists on the storage node under:
/home/jellyfin/jellyfin/media/movies/
open Jellyfin:
Dashboard -> Libraries -> Add Media Library
For movies:
Content type: Movies
Display name: Movies
Folder: /media/movies
For series:
Content type: Shows
Display name: Series
Folder: /media/series
Save and run a library scan if required. Test one media file end-to-end before bulk uploading the library.
18. Optional Automated Download Architecture
For an automated workflow, the planned stack can be:
Prowlarr -> Radarr/Sonarr -> qBittorrent -> /downloads
|
v
organized import
|
v
/media/movies or /media/series
|
v
Jellyfin
Deploy and validate one component at a time, starting with the download client. Only use sources/content you are authorized to download.
19. Quick Troubleshooting Matrix
Symptom First check
Pod Pending Node selector, taint/toleration,
PVC binding
/config/log permission denied fsGroup, PVC permissions
Pod CrashLoopBackOff kubectl logs +
kubectl describe pod
Service has no backend EndpointSlice / selector
ClusterIP works, NodePort fails kube-proxy/NodePort path
HTTPS to :31096 gives Use HTTP; TLS terminates at NPM
wrong-version error
Permission denied (publickey) Authentication/key for that hop
over SSH
Host key verification failed known_hosts/host fingerprint
Jellyfin cannot see uploaded movie File location, permissions,
/media/... library path
20. Final State Checklist
[✓] Dedicated storage node selected
[✓] storage=true:NoSchedule tolerated
[✓] Jellyfin runs as UID/GID 1001
[✓] Config persisted on Longhorn
[✓] Cache persisted on Longhorn
[✓] Media mounted from storage node
[✓] Downloads directory mounted
[✓] Jellyfin pod 1/1 Running
[✓] ClusterIP functional
[✓] NodePort 31096 functional
[✓] Nginx Proxy Manager routing functional
[✓] Jellyfin dashboard reachable
[✓] Multi-hop media transfer path established
[→] Create Movies/Series libraries and add media
[ ] Optional automated downloader stack