172 lines
6.2 KiB
YAML
172 lines
6.2 KiB
YAML
# 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).
|
|
# The connection itself is read-only (?mode=ro). A read-write connection that
|
|
# believes it is the last one checkpoints and deletes -wal/-shm on close; when
|
|
# Jellyfin had lost its POSIX locks this deleted them under the running server
|
|
# and corrupted jellyfin.db (2026-09-16). The copy is integrity-checked so a
|
|
# broken snapshot fails the job instead of landing in restic.
|
|
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 "file:/db-local/jellyfin.db?mode=ro" ".backup /backup/jellyfin.db"
|
|
[ "$(sqlite3 /backup/jellyfin.db 'PRAGMA integrity_check')" = ok ]
|
|
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 and read-only connection for
|
|
# the same reasons 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 "file:/config/data/introskipper/$db?mode=ro" ".backup /backup/introskipper/$db"
|
|
[ "$(sqlite3 "/backup/introskipper/$db" 'PRAGMA integrity_check')" = ok ]
|
|
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: {}
|