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 <noreply@anthropic.com>
This commit is contained in:
1 parent
282ff4e741
commit
1791aca690
3 files changed
+172
-1
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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-<timestamp>.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
|
||||
|
||||
@@ -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 <dump-file> # restore that backup (asks to type
|
||||
# # RESTORE to confirm)
|
||||
# FORCE=1 docker/restore-db.sh <file> # skip the confirmation prompt
|
||||
# # (only for scripted/non-interactive
|
||||
# # use - know what you're doing)
|
||||
#
|
||||
# <dump-file> 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 <dump-file>"
|
||||
}
|
||||
|
||||
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
|
||||
Reference in new issue
Block a user