diff --git a/.env.example b/.env.example index cc15017..5e6fab3 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,10 @@ MUSIC_DIR=./music # Set this only once Lyra runs on the same host as slskd; until then Soulseek can search but # not deliver. Defaults to ./slskd-downloads (an empty local dir) so the stack still starts. # SLSKD_DOWNLOADS_DIR=/var/lib/slskd/downloads + +# Postgres backups (db-backup sidecar). Dumps go to BACKUP_DIR on the host; the sidecar +# keeps the last BACKUP_KEEP dumps and runs every BACKUP_INTERVAL_SECONDS (default daily). +# Point BACKUP_DIR at a NAS/synced path for off-box safety. See README "Backup & restore". +# BACKUP_DIR=./backups +# BACKUP_INTERVAL_SECONDS=86400 +# BACKUP_KEEP=7 diff --git a/.gitignore b/.gitignore index 2127310..f94ff6f 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,4 @@ Thumbs.db .vscode/ *.swp slskd-downloads/ +backups/ diff --git a/README.md b/README.md index 954cd5d..e785ae3 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,33 @@ The web entrypoint runs `prisma migrate deploy` on every rebuild, so schema chan reach the live DB automatically. Enter Qobuz / Soulseek / Last.fm credentials in **Settings** (they persist across rebuilds), then follow an artist or request an album to feed The Floor. + +## Backup & restore + +**All** durable state — library metadata, monitored/wanted lists, discovery data, +config, and the encrypted Qobuz/Soulseek/Last.fm credentials — lives in the single +`postgres-data` volume. Losing that volume (disk failure, or a stray `down -v`) means +losing everything, recoverable only by a full re-scan and re-entering every credential. + +The `db-backup` sidecar guards against that. It runs `pg_dump` immediately on start and +then every `BACKUP_INTERVAL_SECONDS` (default daily), keeping the last `BACKUP_KEEP` +dumps (default 7) as compressed custom-format `.dump` files in `BACKUP_DIR` +(default `./backups`). It starts automatically with `docker compose up -d`. + +For **off-box** safety (a backup on the same disk doesn't survive a disk failure), point +`BACKUP_DIR` at a NAS/synced path, or rsync `./backups` to another machine on a cron. + +**Restore** into a fresh/empty database (stop `web`/`worker` first so nothing writes +mid-restore): + +```sh +# with the stack up and db healthy: +docker compose stop web worker +docker compose cp ./backups/lyra-YYYYMMDD-HHMMSS.dump db:/tmp/restore.dump +docker compose exec db pg_restore --clean --if-exists -U lyra -d lyra /tmp/restore.dump +docker compose start web worker +``` + +`--clean --if-exists` drops and recreates each object, so restoring over an existing DB +is safe. To restore onto a brand-new host, bring up just `db` first +(`docker compose up -d db`), restore, then `up -d --build` the rest. diff --git a/docker-compose.yml b/docker-compose.yml index f8544ba..afe68db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,5 +57,43 @@ services: db: condition: service_healthy + # Scheduled Postgres backups. ALL durable state (library, monitored/wanted lists, + # discovery data, config, AND the encrypted credentials) lives in the single + # postgres-data volume — one volume loss = total loss. This sidecar pg_dumps on an + # interval (immediately on start, then every BACKUP_INTERVAL_SECONDS), keeping the + # last BACKUP_KEEP dumps in ${BACKUP_DIR:-./backups}. Restore steps: see README. + # For off-box safety, point BACKUP_DIR at a synced/NAS path or rsync ./backups away. + db-backup: + image: postgres:17 + restart: unless-stopped + environment: + PGHOST: db + PGUSER: ${POSTGRES_USER} + PGPASSWORD: ${POSTGRES_PASSWORD} + PGDATABASE: ${POSTGRES_DB} + BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400} + BACKUP_KEEP: ${BACKUP_KEEP:-7} + volumes: + - ${BACKUP_DIR:-./backups}:/backups + depends_on: + db: + condition: service_healthy + entrypoint: ["/bin/sh", "-c"] + command: + - | + echo "db-backup: starting (interval=$${BACKUP_INTERVAL_SECONDS}s keep=$${BACKUP_KEEP})" + while true; do + ts=$$(date +%Y%m%d-%H%M%S) + out="/backups/lyra-$${ts}.dump" + if pg_dump -Fc -f "$${out}.tmp" 2>/tmp/err; then + mv "$${out}.tmp" "$${out}" + echo "db-backup: wrote $${out} ($$(du -h "$${out}" | cut -f1))" + else + echo "db-backup: pg_dump FAILED: $$(cat /tmp/err)"; rm -f "$${out}.tmp" + fi + ls -1t /backups/lyra-*.dump 2>/dev/null | tail -n +$$(( $${BACKUP_KEEP} + 1 )) | xargs -r rm -f + sleep "$${BACKUP_INTERVAL_SECONDS}" + done + volumes: postgres-data: