Backup & restore
What lives where
Section titled “What lives where”Postgres
Section titled “Postgres”Logical backup (pg_dump)
Section titled “Logical backup (pg_dump)”The simplest reliable backup. Compresses well, works across any Postgres version ≥ 16, can be restored to a different instance.
# Daily backup, retained for 30 daysdocker compose -f docker-compose.prod.yml exec -T postgres \ pg_dump -U scani --format=custom --no-owner scani \ > "scani-$(date +%F).dump"For a managed Postgres without a local container, use pg_dump
against the URL directly:
pg_dump "$DATABASE_URL" --format=custom --no-owner \ > "scani-$(date +%F).dump"Restore:
pg_restore --clean --if-exists --no-owner --dbname="$DATABASE_URL" \ scani-2026-05-24.dumpPhysical backup / point-in-time recovery
Section titled “Physical backup / point-in-time recovery”Managed Postgres providers (RDS, Neon, Render, Supabase) all
provide PITR. Use it — it’s strictly more powerful than pg_dump
and handles WAL streaming.
For self-hosted Postgres, wal-g
or pgbackrest are the standard tools.
Encrypted credentials are encrypted at rest
Section titled “Encrypted credentials are encrypted at rest”User integration credentials (exchange API keys, brokerage tokens)
are stored AES-256-GCM-encrypted with the ENCRYPTION_KEY env var.
The backup is only useful with the matching ENCRYPTION_KEY.
Treat ENCRYPTION_KEY like a database backup credential — losing it
means losing the ability to decrypt integration credentials, which
breaks every sync until each user re-enters their keys.
Skip it. Nothing in Redis needs to survive a restart:
- Rate-limiter counters (regenerate from “now”).
- Realtime pub/sub topics (ephemeral by definition).
- Job-lifecycle events already delivered to the UI.
- The portfolio-value cache (recomputed on the next read).
- Admin HMAC replay nonces (expire on their own TTL).
Job state is not in this list. BullMQ runs on the Postgres
backend, so in-flight, delayed and repeatable jobs live in the
bullmq schema of your database and are captured by the Postgres
backup above. Copying a Redis AOF file preserves no jobs — if you
are following an older runbook that told you to, it is copying
nothing that matters.
S3 / MinIO
Section titled “S3 / MinIO”Cloud providers handle durability. For self-hosted MinIO, the
data lives in the minio-data named volume:
# Snapshot the volumedocker run --rm \ -v scani_minio-data:/data:ro \ -v "$PWD":/backup \ alpine \ tar -czf "/backup/minio-$(date +%F).tar.gz" -C /data .For real backups, use mc mirror:
docker run --rm --network scani_default \ minio/mc:latest \ sh -c "mc alias set local http://minio:9000 minioadmin minioadmin && \ mc mirror --overwrite local/job-uploads-dev s3://your-backup-bucket/scani"What a full DR drill looks like
Section titled “What a full DR drill looks like”- Provision a fresh host.
- Pull the same
SCANI_IMAGE_TAGyou were running. - Restore the same
.env, includingENCRYPTION_KEY,BETTER_AUTH_SECRET,LOG_ID_PEPPER. - Restore Postgres from the most recent dump or PITR snapshot.
- (Optional) Restore S3 from your backup bucket.
- Skip Redis — it holds nothing durable. Job state came back with the Postgres restore in step 4.
- Boot the compose stack.
- Sign in. Verify a sync runs (each user’s encrypted creds decrypt successfully).
If sync runs fail with decryption errors, ENCRYPTION_KEY does not
match the backup. There is no recovery from this — users will have
to re-enter their integration credentials.
Backup retention recommendations
Section titled “Backup retention recommendations”- Daily Postgres dump kept for 7 days locally.
- Weekly dump kept for 8 weeks in a different storage account.
- Monthly dump kept for 12 months in a different region.
- For high-stakes deployments, run a parallel WAL-streaming replica.
See also
Section titled “See also”- Production with docker-compose
- Required environment variables —
ENCRYPTION_KEYnotes. - Upgrades & version pinning