↓ Skip to main content

Jellyfin

Jellyfin is the household open-source media server. It is installed only by Argo CD Application jellyfin, which pins the official Helm chart jellyfin at 3.2.0 (image tag 10.11.8) into namespace jellyfin. The GitOps file shape is the usual Helm app: public companion manifests, public values, a private overlay, and the upstream chart. Remote browser access stays on the existing Cloudflare Tunnel, the same way Immich is reached. The in-cluster origin is still jellyfin.jellyfin.svc.cluster.local:8096. A temporary MetalLB VIP on the home LAN is an extra path for apps that cannot complete an Access challenge.

This page sits with the other application guides. Site hostnames and NAS coordinates stay in homelabs-private/clusters/<cluster>/.

GitOps
#

Application jellyfin is multi-source. Public homelabs/jellyfin/manifests apply the Longhorn config claim. Private clusters/<cluster>/manifests/jellyfin apply the NFS media volume. Helm merges $homelabs/jellyfin/values.yaml with $private/clusters/<cluster>/overlays/jellyfin/values.yaml onto chart jellyfin 3.2.0 from https://jellyfin.github.io/jellyfin-helm. Sync-wave is 1; CreateNamespace is on.

PieceWhereRole
Application CRclusters/<cluster>/argocd/applications/jellyfin.yamlMulti-source app
Public valueshomelabs/jellyfin/values.yamlClusterIP :8096, claim names, Recreate, metrics off
Private overlayclusters/<cluster>/overlays/jellyfin/values.yamlTemporary LoadBalancer :8096 + MetalLB VIP; Cloudflare still uses in-cluster DNS
Config PVChomelabs/jellyfin/manifests/pvc-config.yamljellyfin-config-pvc-lh
Media PV/PVCclusters/<cluster>/manifests/jellyfin/jellyfin-media-pvc on nfs-csi

Public values set fullnameOverride: jellyfin, one replica, serviceAccount.automount: false, ingress and HTTPRoute off, and DLNA off. They leave metrics.enabled and metrics.serviceMonitor.enabled false: Jellyfin core has no Prometheus /metrics without a plugin, so a ServiceMonitor would scrape-fail. Cache is not a PVC (persistence.cache.enabled: false). The same file requests 250m CPU / 1Gi memory and limits 2 CPU / 4Gi.

AppProject homelab must allow the official chart repo and destination namespace jellyfin. Apply the live project before the Application is committed:

kubectl apply -f homelabs-private/argocd/projects/homelab.yaml

Day-to-day changes go through Git and an Argo sync. Do not helm install Jellyfin from a workstation. Pin stays 3.2.0; treat a 3.3.x chart bump as a new change.

Public hostname
#

The connector is the existing cloudflared Deployment in namespace cloudflare-tunnel. You do not add a second tunnel, and you do not edit homelabs/cloudflare/. In Zero Trust, add a Public Hostname on that tunnel:

FieldValue
Subdomainjellyfin (published as jellyfin.<your-domain>)
TypeHTTP
URLhttp://jellyfin.jellyfin.svc.cluster.local:8096
Cloudflare AccessOn

Put Access in front of this hostname before it answers. Use an email allow-list or an existing household identity group, not Everyone, and not an empty policy. Dex and Grafana stay Access-off because they are an identity issuer and its callback; Jellyfin is neither, so Access stays on after first-run as well. Clients that cannot complete an Access challenge (some official TV apps) are a later review, not a reason to turn Access off here.

Dashboard route changes apply without restarting cloudflared. The connector itself is documented under Cloudflare Tunnel. Changing the Service to LoadBalancer does not change this URL; the ClusterIP name still answers inside the cluster.

Temporary LAN
#

Temporary LAN (operator bridge): The private overlay may use MetalLB 192.168.68.231 (LoadBalancer + metallb.universe.tf/loadBalancerIPs) so LAN clients can open http://192.168.68.231:8096 without Cloudflare Access. Treat that VIP as disposable. Keep the Public Hostname and Access policy; do not turn Access off to satisfy a TV app. When you no longer need the LAN VIP, return the overlay to ClusterIP :8096 and drop the MetalLB annotation.

first-pool must include that address. The bootstrap range was .220–.230 and was already full; expand the live pool to .220–.231 before the Application syncs a LoadBalancer, and do not DHCP-reserve MetalLB VIPs.

First-run admin
#

Anyone who can hit :8096 before an admin exists becomes the owner. Finish the native wizard on a path that is not public, then publish the hostname with Access already attached.

  1. Sync Application jellyfin and confirm Service jellyfin has Ready endpoints. For first-run, prefer a port-forward so the wizard is not on a LAN VIP. There is no Public Hostname yet.
  2. Forward the Service and open the wizard on your workstation:
kubectl -n jellyfin port-forward svc/jellyfin 8096:8096
  1. Create the first admin. Disable open registration in Jellyfin before you continue.
  2. Then create the Public Hostname above, with Access already on.

Do not run the wizard on a world-open origin, and do not create the hostname before Access exists.

There is no ExternalSecret, Vault path, Dex client, or in-app Google SSO in v1. Access at the Cloudflare edge is the identity gate; the admin password lives in config on the Longhorn claim after the wizard.

Storage
#

Config state is claim jellyfin-config-pvc-lh: longhorn-ssd, ReadWriteOnce, 5Gi, GitOps from homelabs/jellyfin/manifests/pvc-config.yaml and Helm persistence.config.existingClaim. The Deployment strategy is Recreate so that single RWO volume can remount after a replace. RollingUpdate plus RWO deadlocks.

Media is a new claim jellyfin-media-pvc in namespace jellyfin (nfs-csi, ReadWriteMany, 1Ti). The private PersistentVolume points at the household NAS media export. Plex was removed; media is Jellyfin and Navidrome on that existing share. You do not remount another app’s PVC into this namespace. PVCs are namespaced; a second claim on the same export shares files without colocating the apps. Navidrome mounts its own claim read-only and indexes the music subdirectory. NAS server and share belong only in clusters/<cluster>/manifests/jellyfin/, not in public git and not on this page.

Keep library metadata on the config PVC. Leave media-folder NFO and sidecars off so two scanners do not write into the shared tree.

Confirm both claims after sync:

kubectl -n jellyfin get pvc

jellyfin-config-pvc-lh should be Bound on longhorn-ssd and jellyfin-media-pvc Bound on nfs-csi.

Deploy
#

Commit order is homelabs (feature branch + PR so jellyfin/ exists on main) → homelabs-private on main (AppProject apply first, then overlay, media volume, Application) → this docs site. Parent app-of-apps already watches argocd/applications/.

After sync, the Application should be Synced/Healthy and the pod Ready:

kubectl -n argocd get application jellyfin
kubectl -n jellyfin get svc jellyfin
kubectl -n jellyfin get pods

While the temporary LAN overlay is in place, Service jellyfin is LoadBalancer on port 8096 with EXTERNAL-IP 192.168.68.231. The Cloudflare origin remains http://jellyfin.jellyfin.svc.cluster.local:8096.

Smoke
#

v1 health is pod Ready plus the chart’s GET /health. After the hostname exists, sign in through Access at https://jellyfin.<your-domain>, then request https://jellyfin.<your-domain>/health. A 2xx after you pass Access is enough; an Access challenge before that is expected. A Cloudflare 502 means the origin Service or pod (the same signal as Immich and Grafana), so check svc/jellyfin and the Deployment, not a VIP.

There is no ServiceMonitor, PrometheusRule, Grafana dashboard, or Blackbox Probe for Jellyfin in v1. kube-prometheus-stack already watches pod Ready and the Longhorn config volume.

Rollback
#

Disable or delete Application jellyfin, or scale the Deployment to 0. Config remains on jellyfin-config-pvc-lh until you delete that claim. Media files stay on the NAS (Retain). Remove the Zero Trust Public Hostname and its Access application. Leave cloudflared alone.