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
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_URLis 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:
BACKUP_REMOTE— off-box rsync target (Hetzner Storage Box, ~€3/mo). Best.BACKUP_DIRpointed 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.