c2c43cdc92
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>
83 lines
4.1 KiB
Bash
83 lines
4.1 KiB
Bash
#!/usr/bin/env bash
|
|
#
|
|
# VYNDR nightly database backup (security follow-up item 2).
|
|
#
|
|
# Supabase free tier has ZERO backups (no scheduled, no PITR) — the ledger and
|
|
# everything else have no safety net. This dumps the WHOLE database nightly via
|
|
# the direct connection string, keeps 14 days locally, pushes a weekly copy
|
|
# off-box, and pages ntfy on ANY failure. Runs on the Hetzner box via cron.
|
|
#
|
|
# REQUIRED env (set on the box / in the container that runs the cron):
|
|
# SUPABASE_DB_URL the Supabase DIRECT connection string (session mode, the
|
|
# db.<ref>.supabase.co:5432 URL — NOT the :6543 pooler;
|
|
# pg_dump needs a real session). Kev pastes this in Coolify.
|
|
# OPTIONAL env:
|
|
# BACKUP_DIR local dump dir (default /var/backups/vyndr)
|
|
# BACKUP_KEEP_DAYS local retention (default 14)
|
|
# BACKUP_REMOTE off-box rsync target for the weekly copy, e.g.
|
|
# u123456@u123456.your-storagebox.de:vyndr-backups/
|
|
# (empty = skip the off-box push; a WARN is paged)
|
|
# NTFY_URL (default https://ntfy.sh)
|
|
# NTFY_TOPIC (default vyndr-backups-kev2026)
|
|
#
|
|
set -Eeuo pipefail
|
|
|
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/vyndr}"
|
|
KEEP_DAYS="${BACKUP_KEEP_DAYS:-14}"
|
|
NTFY_URL="${NTFY_URL:-https://ntfy.sh}"
|
|
NTFY_TOPIC="${NTFY_TOPIC:-vyndr-backups-kev2026}"
|
|
STAMP="$(date -u +%Y%m%d-%H%M%S)"
|
|
DUMP="${BACKUP_DIR}/vyndr-${STAMP}.dump"
|
|
MIN_BYTES="${BACKUP_MIN_BYTES:-50000}" # a real dump of this DB is far bigger; guards an empty/failed dump
|
|
|
|
notify() { # notify <title> <priority> <message>
|
|
curl -fsS --max-time 15 \
|
|
-H "Title: ${1}" -H "Priority: ${2}" -H "Tags: floppy_disk" \
|
|
-d "${3}" "${NTFY_URL}/${NTFY_TOPIC}" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
fail() { notify "VYNDR backup FAILED" "urgent" "${1}"; echo "ERROR: ${1}" >&2; exit 1; }
|
|
trap 'fail "backup script errored near line ${LINENO}"' ERR
|
|
|
|
[ -n "${SUPABASE_DB_URL:-}" ] || fail "SUPABASE_DB_URL is not set — cannot back up"
|
|
command -v pg_dump >/dev/null 2>&1 || fail "pg_dump not installed (apt-get install postgresql-client)"
|
|
mkdir -p "${BACKUP_DIR}"
|
|
|
|
# 1. Dump the whole DB in custom format (-Fc: compressed, restorable with pg_restore).
|
|
pg_dump "${SUPABASE_DB_URL}" -Fc --no-owner --no-privileges -f "${DUMP}" \
|
|
|| fail "pg_dump failed"
|
|
|
|
# 2. Sanity: a real dump is not tiny. An empty/near-empty file is a silent failure.
|
|
SIZE="$(stat -c%s "${DUMP}" 2>/dev/null || echo 0)"
|
|
[ "${SIZE}" -ge "${MIN_BYTES}" ] || fail "dump is only ${SIZE} bytes (< ${MIN_BYTES}) — treating as a failed backup"
|
|
|
|
# 2b. Integrity fingerprint: a valid custom-format archive lists its objects via
|
|
# pg_restore --list (no target DB needed). Confirm it parses AND contains the
|
|
# ledger — proves it's a real, restorable archive, not just a file of bytes.
|
|
TOC="$(pg_restore --list "${DUMP}" 2>/dev/null)" || fail "pg_restore --list failed — dump is not a valid archive"
|
|
OBJECTS="$(printf '%s\n' "${TOC}" | grep -c ';' || true)"
|
|
printf '%s\n' "${TOC}" | grep -qi 'TABLE DATA public ledger_entries' \
|
|
|| fail "dump archive does not contain ledger_entries — refusing to trust it"
|
|
echo "backup validated: ${DUMP} (${SIZE} bytes, ${OBJECTS} archive objects, ledger_entries present)"
|
|
|
|
# 3. Rotate: drop local dumps older than KEEP_DAYS.
|
|
find "${BACKUP_DIR}" -name 'vyndr-*.dump' -type f -mtime "+${KEEP_DAYS}" -delete || true
|
|
|
|
# 4. Weekly off-box copy (Sundays). A single failure of the off-box push is a
|
|
# WARN, not a hard failure — the local dump still succeeded.
|
|
if [ "$(date -u +%u)" = "7" ]; then
|
|
if [ -n "${BACKUP_REMOTE:-}" ]; then
|
|
if rsync -az --timeout=120 "${DUMP}" "${BACKUP_REMOTE}"; then
|
|
notify "VYNDR backup OK (+off-box)" "default" "Nightly dump ${STAMP} (${SIZE} bytes) + weekly off-box copy pushed."
|
|
else
|
|
notify "VYNDR off-box push FAILED" "high" "Local dump ${STAMP} is fine (${SIZE} bytes) but the weekly off-box rsync failed."
|
|
fi
|
|
else
|
|
notify "VYNDR off-box push SKIPPED" "high" "Local dump ${STAMP} OK but BACKUP_REMOTE is unset — no off-box copy this week."
|
|
fi
|
|
else
|
|
echo "backup ok: ${DUMP} (${SIZE} bytes)"
|
|
fi
|
|
|
|
exit 0
|