Files
vyndr/docs/BACKUP-RUNBOOK.md
T
builtbykev c2c43cdc92 Task A — make the container backup-capable + validated dump + mechanism fingerprint
SUPABASE_DB_URL is set in Coolify on the API service, and this WSL2 box can't
reach db.<ref>.supabase.co — so the backup runs INSIDE the API container, which
has the env + Supabase network. Made that real:
- Dockerfile: install postgresql-client (pg_dump/pg_restore) + rsync + bash in
  the runner image.
- backup-db.sh: added an integrity fingerprint on every run — pg_restore --list
  must parse the archive AND find ledger_entries, else the run FAILS + pages
  (stronger than the size check; catches a corrupt/structureless dump).
- BACKUP-RUNBOOK.md: rewritten for the container-exec reality — host cron does
  `docker exec <api> sh /app/scripts/backup-db.sh` (inherits env + network +
  pg_dump), or a Coolify Scheduled Task. Full restore-fingerprint steps included.

MECHANISM FINGERPRINT (run locally, docker + pg16): seeded a ledger_entries
table (137 rows) → ran backup-db.sh (dump + validate: 22 archive objects,
ledger_entries present) → pg_restore into a scratch DB → 137 rows restored,
exact match. The dump/validate/restore path is proven end-to-end; it's the same
pg_dump/pg_restore that run in the container against Supabase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:08:31 -04:00

3.9 KiB

VYNDR Backup Runbook (security follow-up item 2)

Supabase free tier has zero backups (no scheduled, no PITR). scripts/backup-db.sh is the safety net: a nightly full-database pg_dump, 14 days kept locally, a weekly copy pushed off-box, ntfy alert on any failure.

Where it runs

SUPABASE_DB_URL is set in Coolify on the VYNDR API service — so the backup runs inside that container, which already has the env, the Supabase network, and now pg_dump/pg_restore/rsync (added to the Dockerfile). The Hetzner box can't reach db.<ref>.supabase.co directly and doesn't hold the connection string; the container is the right place.

SUPABASE_DB_URL must be the direct connection string (session mode): Supabase → Settings → Database → Connection string → URI, the db.<ref>.supabase.co:5432 one (NOT the :6543 pooler — pg_dump needs a real session). Already set. Optional: BACKUP_DIR (mount a Coolify persistent volume here so dumps survive redeploys — e.g. /var/backups/vyndr), BACKUP_REMOTE (off-box rsync target, below).

Install the cron (host → docker exec into the API container)

Find the API container name (docker ps | grep vyndr), then a host cron:

# Nightly at 03:10 UTC. BACKUP_REMOTE can also be set in Coolify instead.
sudo crontab -e
# add (replace <api-container>):
10 3 * * * docker exec -e BACKUP_REMOTE='u123456@u123456.your-storagebox.de:vyndr-backups/' <api-container> sh /app/scripts/backup-db.sh >> /var/log/vyndr-backup.log 2>&1

docker exec inherits the container's env (SUPABASE_DB_URL) + network + the newly-installed pg_dump. (If you'd rather, Coolify's Scheduled Tasks can run sh /app/scripts/backup-db.sh on the API service on the same cadence.)

Off-box target (simplest reliable pick)

Hetzner Storage Box over rsync/SSH — you're already on Hetzner, it's ~€3/mo for 1TB, and needs no extra tooling. Create one, add the box's SSH key to it, set BACKUP_REMOTE=u<id>@u<id>.your-storagebox.de:vyndr-backups/. The script pushes the latest dump every Sunday. (Alternative: Backblaze B2 via rclone if you'd rather keep it off Hetzner entirely — swap the rsync line for rclone copy.)

Restore / FINGERPRINT (proves it's a real backup, not just a file)

Built-in (runs on every backup): the script validates each dump with pg_restore --list — a dump that isn't a valid archive, or that doesn't contain ledger_entries, is treated as a FAILURE and paged. So every successful run has already proven the archive parses and holds the ledger.

Full restore proof (run once to fingerprint): from the host, restore the newest dump into a throwaway postgres and count the ledger:

# 1. Produce a dump on demand (writes into the container's BACKUP_DIR)
docker exec <api-container> sh /app/scripts/backup-db.sh
# 2. Copy the newest dump out of the container
newest=$(docker exec <api-container> sh -lc 'ls -t /var/backups/vyndr/vyndr-*.dump | head -1')
docker cp "<api-container>:${newest}" /tmp/vyndr-latest.dump
# 3. Restore into a scratch postgres + count a known table
docker run -d --name vyndr-restore-test -e POSTGRES_PASSWORD=x -p 55432:5432 postgres:15
sleep 6
pg_restore --no-owner --no-privileges -d "postgresql://postgres:x@localhost:55432/postgres" /tmp/vyndr-latest.dump
psql "postgresql://postgres:x@localhost:55432/postgres" -c "select count(*) from public.ledger_entries;"
docker rm -f vyndr-restore-test

A non-zero ledger_entries count from the restored dump = the backup is real and restorable. Record the date + row count as the fingerprint.

Alerting

Any hard failure (missing env, pg_dump error, empty/undersized dump) pages ntfy topic vyndr-backups-kev2026 at urgent priority. The weekly off-box push failing (or BACKUP_REMOTE unset) pages at high priority but does not fail the run — the local dump still succeeded. Subscribe the phone to that topic.