feat: Add Helm chart for Jellyfin media server with PostgreSQL support.

This commit is contained in:
sha
2026-03-16 00:33:42 +02:00
commit 2f2786adba
9 changed files with 510 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: jellyfin
description: A Helm chart for Jellyfin media server with Postgres support
type: application
version: 0.1.9
appVersion: "10.11.6"
+51
View File
@@ -0,0 +1,51 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "jellyfin.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.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 name and version as used by the chart label.
*/}}
{{- define "jellyfin.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "jellyfin.labels" -}}
helm.sh/chart: {{ include "jellyfin.chart" . }}
{{ include "jellyfin.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "jellyfin.selectorLabels" -}}
app.kubernetes.io/name: {{ include "jellyfin.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+47
View File
@@ -0,0 +1,47 @@
{{- if .Values.backup.enabled -}}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "jellyfin.fullname" . }}-backup
{{- if .Values.namespaceOverride }}
namespace: {{ .Values.namespaceOverride }}
{{- end }}
labels:
{{- include "jellyfin.labels" . | nindent 4 }}
spec:
schedule: {{ .Values.backup.schedule | quote }}
jobTemplate:
spec:
template:
spec:
containers:
- name: postgres-backup
image: {{ .Values.backup.image }}
command:
- /bin/sh
- -c
- |
# Backup Jellyfin database
pg_dump -h {{ .Values.postgres.host }} -U {{ .Values.postgres.user }} -d $DB_NAME | gzip > /backup/$DB_NAME-$(date +%Y%m%d-%H%M%S).sql.gz
# Retention: Delete backups older than specified days
find /backup -name "$DB_NAME-*.sql.gz" -mtime +$RETENTION_DAYS -delete
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgres.existingSecret }}
key: JELLYFIN_POSTGRESQL_PASSWORD
- name: DB_NAME
value: {{ .Values.postgres.db | quote }}
- name: RETENTION_DAYS
value: {{ .Values.backup.retentionDays | quote }}
volumeMounts:
- name: backup-storage
mountPath: /backup
volumes:
- name: backup-storage
hostPath:
path: {{ .Values.backup.storagePath }}
type: DirectoryOrCreate
restartPolicy: OnFailure
{{- end }}
+94
View File
@@ -0,0 +1,94 @@
{{- if .Values.dbInit.enabled -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "jellyfin.fullname" . }}-db-init
{{- if .Values.namespaceOverride }}
namespace: {{ .Values.namespaceOverride }}
{{- end }}
annotations:
"helm.sh/hook": post-install,post-upgrade
"helm.sh/hook-delete-policy": before-hook-creation
labels:
{{- include "jellyfin.labels" . | nindent 4 }}
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: db-init
image: {{ .Values.dbInit.image }}
command:
- /bin/sh
- -c
- |
set -ex
# Set admin user from secret or values
if [ -n "$ADMIN_USER_FROM_SECRET" ]; then
PGUSER=$ADMIN_USER_FROM_SECRET
else
PGUSER={{ .Values.postgres.adminUser | quote }}
fi
export PGUSER
# Wait for PostgreSQL to be ready
until pg_isready -h $PGHOST -p $PGPORT -U $PGUSER; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done
# Connect as admin user to create the jellyfin user and database
export PGPASSWORD=$POSTGRES_ADMIN_PASSWORD
# Create or update database user password
echo "Ensuring $POSTGRES_USER user exists with correct password..."
if psql -h $PGHOST -p $PGPORT -U $PGUSER -d postgres -tc "SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'" | grep -q 1; then
psql -h $PGHOST -p $PGPORT -U $PGUSER -d postgres -c "ALTER USER \"$POSTGRES_USER\" WITH PASSWORD '${JELLYFIN_PASSWORD}';"
else
psql -h $PGHOST -p $PGPORT -U $PGUSER -d postgres -c "CREATE USER \"$POSTGRES_USER\" WITH PASSWORD '${JELLYFIN_PASSWORD}';"
fi
# Create Jellyfin database if it doesn't exist
echo "Creating $POSTGRES_DB database..."
psql -h $PGHOST -p $PGPORT -U $PGUSER -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB'" | grep -q 1 || \
psql -h $PGHOST -p $PGPORT -U $PGUSER -d postgres -c "CREATE DATABASE \"$POSTGRES_DB\" OWNER \"$POSTGRES_USER\";"
# Grant all privileges to jellyfin user
echo "Granting privileges..."
psql -h $PGHOST -p $PGPORT -U $PGUSER -d "$POSTGRES_DB" -c "GRANT ALL PRIVILEGES ON DATABASE \"$POSTGRES_DB\" TO \"$POSTGRES_USER\";"
psql -h $PGHOST -p $PGPORT -U $PGUSER -d "$POSTGRES_DB" -c "GRANT ALL PRIVILEGES ON SCHEMA public TO \"$POSTGRES_USER\";"
psql -h $PGHOST -p $PGPORT -U $PGUSER -d "$POSTGRES_DB" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO \"$POSTGRES_USER\";"
psql -h $PGHOST -p $PGPORT -U $PGUSER -d "$POSTGRES_DB" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO \"$POSTGRES_USER\";"
echo "Database initialization complete."
env:
- name: PGHOST
value: {{ .Values.postgres.host | quote }}
- name: PGPORT
value: {{ .Values.postgres.port | quote }}
- name: ADMIN_USER
value: {{ .Values.postgres.adminUser | quote }}
- name: POSTGRES_DB
value: {{ .Values.postgres.db | quote }}
- name: POSTGRES_USER
value: {{ .Values.postgres.user | quote }}
- name: POSTGRES_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgres.adminSecret | default .Values.postgres.existingSecret }}
key: {{ .Values.postgres.adminSecretKey }}
{{- if .Values.postgres.adminUserKey }}
- name: ADMIN_USER_FROM_SECRET
valueFrom:
secretKeyRef:
name: {{ .Values.postgres.adminSecret | default .Values.postgres.existingSecret }}
key: {{ .Values.postgres.adminUserKey }}
{{- end }}
- name: JELLYFIN_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgres.existingSecret }}
key: JELLYFIN_POSTGRESQL_PASSWORD
resources:
{{- toYaml .Values.dbInit.resources | nindent 12 }}
{{- end }}
+102
View File
@@ -0,0 +1,102 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "jellyfin.fullname" . }}
{{- if .Values.namespaceOverride }}
namespace: {{ .Values.namespaceOverride }}
{{- end }}
labels:
{{- include "jellyfin.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "jellyfin.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "jellyfin.selectorLabels" . | nindent 8 }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 8096
protocol: TCP
env:
- name: POSTGRES_HOST
value: {{ .Values.postgres.host | quote }}
- name: POSTGRES_PORT
value: {{ .Values.postgres.port | quote }}
- name: POSTGRES_DB
value: {{ .Values.postgres.db | quote }}
- name: POSTGRES_USER
value: {{ .Values.postgres.user | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgres.existingSecret }}
key: JELLYFIN_POSTGRESQL_PASSWORD
volumeMounts:
{{- if .Values.persistence.media.enabled }}
- name: media
mountPath: {{ .Values.persistence.media.mountPath }}
{{- end }}
{{- if .Values.persistence.config.enabled }}
- name: config
mountPath: {{ .Values.persistence.config.mountPath }}
{{- end }}
{{- if .Values.persistence.dri.enabled }}
- name: dri
mountPath: {{ .Values.persistence.dri.mountPath }}
{{- end }}
{{- if .Values.persistence.extraVolumeMounts }}
{{- toYaml .Values.persistence.extraVolumeMounts | nindent 12 }}
{{- end }}
securityContext:
privileged: true
capabilities:
add: ["SYS_ADMIN"]
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
{{- if .Values.persistence.media.enabled }}
- name: media
hostPath:
path: {{ .Values.persistence.media.hostPath }}
type: DirectoryOrCreate
{{- end }}
{{- if .Values.persistence.config.enabled }}
- name: config
{{- if eq .Values.persistence.config.type "hostPath" }}
hostPath:
path: {{ .Values.persistence.config.hostPath }}
type: DirectoryOrCreate
{{- else }}
persistentVolumeClaim:
claimName: {{ .Values.persistence.config.existingClaim | default (include "jellyfin.fullname" .) }}
{{- end }}
{{- end }}
{{- if .Values.persistence.dri.enabled }}
- name: dri
hostPath:
path: {{ .Values.persistence.dri.hostPath }}
type: Directory
{{- end }}
{{- if .Values.persistence.extraVolumes }}
{{- toYaml .Values.persistence.extraVolumes | nindent 8 }}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
+53
View File
@@ -0,0 +1,53 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := (include "jellyfin.fullname" .) -}}
{{- $svcPort := .Values.service.port -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className }}
{{- end }}
{{- end }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}
{{- if .Values.namespaceOverride }}
namespace: {{ .Values.namespaceOverride }}
{{- end }}
labels:
{{- (include "jellyfin.labels" .) | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
{{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }}
pathType: {{ .pathType }}
{{- end }}
backend:
service:
name: {{ $fullName }}
port:
number: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}
+24
View File
@@ -0,0 +1,24 @@
{{- if .Values.persistence.config.enabled }}
{{- if eq .Values.persistence.config.type "pvc" }}
{{- if not .Values.persistence.config.existingClaim }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin.fullname" . }}
{{- if .Values.namespaceOverride }}
namespace: {{ .Values.namespaceOverride }}
{{- end }}
labels:
{{- include "jellyfin.labels" . | nindent 4 }}
spec:
accessModes:
{{- toYaml .Values.persistence.config.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.persistence.config.size | quote }}
{{- if .Values.persistence.config.storageClass }}
storageClassName: {{ .Values.persistence.config.storageClass | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- end }}
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin.fullname" . }}
{{- if .Values.namespaceOverride }}
namespace: {{ .Values.namespaceOverride }}
{{- end }}
labels:
{{- include "jellyfin.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 8096
protocol: TCP
name: http
selector:
{{- include "jellyfin.selectorLabels" . | nindent 4 }}
+115
View File
@@ -0,0 +1,115 @@
# Default values for jellyfin.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: ghcr.io/rogly-net/jellyfin-postgresql
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: "v10.11.6"
nameOverride: ""
fullnameOverride: ""
namespaceOverride: ""
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.middlewares: traefik-redirect-to-https@kubernetescrd
hosts:
- host: jellyfin.lan
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: jellyfin-tls
# hosts:
# - jellyfin.lan
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "8"
memory: 4Gi
persistence:
config:
enabled: true
type: hostPath # or pvc
hostPath: /srv/data/jellyfin
# existingClaim: jellyfin-config
mountPath: /config
size: 10Gi
accessModes:
- ReadWriteOnce
storageClass: ""
media:
enabled: true
type: hostPath
hostPath: /storage/media
mountPath: /storage/media
dri:
enabled: true
type: hostPath
hostPath: /dev/dri
mountPath: /dev/dri
# Additional volumes (like sas14, sas16, etc. in jellyfin-hs)
extraVolumes: []
# - name: sas14
# hostPath:
# path: /storage/sas14
# type: DirectoryOrCreate
extraVolumeMounts: []
# - name: sas14
# mountPath: /storage/sas14
postgres:
host: postgres-tcp.postgres.svc.cluster.local
port: 5432
db: jellyfin
user: jellyfin
adminUser: postgres
existingSecret: jellyfin-secret
adminSecret: "" # If empty, existingSecret is used
adminSecretKey: POSTGRES_ADMIN_PASSWORD
adminUserKey: "" # If empty, .Values.postgres.adminUser is used
# Keys in the secret:
# JELLYFIN_POSTGRESQL_PASSWORD
# POSTGRES_ADMIN_PASSWORD
dbInit:
enabled: true
image: postgres:15-alpine
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 100m
memory: 128Mi
backup:
enabled: true
schedule: "0 2 * * *"
image: postgres:16
retentionDays: 10
storagePath: /storage/backups/postgres
resources: {}
nodeSelector: {}
tolerations: []
affinity: {}