feat(ops): scheduled Postgres backups via db-backup sidecar

All durable state (library, monitor/wanted lists, discovery, config, and the
encrypted credentials) lived only in the single postgres-data volume with no
backup — one volume loss = total loss. Adds a db-backup compose sidecar that
pg_dumps immediately on start then every BACKUP_INTERVAL_SECONDS (default daily),
keeping the last BACKUP_KEEP (default 7) custom-format dumps in ${BACKUP_DIR}.

Travels with the stack (no host cron), starts with `up -d`. .env.example
documents the knobs; README adds a "Backup & restore" section with pg_restore
steps and an off-box note. Verified live: sidecar wrote a valid 128K dump of the
real lyra DB (pg_restore -l lists all tables + _prisma_migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 10:02:17 +02:00
parent f74fd5ab88
commit 851f725a98
4 changed files with 76 additions and 0 deletions
+7
View File
@@ -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 # 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. # not deliver. Defaults to ./slskd-downloads (an empty local dir) so the stack still starts.
# SLSKD_DOWNLOADS_DIR=/var/lib/slskd/downloads # 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
+1
View File
@@ -41,3 +41,4 @@ Thumbs.db
.vscode/ .vscode/
*.swp *.swp
slskd-downloads/ slskd-downloads/
backups/
+30
View File
@@ -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 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 **Settings** (they persist across rebuilds), then follow an artist or request an album
to feed The Floor. 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.
+38
View File
@@ -57,5 +57,43 @@ services:
db: db:
condition: service_healthy 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: volumes:
postgres-data: postgres-data: