Decision. Pin the pipeline to one node with local SSD
Alternative. Spreading it across nodes with network storage
Why. Transcode workloads are latency-sensitive; local disk keeps playback smooth and the pin makes storage claims simple.
Media server that streams the shared library to every screen in the house
Jellyfin serves the library to players around the house. It is pinned to the media node so transcodes always hit local SSD.
templates/jellyfin/jellyfin-rollout.yaml Jellyfin Rollout
{{- $projectName := .Values.global.projectName -}}
{{- $imagePullSecretsName := .Values.global.imagePullSecretsName -}}
{{- $hostMediaMountPath := .Values.global.hostMediaMountPath -}}
{{- $configBasePath := .Values.global.configBasePath -}}
{{- $timeZone := .Values.global.timeZone -}}
{{- $userID := .Values.global.userID -}}
{{- with .Values.applications.jellyfin }}
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: {{ .name }}
namespace: {{ $projectName }}
spec:
replicas: 1
strategy:
canary:
maxSurge: 0
maxUnavailable: 1
steps:
- setWeight: 100
selector:
matchLabels:
app: {{ .name }}
template:
metadata:
labels:
app: {{ .name }}
spec:
{{- include "media-systems.nodePlacement" $ | nindent 6 }}
serviceAccount: {{ $projectName }}-service-account
serviceAccountName: {{ $projectName }}-service-account
automountServiceAccountToken: true
containers:
- name: {{ .name }}
image: {{ .image }}
imagePullPolicy: IfNotPresent
lifecycle:
postStart:
exec:
command:
- "/bin/sh"
- "-c"
- |
# Temp installation of jellyfin-ffmpeg6
apt update && apt install jellyfin-ffmpeg6 -y;
echo done;
ports:
- name: {{ .name | substr 0 9 }}-http
containerPort: {{ .ports.http }}
protocol: TCP
env:
- name: JELLYFIN_PublishedServerUrl
value: {{ .name }}.opi5cluster.co.uk
- name: PGID
value: {{ $userID | quote }}
- name: PUID
value: {{ $userID | quote }}
- name: TZ
value: {{ $timeZone | quote }}
volumeMounts:
- name: {{ .name }}-config
mountPath: /config
- name: {{ .name }}-movies
mountPath: {{ .moviesMount }}
- name: {{ .name }}-series
mountPath: {{ .seriesMount }}
- name: {{ .name }}-anime
mountPath: {{ .animeMount }}
- name: {{ .name }}-others
mountPath: {{ .othersMount }}
resources: {}
securityContext:
privileged: true
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
volumes:
- name: {{ .name }}-config
hostPath:
path: {{ $configBasePath }}/{{ .name }}
type: DirectoryOrCreate
- name: {{ .name }}-movies
hostPath:
path: {{ $hostMediaMountPath }}/movies
type: Directory
- name: {{ .name }}-series
hostPath:
path: {{ $hostMediaMountPath }}/series
type: Directory
- name: {{ .name }}-anime
hostPath:
path: {{ $hostMediaMountPath }}/anime
type: Directory
- name: {{ .name }}-others
hostPath:
path: {{ $hostMediaMountPath }}/others
type: Directory
{{- end -}} templates/jellyfin/jellyfin-lb-service.yaml Jellyfin LoadBalancer Service (MetalLB IP, pinned to the media node)
{{- $projectName := .Values.global.projectName }}
{{- with .Values.applications.jellyfin }}
apiVersion: v1
kind: Service
metadata:
name: {{ .name }}-loadbalancer
namespace: {{ $projectName }}
annotations:
metallb.universe.tf/loadBalancerIPs: {{ .LoadBalancerIP }}
spec:
type: LoadBalancer
selector:
app: {{ .name }}
externalTrafficPolicy: Cluster
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: {{ .name }}
protocol: TCP
port: 80
targetPort: {{ .ports.http }}
{{- end -}} templates/_shared-templates/http-route-template.yaml Shared HTTPRoute template (used by every media app)
{{- $projectName := .Values.global.projectName }}
{{- range $app, $property := .Values.applications }}
{{- if ne $app "whisper" }}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ $app }}-httproute
namespace: {{ $projectName }}
annotations:
link.argocd.argoproj.io/external-link: "https://{{ $app }}.opi5cluster.co.uk"
spec:
parentRefs:
- name: istio-gateway
namespace: istio
sectionName: websecure
hostnames:
- {{ $app }}.opi5cluster.co.uk
rules:
- backendRefs:
- name: {{ $app }}-service
port: {{ $property.ports.http }}
{{- end }}
{{- end }} templates/_shared-templates/service-template.yaml Shared Service template (used by every media app)
{{- $projectName := .Values.global.projectName }}
{{- range $app, $property := .Values.applications }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $app }}-service
namespace: {{ $projectName }}
spec:
type: ClusterIP
selector:
app: {{ $app }}
ipFamilyPolicy: SingleStack
ports:
- name: {{ $app | substr 0 9 }}-http
protocol: TCP
port: {{ $property.ports.http }}
targetPort: {{ $property.ports.http }}
{{- if eq $property.name "qbittorrent" }}
- name: {{ $app }}-tcp
protocol: TCP
port: {{ $property.ports.torrent }}
targetPort: {{ $property.ports.torrent }}
- name: {{ $app }}-udp
protocol: UDP
port: {{ $property.ports.torrent }}
targetPort: {{ $property.ports.torrent }}
{{- end }}
{{- if eq $property.name "tdarr" }}
- name: {{ $app }}-server
protocol: TCP
port: {{ $property.ports.server }}
targetPort: {{ $property.ports.server }}
{{- end }}
{{- end }} templates/_shared-templates/service-account.yaml Shared ServiceAccount (used by every media app)
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Values.global.projectName }}-service-account
namespace: {{ .Values.global.projectName }} templates/_shared-templates/apps_configs-secret.yaml Shared apps-configs Secret template (used by every media app)
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: apps-configs-external-secret
namespace: {{ .Values.global.projectName}}
spec:
refreshInterval: 24h0m0s
secretStoreRef:
kind: ClusterSecretStore
name: vault-cluster-secret-store
target:
name: apps-configs-secret
creationPolicy: Owner
template:
engineVersion: v2
templateFrom:
- configMap:
name: bazarr-configs-config-tpl
items:
- key: bazarr_config
- configMap:
name: prowlarr-configs-config-tpl
items:
- key: prowlarr_config
- configMap:
name: qbittorrent-configs-config-tpl
items:
- key: qbittorrent_config
- configMap:
name: radarr-configs-config-tpl
items:
- key: radarr_config
- configMap:
name: sonarr-configs-config-tpl
items:
- key: sonarr_config
data:
- secretKey: bazarr_api_key
remoteRef:
key: media-systems
property: bazarr-api-key
- secretKey: bazarr_db_name
remoteRef:
key: media-systems
property: bazarr-db-name
- secretKey: flask_secret_key
remoteRef:
key: media-systems
property: flask-secret-key
- secretKey: main_password
remoteRef:
key: media-systems
property: main-password
- secretKey: main_username
remoteRef:
key: media-systems
property: main-username
- secretKey: prowlarr_api_key
remoteRef:
key: media-systems
property: prowlarr-api-key
- secretKey: prowlarr_db_name
remoteRef:
key: media-systems
property: prowlarr-db-name
- secretKey: qbittorrent_password
remoteRef:
key: media-systems
property: qbittorrent-password
- secretKey: subdl_api_key
remoteRef:
key: media-systems
property: subdl-api-key
- secretKey: radarr_api_key
remoteRef:
key: media-systems
property: radarr-api-key
- secretKey: radarr_db_name
remoteRef:
key: media-systems
property: radarr-db-name
- secretKey: sonarr_api_key
remoteRef:
key: media-systems
property: sonarr-api-key
- secretKey: sonarr_db_name
remoteRef:
key: media-systems
property: sonarr-db-name
- secretKey: db_host_name
remoteRef:
key: postgres
property: internal_host_name
- secretKey: db_username
remoteRef:
key: postgres
property: user_name
- secretKey: db_password
remoteRef:
key: postgres
property: user_password
- secretKey: db_port
remoteRef:
key: postgres
property: port Decision. Pin the pipeline to one node with local SSD
Alternative. Spreading it across nodes with network storage
Why. Transcode workloads are latency-sensitive; local disk keeps playback smooth and the pin makes storage claims simple.