Backups & restore¶
Your spotDL instance holds accounts, votes, reports and match curation — data you cannot re-derive. Back it up.
How you back up depends on your database:
- Postgres (hosted / larger self-host): nightly
pg_dumpto object storage. - SQLite (single-container default): copy the volume file.
Postgres: nightly pg_dump to object storage¶
The community instance backs up nightly with a small script that dumps Postgres,
compresses it, uploads it to object storage (Cloudflare R2 / S3 via rclone),
and prunes old dumps. The script lives at deploy/backup/backup.sh:
set -euo pipefail
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
FILE="spotdl-${STAMP}.sql.gz"
pg_dump "${SPOTDL_DATABASE_URL_PSQL:?}" | gzip > "/tmp/${FILE}" # libpq URL (no +asyncpg)
rclone copyto "/tmp/${FILE}" "${SPOTDL_BACKUP_REMOTE:?}/${FILE}" # remote configured via env (R2/S3)
rclone delete --min-age "${SPOTDL_BACKUP_RETENTION:-30d}" "${SPOTDL_BACKUP_REMOTE}" # retention
libpq vs +asyncpg
pg_dump speaks libpq, not SQLAlchemy. It needs a plain
postgresql://… URL, so backups read a separate SPOTDL_DATABASE_URL_PSQL
variable — not the app's postgresql+asyncpg://… SPOTDL_DATABASE_URL.
Point it at the same database with the driver suffix removed.
The environment variables the script reads:
| Variable | Purpose |
|---|---|
SPOTDL_DATABASE_URL_PSQL |
libpq Postgres URL to dump (postgresql://…) |
SPOTDL_BACKUP_REMOTE |
rclone remote + path, e.g. r2:spotdl-backups |
SPOTDL_BACKUP_RETENTION |
max age before a dump is pruned (default 30d) |
rclone's remote credentials (R2/S3 keys) live in the backup service's
environment only — never in the repo.
SQLite: copy the volume¶
On the single-container SQLite default there is no database server to dump — the
whole database is one file under the data volume. Stop the container (or accept a
crash-consistent copy) and copy spotdl.db:
docker compose -f deploy/docker-compose.selfhost.yml stop
docker run --rm -v spotdl_spotdl_data:/data -v "$PWD":/backup alpine \
cp /data/spotdl.db "/backup/spotdl-$(date -u +%Y%m%dT%H%M%SZ).db"
docker compose -f deploy/docker-compose.selfhost.yml start
Ship the copy off-box on whatever schedule suits you.
The Railway backup cron service¶
On the hosted instance, backup.sh runs as a second Railway service in the
same project as the server:
- Image: a small image with
postgresql-client(forpg_dump) andrclone(for uploads); start commandbash deploy/backup/backup.sh. - Schedule:
cronSchedule = "0 3 * * *"(nightly, 03:00 UTC). - Healthcheck: none. Restart policy: never — it is a one-shot cron job.
- Variables (dashboard only):
SPOTDL_DATABASE_URL_PSQL(the libpq URL, see the note above),SPOTDL_BACKUP_REMOTE(e.g.r2:spotdl-backups), optionalSPOTDL_BACKUP_RETENTION(default30d), and the rclone remote credentials. None of these are committed.
Restoring¶
deploy/backup/restore.sh streams a dump from the remote straight into a target
database:
export SPOTDL_BACKUP_REMOTE=r2:spotdl-backups
# A specific dump into an explicit libpq target URL:
./deploy/backup/restore.sh spotdl-20260709T030000Z.sql.gz \
"postgresql://user:pass@host:5432/target"
# Or the most recent dump into $SPOTDL_RESTORE_TARGET_URL:
export SPOTDL_RESTORE_TARGET_URL="postgresql://user:pass@host:5432/target"
./deploy/backup/restore.sh latest
Restore into a throwaway/target database — never the live app DB unless you intend to overwrite it.
Quarterly restore drill¶
A backup you have never restored is a hope, not a backup. Once a quarter, prove recovery end-to-end and record the result:
- Provision a throwaway Postgres (scratch Railway Postgres or a local
postgres:17container); note its libpq URL. - Restore the latest dump into it:
SPOTDL_BACKUP_REMOTE=… SPOTDL_RESTORE_TARGET_URL=<throwaway> ./deploy/backup/restore.sh latest. - Boot the server image against the throwaway DB
(
SPOTDL_DATABASE_URL=postgresql+asyncpg://…<throwaway>— note the async driver here, unlike the libpq restore URL). - Health:
curl -fsS http://<host>/api/v1/health→{"status":"ok"}. - Function: one
POST /api/v1/resolvewith a known Spotify URL returns resolved metadata (proves the restored data + schema are usable). - Record the outcome (date, dump, pass/fail) and tear down the throwaway DB.
The full runbook, the drill checklist and an outcome log live alongside the
scripts in deploy/backup/README.md.