#!/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 # ./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 </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 " do_restore "$RESTORE_FILE" ;; list) do_list ;; -h|--help|help) usage ;; *) die "Unknown command: $COMMAND (try: backup, restore, list)" ;; esac