feat: add Helm chart for jellyfin-ha (#3)

Adds a production-ready Helm chart under deploy/helm/jellyfin-ha/.

Motivated by a community request on Reddit:
https://www.reddit.com/r/JellyfinCommunity/comments/1rvj17f/jellyfin_ha_on_kubernetes_redisbacked_transcode/oav7mlz/

Features:
- StatefulSet with configurable replica count (default 2 for HA)
- Redis subchart (in-cluster) wired to ITranscodeSessionStore via
  Jellyfin__TranscodeStore__RedisConnectionString env var
- Supports external Redis via ha.transcodeStore.existingSecret or
  ha.transcodeStore.redisConnectionString
- Optional in-cluster PostgreSQL StatefulSet (experimental, mirrors
  existing kubernetes/apps/media/jellyfin-postgres.yaml pattern)
- RWX config + transcode PVCs (required for multi-pod session takeover)
- Per-pod cache via volumeClaimTemplates (RWO)
- Optional NFS PV+PVC for media library
- Intel QSV / VA-API GPUgit checkout -b feat/helm-chart && git add deploy/ && legit add deploy/ && git commit -m dagit commit -m featss
This commit is contained in:
ZoltyMat
2026-03-16 23:58:32 -04:00
committed by mat
parent a9aa2d53ed
commit 7d4cef51f5
17 changed files with 1372 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v2
name: jellyfin-ha
description: >
High-availability Jellyfin media server with Redis-backed transcode session
store, lease-aware segment cleanup, and optional PostgreSQL database provider
for multi-pod Kubernetes deployments.
type: application
version: 0.1.0
appVersion: "10.12.0"
keywords:
- jellyfin
- media-server
- high-availability
- redis
- kubernetes
home: https://github.com/ZoltyMat/jellyfin-ha
sources:
- https://github.com/ZoltyMat/jellyfin-ha
maintainers:
- name: ZoltyMat
url: https://github.com/ZoltyMat
icon: https://raw.githubusercontent.com/jellyfin/jellyfin/master/Jellyfin.Server/Resources/Images/jellyfin-icon-solid.png
@@ -0,0 +1,49 @@
1. Jellyfin HA has been deployed.
{{- if eq (int .Values.replicaCount) 1 }}
⚠ replicaCount=1 — running in single-instance mode. Set replicaCount >= 2 and
ha.enabled=true to enable HA transcoding.
{{- else }}
✔ Running {{ .Values.replicaCount }} replicas.
{{- if include "jellyfin-ha.haEnabled" . }}
✔ HA mode: ACTIVE — transcode sessions replicated via Redis.
{{- else }}
⚠ HA mode: INACTIVE — NullTranscodeSessionStore in use.
Set redis.enabled=true (or ha.transcodeStore.redisConnectionString) to enable HA.
{{- end }}
{{- end }}
2. Get the Jellyfin URL:
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
https://{{ .host }}/
{{- end }}
{{- else if .Values.traefikIngressRoute.enabled }}
https://{{ .Values.traefikIngressRoute.host }}/
{{- else }}
Access via port-forward:
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "jellyfin-ha.fullname" . }} 8096:{{ .Values.service.port }}
http://localhost:8096/
{{- end }}
3. PostgreSQL:
{{- if .Values.postgresql.enabled }}
✔ In-cluster PostgreSQL deployed. Ensure the secret "{{ .Values.postgresql.existingSecret }}"
exists in namespace {{ .Release.Namespace }} before starting the server.
{{- else if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
⚠ config.databaseType=Jellyfin-PostgreSQL but postgresql.enabled=false.
Make sure you have an external PostgreSQL and the correct DATABASE_URL env var set.
{{- else }}
Using SQLite (default). Enable postgresql.enabled=true for a shared database backend.
{{- end }}
4. Transcode storage:
The transcode PVC must be ReadWriteMany when replicaCount > 1.
Current accessMode: {{ .Values.persistence.transcode.accessMode }}
{{- if and (gt (int .Values.replicaCount) 1) (ne .Values.persistence.transcode.accessMode "ReadWriteMany") }}
⚠ WARNING: replicaCount > 1 but transcode accessMode is not ReadWriteMany.
Pod B cannot read Pod A's HLS segments during session takeover.
Set persistence.transcode.accessMode=ReadWriteMany or use an NFS / Longhorn RWX PVC.
{{- end }}
@@ -0,0 +1,137 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "jellyfin-ha.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "jellyfin-ha.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart label.
*/}}
{{- define "jellyfin-ha.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "jellyfin-ha.labels" -}}
helm.sh/chart: {{ include "jellyfin-ha.chart" . }}
{{ include "jellyfin-ha.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "jellyfin-ha.selectorLabels" -}}
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
{{- end }}
{{/*
Service account name.
*/}}
{{- define "jellyfin-ha.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "jellyfin-ha.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Fully qualified name of the in-cluster Redis service.
*/}}
{{- define "jellyfin-ha.redis.fullname" -}}
{{- printf "%s-redis" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{/*
Fully qualified name of the in-cluster PostgreSQL service.
*/}}
{{- define "jellyfin-ha.postgres.fullname" -}}
{{- printf "%s-postgres" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{/*
Compute the Redis connection string.
Priority:
1. existingSecret (mounted as env var in the statefulset template)
2. explicit ha.transcodeStore.redisConnectionString value
3. auto-compose from the in-cluster Redis service name when redis.enabled=true
Returns empty string if none of the above apply (= single-instance / NullStore mode).
This helper returns the literal string only for cases 2 and 3; case 1 is handled
directly in the container env block via secretKeyRef.
*/}}
{{- define "jellyfin-ha.redisConnectionString" -}}
{{- if .Values.ha.transcodeStore.redisConnectionString }}
{{- .Values.ha.transcodeStore.redisConnectionString }}
{{- else if .Values.redis.enabled }}
{{- printf "%s:6379,abortConnect=false" (include "jellyfin-ha.redis.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Return true if HA mode is active and Redis should be wired up.
*/}}
{{- define "jellyfin-ha.haEnabled" -}}
{{- if and .Values.ha.enabled (or .Values.redis.enabled .Values.ha.transcodeStore.redisConnectionString .Values.ha.transcodeStore.existingSecret) }}
{{- "true" }}
{{- end }}
{{- end }}
{{/*
Config PVC claim name either the existing claim or the chart-managed one.
*/}}
{{- define "jellyfin-ha.configPvcName" -}}
{{- if .Values.persistence.config.existingClaim }}
{{- .Values.persistence.config.existingClaim }}
{{- else }}
{{- printf "%s-config" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Transcode PVC claim name either the existing claim or the chart-managed one.
*/}}
{{- define "jellyfin-ha.transcodePvcName" -}}
{{- if .Values.persistence.transcode.existingClaim }}
{{- .Values.persistence.transcode.existingClaim }}
{{- else }}
{{- printf "%s-transcode" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Media PVC claim name either the existing claim or the chart-managed NFS PVC.
*/}}
{{- define "jellyfin-ha.mediaPvcName" -}}
{{- if .Values.persistence.media.existingClaim }}
{{- .Values.persistence.media.existingClaim }}
{{- else if .Values.persistence.media.nfs.enabled }}
{{- printf "%s-media" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.runtimeConfig.enabled }}
# jellyfin.runtimeconfig.json ConfigMap.
# Mount path: /jellyfin/jellyfin.runtimeconfig.json
# Use this to set .NET runtime configuration switches (e.g. Intel QSV codec flags).
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
data:
jellyfin.runtimeconfig.json: |
{{- .Values.runtimeConfig.json | nindent 4 }}
{{- end }}
@@ -0,0 +1,97 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "jellyfin-ha.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}
---
{{- if .Values.traefikIngressRoute.enabled }}
# Traefik v3 IngressRoute (used by k3s default ingress controller).
# Enables sticky session cookies — required for multi-replica Jellyfin so that
# a client always lands on the same pod (session affinity).
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- if .Values.traefikIngressRoute.tls.enabled }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
{{- end }}
spec:
entryPoints:
{{- toYaml .Values.traefikIngressRoute.entryPoints | nindent 4 }}
routes:
- match: Host(`{{ .Values.traefikIngressRoute.host }}`)
kind: Rule
services:
- name: {{ include "jellyfin-ha.fullname" . }}
port: {{ .Values.service.port }}
{{- if .Values.traefikIngressRoute.sticky.enabled }}
sticky:
cookie:
name: {{ .Values.traefikIngressRoute.sticky.cookieName }}
httpOnly: {{ .Values.traefikIngressRoute.sticky.httpOnly }}
secure: {{ .Values.traefikIngressRoute.sticky.secure }}
{{- end }}
{{- if .Values.traefikIngressRoute.tls.enabled }}
tls:
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
{{- end }}
---
{{- if .Values.traefikIngressRoute.tls.enabled }}
# cert-manager Certificate for Traefik TLS termination.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-tls
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
spec:
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
issuerRef:
name: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
kind: ClusterIssuer
dnsNames:
{{- if .Values.traefikIngressRoute.tls.dnsNames }}
{{- toYaml .Values.traefikIngressRoute.tls.dnsNames | nindent 4 }}
{{- else }}
- {{ .Values.traefikIngressRoute.host }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,14 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if .Values.postgresql.enabled }}
# PersistentVolumeClaim for the in-cluster PostgreSQL data directory.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}-data
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.postgresql.persistence.storageClass }}
storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgresql.persistence.size }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.postgresql.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
ports:
- name: postgres
port: {{ .Values.postgresql.service.port }}
targetPort: postgres
protocol: TCP
{{- end }}
@@ -0,0 +1,84 @@
{{- if .Values.postgresql.enabled }}
# In-cluster PostgreSQL StatefulSet — experimental.
# The credentials secret must be created manually before first deploy:
#
# kubectl create secret generic {{ .Values.postgresql.existingSecret }} \
# --namespace {{ .Release.Namespace }} \
# --from-literal=POSTGRES_USER=jellyfin \
# --from-literal=POSTGRES_PASSWORD=<strong-password> \
# --from-literal=POSTGRES_DB=jellyfin \
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@{{ include "jellyfin-ha.postgres.fullname" . }}:5432/jellyfin"
#
# SECURITY: Do NOT add a Secret resource here. Applying this file must not
# overwrite a live secret.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
replicas: 1
serviceName: {{ include "jellyfin-ha.postgres.fullname" . }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
spec:
containers:
- name: postgres
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_DB
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
{{- toYaml .Values.postgresql.resources | nindent 12 }}
livenessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.postgres.fullname" . }}-data
{{- end }}
@@ -0,0 +1,90 @@
{{- if not .Values.persistence.config.existingClaim }}
# Shared config PVC — used by all Jellyfin replicas.
# Must be ReadWriteMany when replicaCount > 1.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- {{ .Values.persistence.config.accessMode }}
{{- if .Values.persistence.config.storageClass }}
storageClassName: {{ .Values.persistence.config.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.config.size }}
{{- end }}
---
{{- if not .Values.persistence.transcode.existingClaim }}
# Shared transcode PVC — must be ReadWriteMany so pod takeover can read
# HLS segments written by the previous owner pod.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-transcode
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- {{ .Values.persistence.transcode.accessMode }}
{{- if .Values.persistence.transcode.storageClass }}
storageClassName: {{ .Values.persistence.transcode.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.transcode.size }}
{{- end }}
---
{{- if and .Values.persistence.media.nfs.enabled (not .Values.persistence.media.existingClaim) }}
# NFS PersistentVolume and PersistentVolumeClaim for the media library.
# Enable persistence.media.nfs.enabled and provide server/path to use this.
# Alternatively, set persistence.media.existingClaim to reuse an existing PVC.
apiVersion: v1
kind: PersistentVolume
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-media-nfs
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
capacity:
storage: {{ .Values.persistence.media.nfs.size }}
accessModes:
- ReadOnlyMany
persistentVolumeReclaimPolicy: Retain
{{- if .Values.persistence.media.nfs.storageClass }}
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
{{- end }}
nfs:
server: {{ .Values.persistence.media.nfs.server | quote }}
path: {{ .Values.persistence.media.nfs.path | quote }}
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-media
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- ReadOnlyMany
{{- if .Values.persistence.media.nfs.storageClass }}
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.media.nfs.size }}
volumeName: {{ include "jellyfin-ha.fullname" . }}-media-nfs
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.redis.enabled }}
# ConfigMap holding the Redis configuration file.
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
data:
redis.conf: |
maxmemory {{ .Values.redis.maxmemory }}
maxmemory-policy {{ .Values.redis.maxmemoryPolicy }}
{{- end }}
@@ -0,0 +1,58 @@
{{- if .Values.redis.enabled }}
# In-cluster Redis Deployment for jellyifn-ha transcode session store.
# No persistence — lease data is small and reconstructable on restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
args: ["redis-server", "/etc/redis/redis.conf"]
ports:
- name: redis
containerPort: 6379
protocol: TCP
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- name: config
mountPath: /etc/redis
volumes:
- name: config
configMap:
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
protocol: TCP
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "jellyfin-ha.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
@@ -0,0 +1,27 @@
{{- if .Values.serviceMonitor.enabled }}
# Prometheus ServiceMonitor.
# Jellyfin does not expose a native /metrics endpoint. Enable this if you have
# a Prometheus sidecar or plan to add one. The kube-state-metrics replica count
# alert is the primary health signal for Jellyfin without a native exporter.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.serviceMonitor.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
endpoints:
- port: http
path: {{ .Values.serviceMonitor.path }}
interval: {{ .Values.serviceMonitor.interval }}
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
{{- end }}
@@ -0,0 +1,277 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
replicas: {{ .Values.replicaCount }}
serviceName: {{ include "jellyfin-ha.fullname" . }}
updateStrategy:
{{- toYaml .Values.updateStrategy | nindent 4 }}
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "jellyfin-ha.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
# ---------------------------------------------------------------------------
# Affinity / anti-affinity
# ---------------------------------------------------------------------------
affinity:
{{- if and .Values.gpu.enabled .Values.gpu.intel.nodeLabel.key }}
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: {{ .Values.gpu.intel.nodeLabel.key }}
operator: In
values:
- {{ .Values.gpu.intel.nodeLabel.value }}
{{- end }}
{{- if .Values.podAntiAffinity.enabled }}
podAntiAffinity:
{{- if eq .Values.podAntiAffinity.type "required" }}
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 18 }}
topologyKey: kubernetes.io/hostname
{{- else }}
preferredDuringSchedulingIgnoredDuringExecution:
- weight: {{ .Values.podAntiAffinity.weight }}
podAffinityTerm:
labelSelector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 20 }}
topologyKey: kubernetes.io/hostname
{{- end }}
{{- end }}
# GPU node toleration
{{- if .Values.gpu.enabled }}
tolerations:
- key: {{ .Values.gpu.intel.toleration.key }}
operator: Equal
value: {{ .Values.gpu.intel.toleration.value | quote }}
effect: {{ .Values.gpu.intel.toleration.effect }}
{{- end }}
# ---------------------------------------------------------------------------
# Init containers
# ---------------------------------------------------------------------------
initContainers:
{{- if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
# Inject database.xml to select the PostgreSQL provider at startup.
- name: inject-db-config
image: busybox:1.37.0
command:
- sh
- -c
- |
mkdir -p /config/config
chown {{ .Values.securityContext.runAsUser }}:{{ .Values.securityContext.runAsGroup }} /config/config
chmod 775 /config/config
cat > /config/config/database.xml << 'DBEOF'
<?xml version="1.0" encoding="utf-8"?>
<DatabaseConfigurationOptions>
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
<LockingBehavior>NoLock</LockingBehavior>
</DatabaseConfigurationOptions>
DBEOF
chown {{ .Values.securityContext.runAsUser }}:{{ .Values.securityContext.runAsGroup }} /config/config/database.xml
chmod 664 /config/config/database.xml
echo "database.xml injected."
volumeMounts:
- name: config
mountPath: /config
{{- end }}
{{- with .Values.extraInitContainers }}
{{- toYaml . | nindent 8 }}
{{- end }}
# ---------------------------------------------------------------------------
# Main container
# ---------------------------------------------------------------------------
containers:
- name: jellyfin
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8096
protocol: TCP
env:
# Pod identity — used by the Redis transcode lease store to identify this replica.
- name: JELLYFIN_HA_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: JELLYFIN_INSTANCE_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
# Disable UDP auto-discovery when running multiple replicas.
- name: JELLYFIN_Network__AutoDiscovery
value: {{ .Values.config.autoDiscovery | quote }}
# Config directory (must differ from data root; see Jellyfin sanity check).
- name: JELLYFIN_CONFIG_DIR
value: {{ .Values.config.configDir | quote }}
{{- if .Values.config.publishedServerUrl }}
- name: JELLYFIN_PublishedServerUrl
value: {{ .Values.config.publishedServerUrl | quote }}
{{- end }}
# ---------------------------------------------------------------------------
# Redis (HA transcode session store)
# ---------------------------------------------------------------------------
{{- if include "jellyfin-ha.haEnabled" . }}
{{- if .Values.ha.transcodeStore.existingSecret }}
# Connection string sourced from an existing secret.
- name: Jellyfin__TranscodeStore__RedisConnectionString
valueFrom:
secretKeyRef:
name: {{ .Values.ha.transcodeStore.existingSecret }}
key: {{ .Values.ha.transcodeStore.existingSecretKey }}
{{- else }}
- name: Jellyfin__TranscodeStore__RedisConnectionString
value: {{ include "jellyfin-ha.redisConnectionString" . | quote }}
{{- end }}
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: {{ .Values.ha.transcodeStore.leaseDurationSeconds | quote }}
{{- end }}
# ---------------------------------------------------------------------------
# PostgreSQL (experimental)
# ---------------------------------------------------------------------------
{{- if and .Values.postgresql.enabled (eq .Values.config.databaseType "Jellyfin-PostgreSQL") }}
- name: POSTGRES_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: DATABASE_URL
{{- end }}
# ---------------------------------------------------------------------------
# Extra environment variables
# ---------------------------------------------------------------------------
{{- with .Values.config.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
privileged: {{ if and .Values.gpu.enabled .Values.gpu.mountDri }}true{{ else }}{{ .Values.securityContext.privileged }}{{ end }}
runAsUser: {{ .Values.securityContext.runAsUser }}
runAsGroup: {{ .Values.securityContext.runAsGroup }}
volumeMounts:
- name: config
mountPath: /config
{{- if or .Values.persistence.media.existingClaim (and .Values.persistence.media.nfs.enabled) }}
- name: media
mountPath: /media
readOnly: true
{{- end }}
- name: transcode
mountPath: /config/transcodes
- name: cache
mountPath: /cache
{{- if and .Values.gpu.enabled .Values.gpu.mountDri }}
- name: dri
mountPath: /dev/dri
{{- end }}
{{- if .Values.runtimeConfig.enabled }}
- name: runtimeconfig
mountPath: /jellyfin/jellyfin.runtimeconfig.json
subPath: jellyfin.runtimeconfig.json
readOnly: true
{{- end }}
{{- with .Values.extraVolumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
livenessProbe:
{{- toYaml .Values.livenessProbe | nindent 12 }}
readinessProbe:
{{- toYaml .Values.readinessProbe | nindent 12 }}
# ---------------------------------------------------------------------------
# Volumes (static — shared across all pods)
# ---------------------------------------------------------------------------
volumes:
- name: config
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.configPvcName" . }}
- name: transcode
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.transcodePvcName" . }}
{{- if or .Values.persistence.media.existingClaim .Values.persistence.media.nfs.enabled }}
- name: media
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.mediaPvcName" . }}
{{- end }}
{{- if and .Values.gpu.enabled .Values.gpu.mountDri }}
- name: dri
hostPath:
path: /dev/dri
{{- end }}
{{- if .Values.runtimeConfig.enabled }}
- name: runtimeconfig
configMap:
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
{{- end }}
{{- with .Values.extraVolumes }}
{{- toYaml . | nindent 8 }}
{{- end }}
# ---------------------------------------------------------------------------
# Per-pod volumes via volumeClaimTemplates
# Cache is per-pod (RWO) — each replica has an independent transcoding cache,
# which avoids lock contention and is safe to lose on pod termination.
# ---------------------------------------------------------------------------
volumeClaimTemplates:
- metadata:
name: cache
labels:
{{- include "jellyfin-ha.labels" . | nindent 10 }}
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.persistence.cache.storageClass }}
storageClassName: {{ .Values.persistence.cache.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.cache.size }}
+405
View File
@@ -0,0 +1,405 @@
# Default values for jellyfin-ha.
# This is a YAML-formatted file.
# -- Override the chart name.
nameOverride: ""
# -- Override the full resource name prefix.
fullnameOverride: ""
# -- Number of Jellyfin replicas.
# Set >= 2 to use HA mode. When replicaCount > 1, ha.enabled should be true
# and a Redis connection must be configured (via redis.enabled or ha.transcodeStore.redisConnectionString).
replicaCount: 2
# -- Container image configuration.
image:
repository: "your-registry/jellyfin-ha"
tag: "latest"
pullPolicy: IfNotPresent
# -- Image pull secrets (e.g. for private ECR registries).
# Example:
# - name: ecr-pull-secret
imagePullSecrets: []
# ---------------------------------------------------------------------------
# HA (High-Availability) configuration
# ---------------------------------------------------------------------------
ha:
# -- Enable HA mode. When true, a Redis connection string is required
# (either via redis.enabled or ha.transcodeStore.redisConnectionString).
# When false, NullTranscodeSessionStore is used and behavior is identical
# to upstream Jellyfin.
enabled: true
transcodeStore:
# -- StackExchange.Redis connection string.
# Leave empty to auto-compose from the in-cluster Redis service when redis.enabled=true.
# Explicit examples:
# redis:6379
# redis:6379,password=secret
# redis.example.com:6380,ssl=true,abortConnect=false
# sentinel-host:26379,serviceName=mymaster
redisConnectionString: ""
# -- How long (seconds) a pod's transcode lease is valid before another pod may take over.
leaseDurationSeconds: 30
# -- Secret containing the Redis connection string.
# If set, the connection string is read from this secret instead of the value above.
# The secret must have a key named by existingSecret.key.
existingSecret: ""
existingSecretKey: "connection-string"
# ---------------------------------------------------------------------------
# In-cluster Redis (for transcode session store)
# ---------------------------------------------------------------------------
redis:
# -- Deploy an in-cluster Redis instance.
# Disable and set ha.transcodeStore.redisConnectionString to use an external Redis.
enabled: true
image:
repository: redis
tag: "7.4.2-alpine3.21"
pullPolicy: IfNotPresent
# -- Maximum memory for Redis to use.
maxmemory: "256mb"
# -- LRU eviction policy when maxmemory is reached.
maxmemoryPolicy: "allkeys-lru"
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# ---------------------------------------------------------------------------
# Jellyfin application configuration
# ---------------------------------------------------------------------------
config:
# -- The externally-reachable URL Jellyfin reports to clients.
publishedServerUrl: ""
# -- Disable UDP auto-discovery (port 7359).
# Recommended when running multiple replicas to prevent duplicate discovery responses.
autoDiscovery: false
# -- Jellyfin config directory inside the container.
# Must differ from the data/root directory to pass Jellyfin's sanity check.
configDir: "/config/config"
# -- Database provider: "SQLite" (default) or "Jellyfin-PostgreSQL" (experimental).
# When set to "Jellyfin-PostgreSQL", an init container will inject database.xml
# and the postgresql.enabled section (or an external connection string) must be configured.
databaseType: "SQLite"
# -- Extra environment variables to set on the Jellyfin container.
# Example:
# - name: JELLYFIN_Network__BaseUrl
# value: "/jellyfin"
extraEnv: []
# ---------------------------------------------------------------------------
# PostgreSQL (experimental — only needed when config.databaseType = Jellyfin-PostgreSQL)
# ---------------------------------------------------------------------------
postgresql:
# -- Deploy an in-cluster PostgreSQL instance.
enabled: false
image:
repository: postgres
tag: "16.6-alpine3.21"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
persistence:
storageClass: ""
size: 5Gi
# -- Name of an existing secret with PostgreSQL credentials.
# Required when postgresql.enabled=true. The secret must contain:
# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DATABASE_URL
# Create it with:
# kubectl create secret generic jellyfin-postgres-credentials \
# --from-literal=POSTGRES_USER=jellyfin \
# --from-literal=POSTGRES_PASSWORD=<password> \
# --from-literal=POSTGRES_DB=jellyfin \
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@<host>:5432/jellyfin"
existingSecret: "jellyfin-postgres-credentials"
service:
port: 5432
# ---------------------------------------------------------------------------
# GPU / hardware transcoding
# ---------------------------------------------------------------------------
gpu:
# -- Enable Intel QSV / VA-API hardware transcoding.
# Mounts /dev/dri from the host and sets the required security context.
enabled: false
intel:
# -- Node affinity label to prefer GPU-capable nodes.
nodeLabel:
key: gpu
value: intel-uhd-630
# -- Toleration for the GPU node taint.
toleration:
key: gpu
value: "true"
effect: NoSchedule
# -- Mount /dev/dri from the host (required for VA-API; implies privileged=true).
mountDri: true
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
persistence:
# Config volume — single-writer; RWO is fine for single-replica deployments.
# For multi-replica: use an RWX storage class (e.g. Longhorn RWX, NFS) or
# point all pods at an existing shared PVC via existingClaim.
config:
# -- Size of the config PVC.
size: 5Gi
# -- Storage class. Leave empty to use the cluster default.
storageClass: ""
# -- Access mode. Use ReadWriteMany when replicaCount > 1 and sharing one PVC.
accessMode: ReadWriteMany
# -- Reuse an existing PVC. When set, no new PVC is created.
existingClaim: ""
# Media volume — read-only mount shared by all pods.
# Configure one of: existingClaim (for an existing PVC), nfs (to create an NFS PV+PVC),
# or existingClaim pointing at a pre-created PVC.
media:
# -- Reuse an existing media PVC (most common for homelab NFS/Longhorn setups).
existingClaim: ""
# -- Create an NFS-backed PV and PVC for the media library.
nfs:
enabled: false
server: "your-nas.local"
path: "/media"
size: 1Ti
storageClass: ""
# Transcode volume — MUST be ReadWriteMany when replicaCount > 1 so that
# a recovering pod can read HLS segments written by the pod it is replacing.
# When replicaCount=1, ReadWriteOnce is acceptable.
transcode:
size: 30Gi
storageClass: ""
accessMode: ReadWriteMany
existingClaim: ""
# Per-pod cache volume — local to each pod; always RWO.
# Created via StatefulSet volumeClaimTemplates (one PVC per pod).
cache:
size: 30Gi
storageClass: ""
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
service:
type: ClusterIP
port: 8096
# -- Annotations for the Service resource.
annotations: {}
# ---------------------------------------------------------------------------
# Ingress (standard Kubernetes Ingress)
# ---------------------------------------------------------------------------
ingress:
enabled: false
# -- Ingress class name (e.g. "nginx", "traefik").
className: ""
annotations: {}
# cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
hosts:
- host: jellyfin.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: jellyfin-tls
# hosts:
# - jellyfin.example.com
# ---------------------------------------------------------------------------
# Traefik IngressRoute (Traefik v3 CRD — used by k3s default ingress)
# ---------------------------------------------------------------------------
traefikIngressRoute:
enabled: false
entryPoints:
- websecure
# -- Hostname for the Traefik routing rule.
host: "jellyfin.example.com"
# -- Enable sticky session cookie (recommended for multi-replica Jellyfin).
sticky:
enabled: true
cookieName: "jellyfin-server-id"
httpOnly: true
secure: true
# -- cert-manager Certificate resource for TLS.
tls:
enabled: false
secretName: "jellyfin-tls"
clusterIssuer: "letsencrypt-prod"
dnsNames: []
# - jellyfin.example.com
# ---------------------------------------------------------------------------
# Resource requests and limits for the Jellyfin container
# ---------------------------------------------------------------------------
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "4"
memory: 4Gi
# ---------------------------------------------------------------------------
# Liveness and readiness probes
# ---------------------------------------------------------------------------
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# ---------------------------------------------------------------------------
# Security context
# ---------------------------------------------------------------------------
# Container-level security context.
securityContext:
# -- Set to true only when GPU passthrough via /dev/dri is required.
# privileged=true is required for DRM ioctls (VA-API). Omit (false) for
# software-only transcoding.
privileged: false
# -- UID for the Jellyfin process. Use 10010 to match the svc-jellyfin NAS account
# when NFS root_squash is enabled.
runAsUser: 1000
runAsGroup: 1000
# Pod-level security context.
podSecurityContext:
# -- fsGroup ensures mounted volumes are group-writable.
fsGroup: 1000
# -- Additional groups for /dev/dri access (video=44, render=109 or 991).
supplementalGroups: []
# - 44 # video
# - 109 # render (legacy)
# - 991 # render (Debian 13 trixie)
seccompProfile:
type: RuntimeDefault
# ---------------------------------------------------------------------------
# Service account
# ---------------------------------------------------------------------------
serviceAccount:
create: false
name: ""
annotations: {}
# ---------------------------------------------------------------------------
# Pod Disruption Budget
# ---------------------------------------------------------------------------
podDisruptionBudget:
enabled: true
minAvailable: 1
# ---------------------------------------------------------------------------
# Pod anti-affinity (spread replicas across nodes for node-level HA)
# ---------------------------------------------------------------------------
podAntiAffinity:
enabled: true
# -- "preferred" won't block scheduling if nodes are insufficient.
# Use "required" to enforce strict cross-node placement.
type: preferred
weight: 100
# ---------------------------------------------------------------------------
# Prometheus ServiceMonitor
# Note: Jellyfin has no native /metrics endpoint. This ServiceMonitor is
# included for future use (e.g. if you add a sidecar exporter) or for
# blackbox-style readiness monitoring. Disable if not using kube-prometheus-stack.
# ---------------------------------------------------------------------------
serviceMonitor:
enabled: false
# -- Scrape interval.
interval: "30s"
# -- Scrape path (Jellyfin does not expose Prometheus metrics natively).
path: /metrics
# -- Additional labels to add to the ServiceMonitor (e.g. to match a Prometheus release label).
additionalLabels: {}
# release: kube-prometheus-stack
# ---------------------------------------------------------------------------
# Runtime config (jellyfin.runtimeconfig.json)
# Set dotnet runtime switches here if needed. Leave empty for defaults.
# ---------------------------------------------------------------------------
runtimeConfig:
enabled: false
# -- Raw JSON content for jellyfin.runtimeconfig.json.
# See jellyfin-runtimeconfig ConfigMap in the existing manifests for an example.
json: |
{
"configProperties": {}
}
# ---------------------------------------------------------------------------
# Extra Kubernetes resources
# ---------------------------------------------------------------------------
# -- Additional volumes to attach to the Jellyfin pod.
extraVolumes: []
# - name: my-extra-config
# configMap:
# name: my-configmap
# -- Additional volume mounts for the Jellyfin container.
extraVolumeMounts: []
# - name: my-extra-config
# mountPath: /etc/my-config
# -- Additional init containers.
extraInitContainers: []
# -- Annotations to add to the StatefulSet.
annotations: {}
# -- Annotations to add to individual pods.
podAnnotations: {}
# -- Labels to add to the StatefulSet.
labels: {}
# -- Labels to add to individual pods.
podLabels: {}
# -- Update strategy for the StatefulSet.
updateStrategy:
type: RollingUpdate