Add database backup/restore script with auto-pruning

scripts/db-backup.sh provides three commands:
- backup: creates a timestamped, gzipped pg_dump (custom format) in ./backups/
- restore: drops, recreates, and loads a backup with confirmation prompt
- list: shows available backups with sizes and dates

Supports --keep N flag for automatic pruning of backups older than N days,
making it cron-friendly for daily automated backups. Also adds backups/
and *.dump.gz to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 14:40:51 -05:00
parent 42767e3119
commit 704f29362a
2 changed files with 301 additions and 0 deletions

296
scripts/db-backup.sh Executable file
View File

@@ -0,0 +1,296 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# db-backup.sh — Backup & restore the HOA LedgerIQ PostgreSQL database
#
# Usage:
# ./scripts/db-backup.sh backup [--dir /path/to/backups] [--keep N]
# ./scripts/db-backup.sh restore <file.sql.gz | file.dump.gz>
# ./scripts/db-backup.sh list [--dir /path/to/backups]
#
# Backup produces a gzipped custom-format dump with a timestamped filename:
# backups/hoafinance_2026-03-02_140530.dump.gz
#
# Cron example (daily at 2 AM, keep 30 days):
# 0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BACKUP_DIR="$PROJECT_DIR/backups"
KEEP_DAYS=0 # 0 = keep forever
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
# If running with the SSL override, detect it
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
fi
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Helpers ----
ensure_postgres_running() {
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
fi
}
format_size() {
local bytes=$1
if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)"
elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)"
elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)"
else printf "%d B" "$bytes"
fi
}
# ---- BACKUP ----
do_backup() {
ensure_postgres_running
mkdir -p "$BACKUP_DIR"
local timestamp
timestamp="$(date +%Y-%m-%d_%H%M%S)"
local filename="${DB_NAME}_${timestamp}.dump.gz"
local filepath="$BACKUP_DIR/$filename"
info "Starting backup of database '${DB_NAME}' ..."
# pg_dump inside the container, stream through gzip on the host
$COMPOSE_CMD exec -T postgres pg_dump \
-U "$DB_USER" \
-d "$DB_NAME" \
--no-owner \
--no-privileges \
--format=custom \
| gzip -9 > "$filepath"
local size
size=$(wc -c < "$filepath" | tr -d ' ')
if [ "$size" -lt 100 ]; then
rm -f "$filepath"
die "Backup file is suspiciously small — something went wrong. Check docker compose logs postgres."
fi
ok "Backup complete: ${filepath} ($(format_size "$size"))"
# ---- Prune old backups ----
if [ "$KEEP_DAYS" -gt 0 ]; then
local pruned=0
while IFS= read -r old_file; do
rm -f "$old_file"
((pruned++))
done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*.dump.gz" -mtime +"$KEEP_DAYS" -type f 2>/dev/null)
if [ "$pruned" -gt 0 ]; then
info "Pruned $pruned backup(s) older than $KEEP_DAYS days"
fi
fi
}
# ---- RESTORE ----
do_restore() {
local file="$1"
# Resolve relative path
if [[ "$file" != /* ]]; then
file="$(pwd)/$file"
fi
[ -f "$file" ] || die "File not found: $file"
ensure_postgres_running
echo ""
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
warn "with the contents of: $(basename "$file")"
echo ""
read -rp "Type 'yes' to continue: " confirm
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
echo ""
info "Step 1/4 — Terminating active connections ..."
$COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d postgres -c "
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
" > /dev/null 2>&1 || true
info "Step 2/4 — Dropping and recreating database ..."
$COMPOSE_CMD exec -T postgres dropdb -U "$DB_USER" --if-exists "$DB_NAME"
$COMPOSE_CMD exec -T postgres createdb -U "$DB_USER" "$DB_NAME"
info "Step 3/4 — Restoring from $(basename "$file") ..."
if [[ "$file" == *.dump.gz ]]; then
# Custom-format dump, gzipped → decompress and pipe to pg_restore
gunzip -c "$file" | $COMPOSE_CMD exec -T postgres pg_restore \
-U "$DB_USER" \
-d "$DB_NAME" \
--no-owner \
--no-privileges \
--exit-on-error 2>&1 | tail -5 || true
elif [[ "$file" == *.sql.gz ]]; then
# Plain SQL dump, gzipped → decompress and pipe to psql
gunzip -c "$file" | $COMPOSE_CMD exec -T postgres psql \
-U "$DB_USER" \
-d "$DB_NAME" \
--quiet 2>&1 | tail -5 || true
elif [[ "$file" == *.dump ]]; then
# Custom-format dump, not compressed
$COMPOSE_CMD exec -T postgres pg_restore \
-U "$DB_USER" \
-d "$DB_NAME" \
--no-owner \
--no-privileges \
--exit-on-error < "$file" 2>&1 | tail -5 || true
elif [[ "$file" == *.sql ]]; then
# Plain SQL dump, not compressed
$COMPOSE_CMD exec -T postgres psql \
-U "$DB_USER" \
-d "$DB_NAME" \
--quiet < "$file" 2>&1 | tail -5 || true
else
die "Unsupported file format. Expected .dump.gz, .sql.gz, .dump, or .sql"
fi
info "Step 4/4 — Restarting backend ..."
$COMPOSE_CMD restart backend > /dev/null 2>&1
echo ""
ok "Restore complete. Backend restarted."
# Quick sanity check
local tenant_count
tenant_count=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
-t -c "SELECT count(*) FROM shared.organizations WHERE status = 'active';" 2>/dev/null | tr -d ' ')
info "Active tenants found: ${tenant_count:-0}"
}
# ---- LIST ----
do_list() {
mkdir -p "$BACKUP_DIR"
local count=0
echo ""
printf " %-42s %10s %s\n" "FILENAME" "SIZE" "DATE"
printf " %-42s %10s %s\n" "--------" "----" "----"
while IFS= read -r f; do
[ -z "$f" ] && continue
local size
size=$(wc -c < "$f" | tr -d ' ')
local mod_date
mod_date=$(date -r "$f" "+%Y-%m-%d %H:%M" 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d. -f1)
printf " %-42s %10s %s\n" "$(basename "$f")" "$(format_size "$size")" "$mod_date"
((count++))
done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*" -type f 2>/dev/null | sort)
echo ""
if [ "$count" -eq 0 ]; then
info "No backups found in $BACKUP_DIR"
else
info "$count backup(s) in $BACKUP_DIR"
fi
}
# ---- CLI ----
usage() {
cat <<EOF
HOA LedgerIQ Database Backup & Restore
Usage:
$(basename "$0") backup [--dir DIR] [--keep DAYS] Create a timestamped gzipped backup
$(basename "$0") restore FILE Restore from a backup file
$(basename "$0") list [--dir DIR] List available backups
Options:
--dir DIR Backup directory (default: ./backups)
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
Supported restore formats:
.dump.gz Custom-format pg_dump, gzipped (default backup format)
.sql.gz Plain SQL dump, gzipped
.dump Custom-format pg_dump, uncompressed
.sql Plain SQL dump, uncompressed
Cron example (daily at 2 AM, retain 30 days):
0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
EOF
exit 0
}
# Parse command
COMMAND="${1:-}"
shift 2>/dev/null || true
[ -z "$COMMAND" ] && usage
# Parse flags
RESTORE_FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--dir) BACKUP_DIR="$2"; shift 2 ;;
--keep) KEEP_DAYS="$2"; shift 2 ;;
--help) usage ;;
*)
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then
RESTORE_FILE="$1"; shift
else
die "Unknown argument: $1"
fi
;;
esac
done
# Load .env if present (for POSTGRES_USER / POSTGRES_DB)
if [ -f "$PROJECT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$PROJECT_DIR/.env"
set +a
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
fi
case "$COMMAND" in
backup)
do_backup
;;
restore)
[ -z "$RESTORE_FILE" ] && die "Usage: $(basename "$0") restore <file>"
do_restore "$RESTORE_FILE"
;;
list)
do_list
;;
-h|--help|help)
usage
;;
*)
die "Unknown command: $COMMAND (try: backup, restore, list)"
;;
esac