#!/bin/bash # ============================================================================= # NAS08 System Backup Script # ============================================================================= # Backs up OMV config, Docker Compose files, app data, and system /etc # Writes a single dated .tar.gz archive to the NAS data drive # Retains the last 10 backups (~30 days), auto-removes older ones # Sends an HTML email report on completion (via OMV Postfix / Gmail relay). # # Schedule via OMV Scheduled Jobs (cron): # 0 2 */3 * * (2:00 AM every 3 days) # # Backup destination: /export/kingdezigns-all/Backups/NAS08/ # Script location: /usr/scripts/omv/nas08-backup.sh # ============================================================================= set -uo pipefail # ----------------------------------------------------------------------------- # CONFIGURATION # ----------------------------------------------------------------------------- HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS08") REPORT_TO="rufus.king@kingdezigns.com" REPORT_FROM="backup-monitor@nas08.local" # Display only — Postfix uses your Gmail relay BACKUP_ROOT="/export/kingdezigns-all/Backups/NAS08" TMP_BASE="/export/kingdezigns-all/Backups/.tmp" DOCKER_DIR="/export/kingdezigns-all/Docker" DATE=$(date +%Y-%m-%d) TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") ARCHIVE_NAME="nas08-backup-${DATE}.tar.gz" TMP_DIR="${TMP_BASE}/nas08-backup-${TIMESTAMP}" RETAIN_COUNT=10 LOG_FILE="${BACKUP_ROOT}/backup.log" # ----------------------------------------------------------------------------- # STATUS TRACKING # OVERALL_STATUS: SUCCESS | WARNING | FAILED # ----------------------------------------------------------------------------- OVERALL_STATUS="SUCCESS" SECTION_ROWS_HTML="" WARNING_COUNT=0 ERROR_COUNT=0 # Append a result row to the section table # Usage: add_section_row "Section name" "STATUS" "Detail message" # STATUS: OK | WARNING | ERROR | SKIPPED add_section_row() { local section="$1" status="$2" detail="$3" case "$status" in OK) BADGE="✓ OK" ;; WARNING) BADGE="⚠ WARNING" WARNING_COUNT=$((WARNING_COUNT + 1)) [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" ;; ERROR) BADGE="✗ ERROR" ERROR_COUNT=$((ERROR_COUNT + 1)) OVERALL_STATUS="FAILED" ;; SKIPPED) BADGE="— SKIPPED" ;; esac SECTION_ROWS_HTML+=" $section $BADGE $detail " } # ----------------------------------------------------------------------------- # LOGGING # ----------------------------------------------------------------------------- log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } log_section() { echo "" | tee -a "$LOG_FILE" echo "------------------------------------------------------------" | tee -a "$LOG_FILE" log "$1" echo "------------------------------------------------------------" | tee -a "$LOG_FILE" } # ----------------------------------------------------------------------------- # PRE-FLIGHT CHECKS # ----------------------------------------------------------------------------- log_section "NAS08 Backup Starting" if [ ! -d "/export/kingdezigns-all" ]; then log "ERROR: /export/kingdezigns-all is not mounted or accessible. Aborting." add_section_row "Pre-flight check" "ERROR" "/export/kingdezigns-all not mounted or accessible" OVERALL_STATUS="FAILED" exit 1 fi mkdir -p "$BACKUP_ROOT" mkdir -p "$TMP_DIR" log "Temporary working directory: $TMP_DIR" # ----------------------------------------------------------------------------- # SECTION 1: OMV CONFIGURATION # ----------------------------------------------------------------------------- log_section "Backing up OMV configuration" OMV_DIR="${TMP_DIR}/omv-config" mkdir -p "$OMV_DIR" OMV_STATUS="OK" OMV_DETAIL="" # omv-confbak is optional — raw config.xml is the primary backup and fully sufficient. if command -v omv-confbak &>/dev/null; then if omv-confbak "${OMV_DIR}/omv-config.xml" 2>/dev/null; then log "omv-confbak completed successfully" OMV_DETAIL="omv-confbak exported + raw config.xml copied" else log "WARNING: omv-confbak failed or returned non-zero — raw config.xml still captured" OMV_DETAIL="omv-confbak failed (non-fatal) + raw config.xml copied" fi else log "omv-confbak not present — using raw config.xml (this is normal)" fi if [ -f /etc/openmediavault/config.xml ]; then cp /etc/openmediavault/config.xml "${OMV_DIR}/config.xml.raw" log "Raw OMV config.xml copied" [[ -z "$OMV_DETAIL" ]] && OMV_DETAIL="raw config.xml copied" else log "WARNING: /etc/openmediavault/config.xml not found — OMV config not backed up" OMV_STATUS="WARNING" OMV_DETAIL="config.xml not found at /etc/openmediavault/config.xml" fi add_section_row "OMV Configuration" "$OMV_STATUS" "$OMV_DETAIL" # ----------------------------------------------------------------------------- # SECTION 2: SYSTEM /etc # ----------------------------------------------------------------------------- log_section "Backing up /etc (system configuration)" ETC_DIR="${TMP_DIR}/etc" mkdir -p "$ETC_DIR" if rsync -a --quiet \ --exclude='/etc/mtab' \ --exclude='/etc/fstab.d' \ --exclude='/etc/blkid.tab' \ /etc/ "${ETC_DIR}/" 2>/dev/null; then log "/etc backed up successfully" add_section_row "System /etc" "OK" "rsync completed successfully" else log "WARNING: /etc rsync encountered errors" add_section_row "System /etc" "WARNING" "rsync completed with errors" fi # ----------------------------------------------------------------------------- # SECTION 3: DOCKER COMPOSE FILES & GLOBAL ENV # ----------------------------------------------------------------------------- log_section "Backing up Docker Compose files" COMPOSE_DIR="${TMP_DIR}/docker-compose" mkdir -p "$COMPOSE_DIR" if [ -d "${DOCKER_DIR}/Compose" ]; then if rsync -a --quiet \ --exclude='vaultwarden/icon_cache/' \ "${DOCKER_DIR}/Compose/" "${COMPOSE_DIR}/" 2>/dev/null; then log "Docker Compose directory backed up (Vaultwarden icon_cache excluded)" # Count compose files for the detail line COMPOSE_COUNT=$(find "${COMPOSE_DIR}" -name "docker-compose.yml" -o -name "compose.yml" 2>/dev/null | wc -l) add_section_row "Docker Compose Files" "OK" "${COMPOSE_COUNT} compose file(s) · vaultwarden/icon_cache excluded" else log "WARNING: Docker Compose rsync encountered errors" add_section_row "Docker Compose Files" "WARNING" "rsync completed with errors" fi else log "WARNING: ${DOCKER_DIR}/Compose not found — skipping" add_section_row "Docker Compose Files" "WARNING" "${DOCKER_DIR}/Compose not found" fi # ----------------------------------------------------------------------------- # SECTION 4: APP DATA (Pi-hole, Plex, Shared) # ----------------------------------------------------------------------------- log_section "Backing up application data" APPDATA_DIR="${TMP_DIR}/app-data" mkdir -p "$APPDATA_DIR" APPDATA_WARN=0 APPDATA_DETAIL="" # Pi-hole if [ -d "${DOCKER_DIR}/pihole" ]; then if rsync -a --quiet "${DOCKER_DIR}/pihole/" "${APPDATA_DIR}/pihole/" 2>/dev/null; then log "Pi-hole data backed up" APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole ✓" else log "WARNING: Pi-hole rsync encountered errors" APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole ⚠" APPDATA_WARN=$((APPDATA_WARN + 1)) fi else log "WARNING: ${DOCKER_DIR}/pihole not found — skipping" APPDATA_DETAIL="${APPDATA_DETAIL} Pi-hole —" fi # Plex (Cache, Logs, Crash Reports excluded) if [ -d "${DOCKER_DIR}/plex" ]; then if rsync -a --quiet \ --exclude='Plex Media Server/Cache/' \ --exclude='Plex Media Server/Logs/' \ --exclude='Plex Media Server/Crash Reports/' \ "${DOCKER_DIR}/plex/" "${APPDATA_DIR}/plex/" 2>/dev/null; then log "Plex data backed up (Cache, Logs, Crash Reports excluded)" APPDATA_DETAIL="${APPDATA_DETAIL} · Plex ✓" else log "WARNING: Plex rsync encountered errors" APPDATA_DETAIL="${APPDATA_DETAIL} · Plex ⚠" APPDATA_WARN=$((APPDATA_WARN + 1)) fi else log "WARNING: ${DOCKER_DIR}/plex not found — skipping" APPDATA_DETAIL="${APPDATA_DETAIL} · Plex —" fi # Shared folder if [ -d "${DOCKER_DIR}/Shared" ]; then if rsync -a --quiet "${DOCKER_DIR}/Shared/" "${APPDATA_DIR}/shared/" 2>/dev/null; then log "Shared folder backed up" APPDATA_DETAIL="${APPDATA_DETAIL} · Shared ✓" else log "WARNING: Shared rsync encountered errors" APPDATA_DETAIL="${APPDATA_DETAIL} · Shared ⚠" APPDATA_WARN=$((APPDATA_WARN + 1)) fi else log "WARNING: ${DOCKER_DIR}/Shared not found — skipping" APPDATA_DETAIL="${APPDATA_DETAIL} · Shared —" fi if [ "$APPDATA_WARN" -eq 0 ]; then add_section_row "App Data (Pi-hole / Plex / Shared)" "OK" "${APPDATA_DETAIL# }" else add_section_row "App Data (Pi-hole / Plex / Shared)" "WARNING" "$APPDATA_WARN item(s) missing or failed ·${APPDATA_DETAIL}" fi # ----------------------------------------------------------------------------- # SECTION 5: CRONTAB & SCHEDULED JOBS # ----------------------------------------------------------------------------- log_section "Backing up crontab and scheduled jobs" CRON_DIR="${TMP_DIR}/cron" mkdir -p "$CRON_DIR" CRON_WARN=0 crontab -l > "${CRON_DIR}/root-crontab.txt" 2>/dev/null \ && log "Root crontab saved" \ || { log "No root crontab found"; CRON_WARN=$((CRON_WARN + 1)); } if [ -d /var/spool/cron/crontabs ]; then cp -r /var/spool/cron/crontabs/ "${CRON_DIR}/crontabs/" \ && log "All crontabs copied" \ || { log "WARNING: crontabs copy failed"; CRON_WARN=$((CRON_WARN + 1)); } fi if [ -d /etc/cron.d ]; then cp -r /etc/cron.d/ "${CRON_DIR}/cron.d/" \ && log "cron.d copied" \ || { log "WARNING: cron.d copy failed"; CRON_WARN=$((CRON_WARN + 1)); } fi if [ "$CRON_WARN" -eq 0 ]; then add_section_row "Crontab / Scheduled Jobs" "OK" "root crontab, crontabs/, cron.d/ saved" else add_section_row "Crontab / Scheduled Jobs" "WARNING" "$CRON_WARN item(s) missing or failed to copy" fi # ----------------------------------------------------------------------------- # SECTION 6: BACKUP MANIFEST # ----------------------------------------------------------------------------- log_section "Generating backup manifest" OMV_VER=$(dpkg -l openmediavault 2>/dev/null | awk '/^ii/{print $3}' || echo "Unknown") KERNEL_VER=$(uname -r) HOST_IP=$(hostname -I | awk '{print $1}') DOCKER_VER=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || echo "Unknown") MANIFEST="${TMP_DIR}/MANIFEST.txt" cat > "$MANIFEST" </dev/null; then log "Archive created: ${BACKUP_ROOT}/${ARCHIVE_NAME}" ARCHIVE_SIZE=$(du -sh "${BACKUP_ROOT}/${ARCHIVE_NAME}" | cut -f1) log "Archive size: ${ARCHIVE_SIZE}" add_section_row "Archive Creation" "OK" "${ARCHIVE_NAME} · ${ARCHIVE_SIZE}" else log "ERROR: Archive creation failed" ARCHIVE_SIZE="N/A" add_section_row "Archive Creation" "ERROR" "tar failed — archive not created" rm -rf "$TMP_DIR" OVERALL_STATUS="FAILED" fi # ----------------------------------------------------------------------------- # SECTION 8: CLEANUP TEMP FILES # ----------------------------------------------------------------------------- log_section "Cleaning up temporary files" if rm -rf "$TMP_DIR" 2>/dev/null; then log "Temporary directory removed" else log "WARNING: Failed to remove temp directory: $TMP_DIR" fi rmdir "$TMP_BASE" 2>/dev/null && log ".tmp directory removed (empty)" || true # ----------------------------------------------------------------------------- # SECTION 9: ROTATE OLD BACKUPS # ----------------------------------------------------------------------------- log_section "Rotating old backups (keeping last ${RETAIN_COUNT})" BACKUP_LIST=$(ls -1t "${BACKUP_ROOT}"/nas08-backup-*.tar.gz 2>/dev/null || true) BACKUP_COUNT=$(echo "$BACKUP_LIST" | grep -c . || true) DELETED_COUNT=0 if [ "$BACKUP_COUNT" -gt "$RETAIN_COUNT" ]; then DELETE_LIST=$(echo "$BACKUP_LIST" | tail -n +$((RETAIN_COUNT + 1))) while IFS= read -r OLD_BACKUP; do rm -f "$OLD_BACKUP" log "Removed old backup: $(basename "$OLD_BACKUP")" DELETED_COUNT=$((DELETED_COUNT + 1)) done <<< "$DELETE_LIST" add_section_row "Backup Rotation" "OK" "Kept $RETAIN_COUNT most recent · removed $DELETED_COUNT old archive(s)" else log "Backup count (${BACKUP_COUNT}) within retention limit — no rotation needed" add_section_row "Backup Rotation" "OK" "$BACKUP_COUNT / $RETAIN_COUNT slots used — no rotation needed" fi # ----------------------------------------------------------------------------- # DONE — log summary # ----------------------------------------------------------------------------- log_section "Backup Complete" log "Archive: ${BACKUP_ROOT}/${ARCHIVE_NAME}" log "Size: ${ARCHIVE_SIZE:-N/A}" log "Status: ${OVERALL_STATUS}" log "" # ----------------------------------------------------------------------------- # BUILD & SEND HTML EMAIL (pattern: nas08_zfs_scrub.sh) # ----------------------------------------------------------------------------- # Overall banner & badge case "$OVERALL_STATUS" in SUCCESS) BADGE_HTML="
✅ SUCCESS
" BANNER_HTML="✅  Backup completed successfully — no action required." ;; WARNING) BADGE_HTML="
⚠️ WARNING
" BANNER_HTML="⚠️  Backup completed with warnings — review recommended." ;; FAILED) BADGE_HTML="
🚨 FAILED
" BANNER_HTML="🚨  Backup failed — immediate attention required!" ;; esac # Archive info card — only shown when archive was created if [[ "${ARCHIVE_SIZE:-N/A}" != "N/A" ]]; then ARCHIVE_CARD="
🗜️ Archive
Filename
$ARCHIVE_NAME
Size
$ARCHIVE_SIZE
Retained
$BACKUP_COUNT / $RETAIN_COUNT
" else ARCHIVE_CARD="" fi # System versions card VERSIONS_CARD="
🖥️ System Versions
OMV ${OMV_VER} Kernel ${KERNEL_VER}
Docker ${DOCKER_VER} IP Address ${HOST_IP}
" # Compose full HTML email HTML_BODY=$(cat <
$BANNER_HTML $ARCHIVE_CARD $VERSIONS_CARD
System Backup Monitor
💾 $HOSTNAME_LABEL
$REPORT_TIME  |  7 sections · $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)
$BADGE_HTML
📋 Backup Section Results
$SECTION_ROWS_HTML
Section Status Detail
📊 Run Summary
Host $HOSTNAME_LABEL
Run Time $REPORT_TIME
Archive ${ARCHIVE_NAME}
Archive Size ${ARCHIVE_SIZE:-N/A}
Backups on Disk $BACKUP_COUNT of $RETAIN_COUNT max retained
Warnings $WARNING_COUNT
Errors $ERROR_COUNT
Overall Status $OVERALL_STATUS
Automated Backup Monitor  |  $HOSTNAME_LABEL  |  KingDezigns Infrastructure
EOF ) # Send email via OMV Postfix (Gmail relay) SUBJECT="[Backup] $HOSTNAME_LABEL — $OVERALL_STATUS | $REPORT_TIME" { echo "To: $REPORT_TO" echo "From: $REPORT_FROM" echo "Subject: $SUBJECT" echo "MIME-Version: 1.0" echo "Content-Type: text/html; charset=UTF-8" echo "" echo "$HTML_BODY" } | sendmail -t log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" log "===== NAS08 Backup script finished ====="