#!/bin/bash # ============================================ # Wordly.art - PostgreSQL Backup to NAS # ============================================ # CRON: Run daily at 03:00 # 0 3 * * * /opt/wordly/scripts/backup-to-nas.sh >> /var/log/wordly-backup.log 2>&1 # # Usage: # ./backup-to-nas.sh # Default: daily backup # ./backup-to-nas.sh --full # Full backup with upload cleanup # ./backup-to-nas.sh --restore FILE # Restore from specific backup # ============================================ set -euo pipefail # =========================================== # CONFIGURATION - MODIFY THESE VALUES # =========================================== # NAS settings (SMB/CIFS or NFS mount point) NAS_BACKUP_DIR="/mnt/nas-backups/wordly" # Docker container name for PostgreSQL POSTGRES_CONTAINER="wordly-postgres" POSTGRES_USER="translate" POSTGRES_DB="translate_db" POSTGRES_PASSWORD="yLLgkEvt6mvzGDdoqtQvI1vEgMmR-W75ZTPW5StaIAU" # Backup retention DAILY_RETENTION=7 # Keep 7 daily backups WEEKLY_RETENTION=4 # Keep 4 weekly backups MONTHLY_RETENTION=6 # Keep 6 monthly backups # Notification (optional - leave empty to disable) NOTIFICATION_WEBHOOK="" # Slack/Discord webhook URL # =========================================== # INTERNALS # =========================================== TIMESTAMP=$(date +"%Y%m%d_%H%M%S") DATE_ONLY=$(date +"%Y-%m-%d") DAY_OF_WEEK=$(date +"%u") # 1=Mon, 7=Sun DAY_OF_MONTH=$(date +"%d") BACKUP_NAME="wordly_db_${TIMESTAMP}.sql.gz" BACKUP_PATH="${NAS_BACKUP_DIR}/${BACKUP_NAME}" LOG_PREFIX="[Wordly Backup ${TIMESTAMP}]" # Colors for terminal output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # =========================================== # FUNCTIONS # =========================================== log() { echo "${LOG_PREFIX} $1" } log_success() { echo -e "${LOG_PREFIX} ${GREEN}$1${NC}" } log_error() { echo -e "${LOG_PREFIX} ${RED}ERROR: $1${NC}" } log_warning() { echo -e "${LOG_PREFIX} ${YELLOW}WARNING: $1${NC}" } send_notification() { local message="$1" if [ -n "${NOTIFICATION_WEBHOOK}" ]; then curl -s -X POST "${NOTIFICATION_WEBHOOK}" \ -H "Content-Type: application/json" \ -d "{\"text\": \"${message}\"}" > /dev/null 2>&1 || true fi } check_prerequisites() { # Check NAS mount if [ ! -d "${NAS_BACKUP_DIR}" ]; then log_error "NAS backup directory not found: ${NAS_BACKUP_DIR}" log "Attempting to mount NAS..." # Try to mount if configured via /etc/fstab mount "${NAS_BACKUP_DIR}" 2>/dev/null || true if [ ! -d "${NAS_BACKUP_DIR}" ]; then log_error "Cannot mount NAS. Aborting." send_notification "Wordly Backup FAILED: NAS not mounted at ${NAS_BACKUP_DIR}" exit 1 fi fi # Check Docker if ! docker ps --format '{{.Names}}' | grep -q "${POSTGRES_CONTAINER}"; then log_error "PostgreSQL container '${POSTGRES_CONTAINER}' is not running." send_notification "Wordly Backup FAILED: PostgreSQL container not running" exit 1 fi log_success "Prerequisites OK" } create_backup() { log "Starting backup of '${POSTGRES_DB}'..." # Create backup directory structure mkdir -p "${NAS_BACKUP_DIR}/daily" mkdir -p "${NAS_BACKUP_DIR}/weekly" mkdir -p "${NAS_BACKUP_DIR}/monthly" # Run pg_dump inside Docker container docker exec "${POSTGRES_CONTAINER}" pg_dump \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ --format=custom \ --compress=9 \ --no-owner \ --no-acl \ 2>/dev/null | gzip > "${NAS_BACKUP_DIR}/daily/${BACKUP_NAME}" local backup_size=$(du -h "${NAS_BACKUP_DIR}/daily/${BACKUP_NAME}" | cut -f1) if [ -f "${NAS_BACKUP_DIR}/daily/${BACKUP_NAME}" ]; then log_success "Backup created: ${BACKUP_NAME} (${backup_size})" # Copy to weekly/monthly if applicable if [ "${DAY_OF_WEEK}" = "7" ]; then cp "${NAS_BACKUP_DIR}/daily/${BACKUP_NAME}" "${NAS_BACKUP_DIR}/weekly/" log "Weekly backup copied" fi if [ "${DAY_OF_MONTH}" = "01" ]; then cp "${NAS_BACKUP_DIR}/daily/${BACKUP_NAME}" "${NAS_BACKUP_DIR}/monthly/" log "Monthly backup copied" fi send_notification "Wordly Backup SUCCESS: ${BACKUP_NAME} (${backup_size})" else log_error "Backup file was not created!" send_notification "Wordly Backup FAILED: pg_dump produced no output" exit 1 fi } cleanup_old_backups() { log "Cleaning up old backups..." # Daily: keep last N days local daily_count=$(ls -1 "${NAS_BACKUP_DIR}/daily/" 2>/dev/null | wc -l) if [ "${daily_count}" -gt "${DAILY_RETENTION}" ]; then ls -1t "${NAS_BACKUP_DIR}/daily/" | tail -n +$((DAILY_RETENTION + 1)) | while read -r f; do rm -f "${NAS_BACKUP_DIR}/daily/${f}" log " Deleted daily: ${f}" done fi # Weekly: keep last N weeks local weekly_count=$(ls -1 "${NAS_BACKUP_DIR}/weekly/" 2>/dev/null | wc -l) if [ "${weekly_count}" -gt "${WEEKLY_RETENTION}" ]; then ls -1t "${NAS_BACKUP_DIR}/weekly/" | tail -n +$((WEEKLY_RETENTION + 1)) | while read -r f; do rm -f "${NAS_BACKUP_DIR}/weekly/${f}" log " Deleted weekly: ${f}" done fi # Monthly: keep last N months local monthly_count=$(ls -1 "${NAS_BACKUP_DIR}/monthly/" 2>/dev/null | wc -l) if [ "${monthly_count}" -gt "${MONTHLY_RETENTION}" ]; then ls -1t "${NAS_BACKUP_DIR}/monthly/" | tail -n +$((MONTHLY_RETENTION + 1)) | while read -r f; do rm -f "${NAS_BACKUP_DIR}/monthly/${f}" log " Deleted monthly: ${f}" done fi log_success "Cleanup done" } verify_backup() { log "Verifying backup integrity..." if gzip -t "${NAS_BACKUP_DIR}/daily/${BACKUP_NAME}" 2>/dev/null; then log_success "Backup integrity OK" else log_error "Backup integrity check FAILED!" send_notification "Wordly Backup WARNING: Integrity check failed for ${BACKUP_NAME}" # Don't delete - let admin investigate fi } restore_backup() { local backup_file="$1" if [ -z "${backup_file}" ]; then log_error "Usage: $0 --restore " echo "" echo "Available backups:" echo "=== Daily ===" ls -lht "${NAS_BACKUP_DIR}/daily/" 2>/dev/null || echo " (none)" echo "=== Weekly ===" ls -lht "${NAS_BACKUP_DIR}/weekly/" 2>/dev/null || echo " (none)" echo "=== Monthly ===" ls -lht "${NAS_BACKUP_DIR}/monthly/" 2>/dev/null || echo " (none)" exit 1 fi # Find the file local full_path="" for dir in daily weekly monthly; do if [ -f "${NAS_BACKUP_DIR}/${dir}/${backup_file}" ]; then full_path="${NAS_BACKUP_DIR}/${dir}/${backup_file}" break fi done if [ -z "${full_path}" ]; then log_error "Backup file not found: ${backup_file}" exit 1 fi echo "" log_warning "RESTORE MODE - This will OVERWRITE the current database!" echo " File: ${full_path}" echo " Database: ${POSTGRES_DB}" echo "" read -p "Are you sure? Type 'YES' to confirm: " confirm if [ "${confirm}" != "YES" ]; then log "Restore cancelled." exit 0 fi log "Restoring from ${full_path}..." # Create a safety backup first log "Creating safety backup before restore..." SAFETY_NAME="wordly_db_pre_restore_${TIMESTAMP}.sql.gz" docker exec "${POSTGRES_CONTAINER}" pg_dump \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ --format=custom \ --compress=9 \ 2>/dev/null | gzip > "${NAS_BACKUP_DIR}/daily/${SAFETY_NAME}" log "Safety backup: ${SAFETY_NAME}" # Restore gunzip -c "${full_path}" | docker exec -i "${POSTGRES_CONTAINER}" \ pg_restore \ -U "${POSTGRES_USER}" \ -d "${POSTGRES_DB}" \ --clean \ --if-exists \ --no-owner \ --no-acl \ 2>/dev/null || true log_success "Restore completed!" log_warning "Restart backend: docker restart wordly-backend" } # =========================================== # MAIN # =========================================== case "${1:-}" in --restore) restore_backup "${2:-}" ;; --full) check_prerequisites create_backup verify_backup cleanup_old_backups log_success "Full backup cycle complete!" ;; *) check_prerequisites create_backup verify_backup cleanup_old_backups log_success "Backup complete!" ;; esac