Upload files to "/"
This commit is contained in:
603
jellyfin-kubernetes-sop.md
Normal file
603
jellyfin-kubernetes-sop.md
Normal file
@@ -0,0 +1,603 @@
|
||||
# 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
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
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.top` points to the NPM entry point.
|
||||
- A `jellyfin` Linux user exists on the storage node with UID/GID
|
||||
`1001:1001`.
|
||||
|
||||
## 3. Storage Node Directories
|
||||
|
||||
On `kurasuta-storage-vm-exp`, use:
|
||||
|
||||
``` text
|
||||
/home/jellyfin/jellyfin/
|
||||
├── config/
|
||||
├── downloads/
|
||||
└── media/
|
||||
├── movies/
|
||||
└── series/
|
||||
```
|
||||
|
||||
Verify identity and directory ownership:
|
||||
|
||||
``` bash
|
||||
id jellyfin
|
||||
ls -ld /home/jellyfin/jellyfin /home/jellyfin/jellyfin/*
|
||||
```
|
||||
|
||||
Expected Jellyfin identity:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
docker.io/jellyfin/jellyfin:latest
|
||||
```
|
||||
|
||||
Run Jellyfin as the storage user's UID/GID:
|
||||
|
||||
``` yaml
|
||||
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:
|
||||
|
||||
``` text
|
||||
System.UnauthorizedAccessException: Access to the path '/config/log' is denied.
|
||||
Permission denied
|
||||
```
|
||||
|
||||
## 6. Scheduling on the Storage Node
|
||||
|
||||
Pin Jellyfin to the storage node:
|
||||
|
||||
``` yaml
|
||||
nodeSelector:
|
||||
kubernetes.io/hostname: kurasuta-storage-vm-exp
|
||||
```
|
||||
|
||||
The node has this taint:
|
||||
|
||||
``` text
|
||||
storage=true:NoSchedule
|
||||
```
|
||||
|
||||
Therefore the pod requires a matching toleration:
|
||||
|
||||
``` yaml
|
||||
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:
|
||||
|
||||
``` yaml
|
||||
volumes:
|
||||
- name: media
|
||||
hostPath:
|
||||
path: /home/jellyfin/jellyfin/media
|
||||
type: Directory
|
||||
- name: downloads
|
||||
hostPath:
|
||||
path: /home/jellyfin/jellyfin/downloads
|
||||
```
|
||||
|
||||
Container mounts:
|
||||
|
||||
``` yaml
|
||||
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:
|
||||
|
||||
``` text
|
||||
Service type: NodePort
|
||||
Service port: 8096
|
||||
NodePort: 31096
|
||||
```
|
||||
|
||||
Expected service:
|
||||
|
||||
``` text
|
||||
jellyfin NodePort <ClusterIP> <none> 8096:31096/TCP
|
||||
```
|
||||
|
||||
Traffic path:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` bash
|
||||
helm lint -f override_values.yaml .
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
``` text
|
||||
1 chart(s) linted, 0 chart(s) failed
|
||||
```
|
||||
|
||||
Render the chart before installation and inspect it:
|
||||
|
||||
``` bash
|
||||
helm template jellyfin -f override_values.yaml . > jellyfin.yaml
|
||||
```
|
||||
|
||||
Verify the rendered Deployment contains:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` bash
|
||||
kubectl create namespace jellyfin
|
||||
```
|
||||
|
||||
Install/upgrade the release:
|
||||
|
||||
``` bash
|
||||
helm upgrade --install jellyfin \
|
||||
--namespace jellyfin \
|
||||
-f override_values.yaml \
|
||||
.
|
||||
```
|
||||
|
||||
Helm release status must include the namespace:
|
||||
|
||||
``` bash
|
||||
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:
|
||||
|
||||
``` bash
|
||||
kubectl get pods -n jellyfin -o wide
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
``` text
|
||||
READY STATUS RESTARTS
|
||||
1/1 Running 0
|
||||
```
|
||||
|
||||
Confirm it is scheduled on:
|
||||
|
||||
``` text
|
||||
kurasuta-storage-vm-exp
|
||||
```
|
||||
|
||||
Check PVCs:
|
||||
|
||||
``` bash
|
||||
kubectl get pvc -n jellyfin
|
||||
```
|
||||
|
||||
Both config and cache claims should be `Bound`.
|
||||
|
||||
If the pod crashes:
|
||||
|
||||
``` bash
|
||||
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:
|
||||
|
||||
``` bash
|
||||
kubectl get svc -n jellyfin
|
||||
```
|
||||
|
||||
Check EndpointSlice:
|
||||
|
||||
``` bash
|
||||
kubectl get endpointslices.discovery.k8s.io -n jellyfin
|
||||
```
|
||||
|
||||
The Jellyfin endpoint should point to the pod on port `8096`.
|
||||
|
||||
Test ClusterIP:
|
||||
|
||||
``` bash
|
||||
curl -v --max-time 5 http://<JELLYFIN_CLUSTER_IP>:8096/
|
||||
```
|
||||
|
||||
Test NodePort:
|
||||
|
||||
``` bash
|
||||
curl -v --max-time 5 http://10.20.30.32:31096/
|
||||
```
|
||||
|
||||
A successful Jellyfin root request can return:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
/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:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` sshconfig
|
||||
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:
|
||||
|
||||
``` powershell
|
||||
ssh storage-node
|
||||
```
|
||||
|
||||
Once authentication is working, copy a movie:
|
||||
|
||||
``` powershell
|
||||
scp "C:\Movies\movie.mkv" storage-node:/home/jellyfin/jellyfin/media/movies/
|
||||
```
|
||||
|
||||
Or a directory:
|
||||
|
||||
``` powershell
|
||||
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:
|
||||
|
||||
``` text
|
||||
/home/jellyfin/jellyfin/media/movies/
|
||||
```
|
||||
|
||||
open Jellyfin:
|
||||
|
||||
``` text
|
||||
Dashboard -> Libraries -> Add Media Library
|
||||
```
|
||||
|
||||
For movies:
|
||||
|
||||
``` text
|
||||
Content type: Movies
|
||||
Display name: Movies
|
||||
Folder: /media/movies
|
||||
```
|
||||
|
||||
For series:
|
||||
|
||||
``` text
|
||||
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:
|
||||
|
||||
``` text
|
||||
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
|
||||
|
||||
``` text
|
||||
[✓] 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
|
||||
```
|
||||
Reference in New Issue
Block a user