Files
vyndr/docs/BACKUP-RUNBOOK.md
builtbykev b742230d94 Phase 1: ship the backup cron as CODE + a manual regrade trigger
FOUNDATION-FIRST re-order, phase 1 (tooling + safety).

BACKUP (highest-severity open item) — INSTALLED, not re-proven.
src/backupScheduler.js runs scripts/backup-db.sh nightly from inside the
API container, armed at boot in server.js. The container already has
SUPABASE_DB_URL, pg_dump and the Supabase route, so deploy == installed:
no host crontab, no Coolify click. Arming is deliberately opt-OUT (armed
whenever SUPABASE_DB_URL exists; BACKUP_CRON=0 kills it) because the S62
design was opt-in and nobody ever opted in — the DB went unbacked every
night for weeks. A failed run pages high-priority ntfy; silence is the
danger with backups.

Durability is the one part still needing a human: the container FS is
ephemeral, so a dump dies on redeploy unless BACKUP_REMOTE (off-box
rsync) or BACKUP_DIR (persistent volume) is set. The scheduler detects
that and pages a WARNING at boot rather than letting an undurable backup
read as "backed up". Runbook rewritten to lead with the code path.

MANUAL REGRADE TRIGGER — scripts/run-snapshot.js, runnable via
docker exec with no VYNDR_INTERNAL_KEY and no new HTTP surface. Runs the
SAME snapshotService.runSnapshot the cron runs (including the team-stats
refresh that powers opp_rank_stat), supports `all` and `--settle`, and
prints the grade/confidence distribution plus p_win/ev_pct presence —
which is the thing you actually want when verifying a grading change.

ACCESS BLOCKER, logged honestly in specs/model-train.md: there is no
VYNDR_INTERNAL_KEY in the local .env and SSH to the box times out from
WSL2, so I can neither curl the internal endpoints (which already exist
from S45) nor docker exec. The trigger is built and correct but only Kev
can run it until a key or SSH access exists. This is the highest-leverage
unblock for phases 2 and 3, which both need on-demand regrade+settle to
verify anything.

Also logged the standing cautions: CLV ledger stays private until
backtest-proven; "self-improving model" is unsupported marketing until
the loop closes; the engine is MLB/WNBA-calibrated and NFL/NBA/soccer
need their own calibration before the hub grades them (scaling gate).

Suite 277/3300 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-19 19:28:15 -04:00

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

THE CRON NOW SHIPS AS CODE (Session 64) — no install step

Read this before following the manual instructions below; they are now the FALLBACK, not the primary path.

src/backupScheduler.js runs the nightly backup inside the API container, armed from server.js at boot. The container already holds SUPABASE_DB_URL, pg_dump and the Supabase network route, so deploy == installed. Nothing to add to crontab, nothing to click in Coolify.

  • Arming is opt-OUT: armed whenever SUPABASE_DB_URL is set. The S62 design was opt-in (a host cron someone had to add) and nobody ever added it — the database went unbacked every night for weeks. That failure mode is now impossible.
  • Kill switch: BACKUP_CRON=0.
  • Schedule: BACKUP_HOUR_UTC (default 3) / BACKUP_MINUTE_UTC (default 10).
  • Failure pages high-priority ntfy. Silence is the danger with backups.
  • Boot log line: [backupScheduler] armed — nightly 03:10 UTC ...

⚠️ DURABILITY — the one thing still requiring a human

The container filesystem is ephemeral: a dump written inside it is LOST on the next redeploy. The scheduler detects this and pages a warning at boot when neither is configured. Set ONE of:

  1. BACKUP_REMOTE — off-box rsync target (Hetzner Storage Box, ~€3/mo). Best.
  2. BACKUP_DIR pointed at a Coolify persistent volume (e.g. /var/backups/vyndr).

Until one is set, backups run but do not survive a deploy. An undurable backup that reads as "backed up" is worse than a loud gap — hence the boot-time page.


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.