Skip to content

Backup & restore

DataWhereCritical to back up?
Holdings, transactions, observations, prices, vaults, groups, accounts, users, sessions, encrypted integration credsPostgresYes. The whole truth lives here.
BullMQ job state, scheduled-job statePostgres, in the bullmq schemaCovered by the Postgres backup below — pg_dump of the database takes every schema, so you get it whether or not you meant to.
Rate-limiter buckets, realtime pub/sub, job-lifecycle events, portfolio-value cacheRedisNo. All of it regenerates from “now”.
Screenshot uploads, CSV imports, file-import payloadsS3 / SeaweedFSIf your retention model needs them. The application can run without them; only the audit trail / re-parse flow is impacted.
Code, schema, env configGit + your secret storeYes.

The simplest reliable backup. Compresses well, works across any Postgres version ≥ 16, can be restored to a different instance.

Terminal window
# Daily backup, retained for 30 days
docker 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:

Terminal window
pg_dump "$DATABASE_URL" --format=custom --no-owner \
> "scani-$(date +%F).dump"

Restore:

Terminal window
pg_restore --clean --if-exists --no-owner --dbname="$DATABASE_URL" \
scani-2026-05-24.dump

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).

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.

Back up objects through S3 using your authenticated endpoint. With rclone remotes configured for your Scani bucket and backup destination:

Terminal window
rclone copy scani:job-uploads backup:scani-uploads --metadata
rclone check scani:job-uploads backup:scani-uploads --download --one-way

Keep remote credentials in your secret manager or a protected rclone config. Use the actual bucket names from your installation. Avoid sync for backups: source deletions should not immediately delete your only recovery copy.

For a filesystem snapshot, stop writers and SeaweedFS first, then snapshot the whole seaweedfs-data volume, including its metadata store. A live tar of that volume is not a consistent backup. Keep .env and the encryption key with your protected recovery materials. Test restoration into an isolated stack.

  1. Provision a fresh host.
  2. Pull the same SCANI_IMAGE_TAG you were running.
  3. Restore the same .env, including ENCRYPTION_KEY, BETTER_AUTH_SECRET, LOG_ID_PEPPER.
  4. Restore Postgres from the most recent dump or PITR snapshot.
  5. (Optional) Restore S3 from your backup bucket.
  6. Skip Redis — it holds nothing durable. Job state came back with the Postgres restore in step 4.
  7. Boot the compose stack.
  8. 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.

  • 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.