From 8d99b0f7feb978554497b79de8cc1611aa38cdfe Mon Sep 17 00:00:00 2001 From: Oleksandr Berezovskyi Date: Sun, 13 Sep 2026 15:58:18 +0300 Subject: [PATCH] feat(k8s/jellyfin): add restic backup for database and configuration --- kubernetes/app/jellyfin/cronjob-backup.yaml | 163 ++++++++++++++++++ kubernetes/app/jellyfin/networkpolicy.yaml | 26 +++ .../app/jellyfin/secret-backup.sops.yaml | 26 +++ 3 files changed, 215 insertions(+) create mode 100644 kubernetes/app/jellyfin/cronjob-backup.yaml create mode 100644 kubernetes/app/jellyfin/secret-backup.sops.yaml diff --git a/kubernetes/app/jellyfin/cronjob-backup.yaml b/kubernetes/app/jellyfin/cronjob-backup.yaml new file mode 100644 index 0000000..25e6c16 --- /dev/null +++ b/kubernetes/app/jellyfin/cronjob-backup.yaml @@ -0,0 +1,163 @@ +# Jellyfin SQLite database backup. +# jellyfin.db lives on the local-path PVC (/config/data/jellyfin.db is a symlink +# to it), so it is read from jellyfin-data directly. sqlite3 ".backup" uses the +# online backup API — no downtime, consistent snapshot while Jellyfin runs. +# The volume is mounted read-write on purpose: jellyfin.db is in WAL mode, and +# SQLite cannot open a WAL database from a read-only filesystem once Jellyfin has +# closed it and removed the -wal/-shm files (e.g. while scaled to zero). +apiVersion: batch/v1 +kind: CronJob +metadata: + name: jellyfin-db-backup + namespace: jellyfin + labels: + app: jellyfin-backup +spec: + schedule: "30 2 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + app: jellyfin-backup + spec: + restartPolicy: OnFailure + initContainers: + - name: sqlite-backup + image: alpine:3.21 + command: + - sh + - -c + - | + set -e + apk add --no-cache sqlite + sqlite3 /db-local/jellyfin.db ".backup /backup/jellyfin.db" + volumeMounts: + - name: db + mountPath: /db-local + - name: backup-tmp + mountPath: /backup + containers: + - name: resticprofile + image: creativeprojects/resticprofile:0.32.0 + command: + - sh + - -c + - | + resticprofile -c /secrets/profiles.yaml -n jellyfin-db backup + resticprofile -c /secrets/profiles.yaml -n jellyfin-db copy + env: + - name: B2_ACCOUNT_ID + valueFrom: + secretKeyRef: + name: jellyfin-backup-config + key: B2_ACCOUNT_ID + - name: B2_ACCOUNT_KEY + valueFrom: + secretKeyRef: + name: jellyfin-backup-config + key: B2_ACCOUNT_KEY + volumeMounts: + - name: secrets + mountPath: /secrets + readOnly: true + - name: backup-tmp + mountPath: /backup + volumes: + - name: secrets + secret: + secretName: jellyfin-backup-config + - name: db + persistentVolumeClaim: + claimName: jellyfin-data + - name: backup-tmp + emptyDir: {} +--- +# Jellyfin configuration backup. +# Only non-regenerable state: server config, library definitions, plugin +# configuration (including the Authelia SSO settings), collections, and the +# Intro Skipper analysis results. Metadata (27G), trickplay (20G) and extracted +# subtitles (1.7G) are deliberately excluded — they are regenerable from the +# media files and the metadata providers. +# Intro Skipper keeps its results in two live WAL-mode SQLite databases, so they +# are snapshotted with sqlite3 ".backup" rather than copied as files (which could +# capture a torn page mid-write). Read-write mount for the same reason as above. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: jellyfin-config-backup + namespace: jellyfin + labels: + app: jellyfin-backup +spec: + schedule: "30 3 * * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + metadata: + labels: + app: jellyfin-backup + spec: + restartPolicy: OnFailure + initContainers: + - name: sqlite-backup + image: alpine:3.21 + command: + - sh + - -c + - | + set -e + apk add --no-cache sqlite + mkdir -p /backup/introskipper + for db in introskipper.db introskipper-cache.db; do + sqlite3 "/config/data/introskipper/$db" ".backup /backup/introskipper/$db" + done + volumeMounts: + - name: config + mountPath: /config + - name: backup-tmp + mountPath: /backup + containers: + - name: resticprofile + image: creativeprojects/resticprofile:0.32.0 + command: + - sh + - -c + - | + resticprofile -c /secrets/profiles.yaml -n jellyfin-config backup + resticprofile -c /secrets/profiles.yaml -n jellyfin-config copy + env: + - name: B2_ACCOUNT_ID + valueFrom: + secretKeyRef: + name: jellyfin-backup-config + key: B2_ACCOUNT_ID + - name: B2_ACCOUNT_KEY + valueFrom: + secretKeyRef: + name: jellyfin-backup-config + key: B2_ACCOUNT_KEY + volumeMounts: + - name: secrets + mountPath: /secrets + readOnly: true + - name: config + mountPath: /config + readOnly: true + - name: backup-tmp + mountPath: /backup + volumes: + - name: secrets + secret: + secretName: jellyfin-backup-config + - name: config + persistentVolumeClaim: + claimName: jellyfin-config + - name: backup-tmp + emptyDir: {} diff --git a/kubernetes/app/jellyfin/networkpolicy.yaml b/kubernetes/app/jellyfin/networkpolicy.yaml index 7545038..4147fcc 100644 --- a/kubernetes/app/jellyfin/networkpolicy.yaml +++ b/kubernetes/app/jellyfin/networkpolicy.yaml @@ -45,3 +45,29 @@ spec: protocol: UDP - port: 1900 protocol: UDP +--- +# Backup jobs need DNS, the Synology rest-server (8888) and B2 over HTTPS. +# Declaring egress here also confines the backup pods to exactly those. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: allow-backup-egress + namespace: jellyfin +spec: + podSelector: + matchLabels: + app: jellyfin-backup + policyTypes: + - Egress + egress: + - ports: + - port: 53 + protocol: UDP + - port: 53 + protocol: TCP + - ports: + - port: 8888 + protocol: TCP + - ports: + - port: 443 + protocol: TCP diff --git a/kubernetes/app/jellyfin/secret-backup.sops.yaml b/kubernetes/app/jellyfin/secret-backup.sops.yaml new file mode 100644 index 0000000..a6b6f7a --- /dev/null +++ b/kubernetes/app/jellyfin/secret-backup.sops.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: jellyfin-backup-config + namespace: jellyfin +stringData: + profiles.yaml: ENC[AES256_GCM,data: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,iv:+DuENxnJKTQ3ZKom2uNsW7QU1eMsrO+ZFCo5aW51pwE=,tag:4MiKklkRuvJuCvlu8tV07w==,type:str] + restic-password-nas: ENC[AES256_GCM,data:2uDszBmHa4YG1oYyhN4Wjw0Lcc6/MpmrCHldwNF22kFiokbx4R+zrd8ML4NvK1ezmQmMDhlb3RsyAcQNRgJ9Ng==,iv:96pdW1B6M97+eG1EC2YPbNmO4OQmt47uiJOWuvzdYzE=,tag:dQKHD+2LeWL5+tcQbrbN0g==,type:str] + restic-password-b2: ENC[AES256_GCM,data:ceHYbKn174cIm38qnlGvx9edxP8apPWkwx7wAkNncSF6kIE5OvXVsNAWcjsZKQLEgYXFX5NwLZMDPY0edtAhNQ==,iv:mDki/Soljcnp60cfihK2EqfTNHv/VnoieetBThFqLtE=,tag:5VMRM0fe4nFDal3BO+ninw==,type:str] + B2_ACCOUNT_ID: ENC[AES256_GCM,data:6u+UlYRILbb0TRTdISi7TdVItw4omfodFw==,iv:A+be6o3ZjfYsyvEk750DcoSlBsKYM2FncSY8kqkcpqs=,tag:9ReTmil5sAfo+04IfdaKYw==,type:str] + B2_ACCOUNT_KEY: ENC[AES256_GCM,data:p+irC0iQR3RjhmElD+tZcXcgWsV8ROGHsEOJ7kxXvg==,iv:2VgptxIaJ0v1e5jejLyBIPHnvxD6ldgTWn/3oa1M32Y=,tag:9gHDOD0524IAoQQLOytT+w==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5Mk5NMVNxOG5JWHhkMjlh + YzRyYVhYT2tncmVzRzB5NXc2aEZQcUVtVFUwCnl2U2JQSkdpTjZVb1pPTmlPeVRt + SURJNFFzUTFTdVZpckV5d2puN2xQTmcKLS0tIDUxeXFLdGxOdDVLdng2SFRWb296 + NFpDSkZmYkY0Vyt4Nml3N3VXbFdZZ0kKfNoqtyb+16qpDdCHgp3uiDCHqtiDXEjB + p1kaQkgF6xqFohomV+6x0RsW+r7PaGh8oWRut0NBRahZwIKBucZUTw== + -----END AGE ENCRYPTED FILE----- + recipient: age1zffnskvuezntkk703a0pyxsd5m8vx2hm33dr47wdfy8mn4fdw4sqgw0jgc + encrypted_regex: ^(data|stringData|email)$ + lastmodified: "2026-09-13T13:09:04Z" + mac: ENC[AES256_GCM,data:E8xZZ+0hdRPKE/nss00gopEg7feF7t4pXBqYkEtr9yzHn1Vz4Opfv4vBDDgwqntZdekE40rpzWTuPJDAfOrhDsCsgZjOgWa6VpSRNM9GQ37Ax/fv/G42CXtwBPCzY1p2GuXr9nsJI+/P4c6sx4FIXZ0o4QLCHuStkYpjbHlyIFA=,iv:LJCUP/KbZ0tZPdG2hedHfZMfYWS+w4T73JaI7nLe2uQ=,tag:J7GT2jm6iHFZ8oMV2ecEIw==,type:str] + version: 3.13.3