From 1791aca690f8df9ad3617c270ad64e3dc2df79bd Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Fri, 18 Sep 2026 20:17:57 +0200 Subject: [PATCH] docs+chore: add DB restore script and document backup/restore in README docker/restore-db.sh - emergency-only, not wired into any crontab. Lists available backups when run without args; restores via pg_restore (--clean --if-exists --no-owner --no-privileges -j 4) against DIRECT_URL, same throwaway postgres:17-alpine container pattern as backup-db.sh. Requires typing RESTORE to confirm (FORCE=1 skips it for scripted use). README.md gets a new 'Database backups & disaster recovery' section (setup step 9) documenting all three scripts, the DIRECT_URL vs DATABASE_URL reasoning, and the storage-metadata restore caveat. No admin-panel UI for this by design - restore is SSH-only, deliberately higher-friction than a browser button. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 3 +- README.md | 36 ++++++++++++ docker/restore-db.sh | 134 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 docker/restore-db.sh diff --git a/.gitignore b/.gitignore index a8fb4f7..45bba74 100644 --- a/.gitignore +++ b/.gitignore @@ -34,9 +34,10 @@ yarn-error.log* .env* !.env.example -# NAS-side cron runtime logs (docker/run-cron.sh, docker/backup-db.sh), not app code +# NAS-side cron runtime logs (docker/run-cron.sh, docker/backup-db.sh, docker/restore-db.sh), not app code docker/cron.log docker/backup.log +docker/restore.log # temp scaffold directory (removed after setup) /scaffold-tmp diff --git a/README.md b/README.md index 1bae460..0d360ff 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,42 @@ docker compose build **As of 2026-08-16 this runs as 3 replicas (`pawfeed-1/2/3`), not 1 container** — Next.js standalone output is single-process, so a single container can't use more than one CPU core; there's no single-container path anymore. Nginx Proxy Manager load-balances across them via a `least_conn` upstream (`pawfeed_upstream`) — see `docker/SCALING-ROADMAP.md` for the full rationale and the NPM-side config location. For deploys against the live site, run `./docker/rolling-deploy.sh` instead of `docker compose up -d --build` — it rebuilds the image once and restarts the 3 replicas one at a time (gated on `GET /api/health`), verified zero-downtime. `docker compose up -d --build` still works but restarts all 3 at once. +### 9. Database backups & disaster recovery (production only) + +There's no admin-panel UI for this — it's deliberately SSH-only, since a one-click restore in a browser is a much bigger blast radius than a command someone has to type deliberately. Three scripts live in `docker/`, all NAS-side (not run from a local dev machine), all working against `DIRECT_URL` (the non-pooled port-5432 connection — `pg_dump`/`pg_restore` against the pgbouncer-fronted `DATABASE_URL` are unreliable) via a throwaway `postgres:17-alpine` container, since the NAS host has no `postgresql-client` in its `PATH` (same reason Node/Prisma tooling there also runs in a throwaway container — see `CLAUDE.md`). + +**Backup** (`docker/backup-db.sh`) — runs daily at 03:00 via the NAS crontab, no arguments. Dumps in `pg_dump -Fc` (custom, compressed) format to: + +``` +/srv/dev-disk-by-uuid-ad295d7f-a870-4f70-9de2-dfded1fabf7f/BACKUPALL/pawfeed/pawfeed-.dump +``` + +30-day retention (older `.dump` files are deleted after each successful run). Every run is logged to `docker/backup.log` — there's no local mail delivery configured on this NAS for cron failure notifications, so the log is the only place a failure would show up. + +To back up on demand: + +```bash +ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/backup-db.sh" +``` + +**Restore** (`docker/restore-db.sh`) — manual/emergency use only, **not** wired into any crontab. + +```bash +# List available backups +ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/restore-db.sh" + +# Restore one (prompts: type RESTORE to confirm — this drops and recreates +# live objects via pg_restore --clean --if-exists) +ssh daniel@192.168.1.222 "/Dockers/PawFeed/docker/restore-db.sh pawfeed-2026-09-18_19-34-22.dump" + +# Non-interactive (scripted use only — skips the confirmation prompt) +ssh daniel@192.168.1.222 "FORCE=1 /Dockers/PawFeed/docker/restore-db.sh pawfeed-2026-09-18_19-34-22.dump" +``` + +Runs `pg_restore --clean --if-exists --no-owner --no-privileges -j 4` against `DIRECT_URL`. `--no-owner --no-privileges` avoids `ALTER OWNER`/`GRANT` errors if the connecting role doesn't exactly match the dump's original roles; `-j 4` parallelizes the restore (only possible with custom-format dumps). Logged to `docker/restore.log`. + +**Caveat:** the dump is the *entire* database, including Supabase-internal schemas (`auth`, `storage`, `extensions`, ...). This app uses Clerk for identity (not Supabase Auth), so `auth` is normally empty/unused here. `storage` holds Supabase Storage's *metadata* rows (bucket/object records) — the actual file bytes live in Supabase's object store separately and aren't part of this dump, so a restore can leave `storage.objects` rows pointing at files deleted since the backup, or miss rows for files uploaded after it. Treat a restore as "the app is back up," not "storage state is bit-for-bit consistent" — spot-check media after restoring. + --- ## Key Commands diff --git a/docker/restore-db.sh b/docker/restore-db.sh new file mode 100644 index 0000000..8e0fa5b --- /dev/null +++ b/docker/restore-db.sh @@ -0,0 +1,134 @@ +#!/bin/sh +# Emergency restore of a pg_dump backup (docker/backup-db.sh) into the +# production Supabase Postgres DB. NOT wired into crontab - manual/disaster +# recovery use only. +# +# Usage: +# docker/restore-db.sh # list available backups, exit +# docker/restore-db.sh # restore that backup (asks to type +# # RESTORE to confirm) +# FORCE=1 docker/restore-db.sh # skip the confirmation prompt +# # (only for scripted/non-interactive +# # use - know what you're doing) +# +# can be a bare filename (resolved inside BACKUP_DIR) or a full +# path. +# +# Restores via a throwaway postgres:17-alpine container (matches the +# Supabase server's Postgres 17.6) against DIRECT_URL (the non-pooled +# port-5432 connection - pg_restore against the pgbouncer-fronted +# DATABASE_URL is unreliable), using: +# --clean --if-exists drop existing objects before recreating them, so +# the restore is idempotent against a live DB +# --no-owner --no-privileges +# skip ALTER OWNER / GRANT statements - avoids +# errors if the restoring role doesn't exactly match +# the dump's original roles +# -j 4 parallel restore (custom format only) +# +# CAVEAT: this restores the WHOLE database dump, including Supabase-internal +# schemas (auth, storage, extensions, ...). This app uses Clerk for identity +# (not Supabase Auth), so `auth` is normally empty/unused here. `storage` +# holds Supabase Storage's *metadata* rows (bucket/object records) - the +# actual file bytes live in Supabase's object store separately and are not +# part of this dump, so a restore can leave storage.objects rows pointing at +# files that were since deleted, or miss rows for files uploaded after the +# backup. Full data consistency after a restore is not guaranteed for +# storage - review before treating the app as fully recovered. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" +LOG_FILE="$SCRIPT_DIR/restore.log" +BACKUP_DIR="/srv/dev-disk-by-uuid-ad295d7f-a870-4f70-9de2-dfded1fabf7f/BACKUPALL/pawfeed" +PG_IMAGE="postgres:17-alpine" + +log() { + printf '%s [restore-db] %s\n' "$(date -Iseconds)" "$1" >> "$LOG_FILE" +} + +list_backups() { + echo "Available backups in $BACKUP_DIR:" + ls -lh "$BACKUP_DIR"/pawfeed-*.dump 2>/dev/null | awk '{print " " $NF " (" $5 ")"}' \ + || echo " (none found)" + echo + echo "Usage: $0 " +} + +if [ $# -eq 0 ]; then + list_backups + exit 0 +fi + +DUMP_ARG="$1" +if [ -f "$DUMP_ARG" ]; then + DUMP_PATH="$DUMP_ARG" +elif [ -f "$BACKUP_DIR/$DUMP_ARG" ]; then + DUMP_PATH="$BACKUP_DIR/$DUMP_ARG" +else + echo "ERROR: backup file not found: $DUMP_ARG" >&2 + echo >&2 + list_backups + exit 1 +fi + +DUMP_DIR="$(cd "$(dirname "$DUMP_PATH")" && pwd)" +DUMP_NAME="$(basename "$DUMP_PATH")" + +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: $ENV_FILE not found" >&2 + exit 1 +fi + +DIRECT_URL="$(grep -E '^DIRECT_URL=' "$ENV_FILE" | head -n1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//')" +if [ -z "$DIRECT_URL" ]; then + echo "ERROR: DIRECT_URL not set in $ENV_FILE" >&2 + exit 1 +fi + +TARGET_HOST="$(printf '%s' "$DIRECT_URL" | sed -E 's#^postgresql://[^@]+@([^/]+)/.*#\1#')" + +echo "!!! This will DROP AND RECREATE objects in the LIVE database !!!" +echo " Backup file: $DUMP_PATH" +echo " Target: $TARGET_HOST (from DIRECT_URL in $ENV_FILE)" +echo + +if [ "${FORCE:-}" != "1" ]; then + printf 'Type RESTORE to continue: ' + read -r CONFIRM + if [ "$CONFIRM" != "RESTORE" ]; then + echo "Aborted (no changes made)." + exit 1 + fi +fi + +log "starting restore of $DUMP_NAME onto $TARGET_HOST" + +OUT_FILE="$(mktemp)" +trap 'rm -f "$OUT_FILE"' EXIT + +# Captured to a temp file (not piped straight to `tee`) so the exit status +# checked below is docker run's own, not a pipeline's last stage. +set +e +docker run --rm \ + -e DB_URL="$DIRECT_URL" \ + -e FILE_NAME="$DUMP_NAME" \ + -v "$DUMP_DIR:/backup:ro" \ + "$PG_IMAGE" \ + sh -c 'pg_restore --clean --if-exists --no-owner --no-privileges -j 4 -d "$DB_URL" "/backup/$FILE_NAME"' \ + > "$OUT_FILE" 2>&1 +STATUS=$? +set -e + +cat "$OUT_FILE" +cat "$OUT_FILE" >> "$LOG_FILE" + +if [ "$STATUS" -eq 0 ]; then + log "OK: restored $DUMP_NAME onto $TARGET_HOST" + echo + echo "Restore finished. Spot-check the app before trusting it's fully recovered" + echo "(see the storage-metadata caveat at the top of this script)." +else + log "ERROR: restore of $DUMP_NAME failed (exit $STATUS), see output above" + exit 1 +fi