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