diff --git a/.gitignore b/.gitignore index af0b099..a8fb4f7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,9 @@ yarn-error.log* .env* !.env.example -# NAS-side cron runtime log (docker/run-cron.sh), not app code +# NAS-side cron runtime logs (docker/run-cron.sh, docker/backup-db.sh), not app code docker/cron.log +docker/backup.log # temp scaffold directory (removed after setup) /scaffold-tmp diff --git a/docker/backup-db.sh b/docker/backup-db.sh new file mode 100644 index 0000000..9a39dd3 --- /dev/null +++ b/docker/backup-db.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Daily pg_dump backup of the production Supabase Postgres DB, run via NAS +# crontab (03:00). +# +# Dumps via a throwaway postgres:17-alpine container - matches the Supabase +# server's Postgres 17.6, and this NAS has no postgresql-client in its host +# PATH (same reason Prisma/Node tooling here also runs in a throwaway +# container - see CLAUDE.md). Uses DIRECT_URL (the non-pooled port-5432 +# connection); pg_dump against the pgbouncer-fronted DATABASE_URL is +# unreliable. Custom format (-Fc): compressed, restorable selectively via +# `pg_restore -j ` for parallelism. +# +# Retention: 30 days, enforced by deleting older *.dump files after each run. +# +# Every run is logged to docker/backup.log (this NAS has no local mail +# delivery set up for cron failures - see docker/run-cron.sh for the same +# issue/fix on the app-cron side). +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="$SCRIPT_DIR/.env" +LOG_FILE="$SCRIPT_DIR/backup.log" +BACKUP_DIR="/srv/dev-disk-by-uuid-ad295d7f-a870-4f70-9de2-dfded1fabf7f/BACKUPALL/pawfeed" +RETENTION_DAYS=30 +PG_IMAGE="postgres:17-alpine" + +log() { + printf '%s [backup-db] %s\n' "$(date -Iseconds)" "$1" >> "$LOG_FILE" +} + +if [ ! -f "$ENV_FILE" ]; then + log "ERROR: $ENV_FILE not found" + 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 + log "ERROR: DIRECT_URL not set in $ENV_FILE" + exit 1 +fi + +mkdir -p "$BACKUP_DIR" + +STAMP="$(date +%Y-%m-%d_%H-%M-%S)" +FILE_NAME="pawfeed-$STAMP.dump" +PART_NAME="$FILE_NAME.part" + +if docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e DB_URL="$DIRECT_URL" \ + -e FILE_NAME="$PART_NAME" \ + -v "$BACKUP_DIR:/backup" \ + "$PG_IMAGE" \ + sh -c 'pg_dump "$DB_URL" -Fc -f "/backup/$FILE_NAME"' \ + >> "$LOG_FILE" 2>&1 +then + mv "$BACKUP_DIR/$PART_NAME" "$BACKUP_DIR/$FILE_NAME" + SIZE="$(du -h "$BACKUP_DIR/$FILE_NAME" | cut -f1)" + log "OK: $FILE_NAME ($SIZE)" +else + rm -f "$BACKUP_DIR/$PART_NAME" + log "ERROR: pg_dump failed, see output above" + exit 1 +fi + +DELETED=0 +for old in $(find "$BACKUP_DIR" -name 'pawfeed-*.dump' -mtime "+$RETENTION_DAYS"); do + rm -f "$old" + DELETED=$((DELETED + 1)) +done +log "retention: removed $DELETED backup(s) older than ${RETENTION_DAYS}d"