September 16Sep 16 After encountering persistent, silent exits when the CA Appdata Backup plugin made it to the Flash backup part of the process, I put together a custom backup script with Claude Code to ensure my boot drive is reliably backed up.Script HighlightsStandardized Format: Generates zips matching official naming conventions (<host>-v<version>-boot-backup-YYYYMMDD-HHMM.zip) with root relative paths.Integrity Checks: Runs unzip -t and explicitly checks for critical files (ident.cfg, super.dat) before keeping the archive.Rollback Handling: Includes a configurable toggle (EXCLUDE_PREVIOUS) to keep or skip /boot/previous files—useful if you run beta releases (like me, v7.4.0-beta.2) and want rollback data included.Failsafes & Maintenance: Features process locking, destination free-space validation, automated pruning of old archives, and Unraid GUI notifications.Flexible Execution: Can be run manually, via cron, or plugged into the CA Appdata Backup post-run hook (Settings -> Appdata Backup/Restore -> Post-run script).AI Code DisclaimerThis script was authored using Claude Code. While it is actively working in my personal setup, it has not been deeply or widely tested across different hardware configurations or Unraid versions. Please review the script, test it manually in your environment, and verify the resulting archives before relying on it for disaster recovery.Configuration RequirementsBefore running, open the script and update the following variables:DEST: Point this to your array or preferred storage location.EXCLUDE_PREVIOUS: Set to 0 to back up rollback system files, or 1 to exclude them and conserve space.#!/bin/bash # # flash-backup.sh -- local backup of the Unraid boot device (/boot, "flash") to the array. # # Intended to be called from CA Appdata Backup: # Settings -> Appdata Backup/Restore -> Post-run script # but it is standalone and can be run by hand or from cron. # # Produces, matching the historical naming convention: # <host>-v<unraid-version>-boot-backup-YYYYMMDD-HHMM.zip # e.g. nas-v7.4.0-beta.2-boot-backup-20260916-1305.zip # # Archive paths are relative to /boot (top-level entries are config/, bzimage, ...), # which matches the archives produced by the Unraid Connect flash backup. # # Exit codes: 0 = success, 1 = failure (the plugin surfaces a non-zero exit). set -uo pipefail # ---------------------------------------------------------------- configuration DEST="/mnt/user/backups/processes/unraid_dr/nas/flash" SRC="/boot" KEEP=4 # how many archives to retain (oldest pruned beyond this) # /boot/previous is the rollback copy of the PREVIOUS Unraid release. # Currently INCLUDED (EXCLUDE_PREVIOUS=0) because this server runs beta releases # and /boot/previous is the rollback path. Set to 1 to exclude it and halve the # archive size once you're back on a stable release. EXCLUDE_PREVIOUS=0 MIN_FREE_MB=4096 # refuse to start if the destination has less than this LOCK="/tmp/flash-backup.lock" NOTIFY="/usr/local/emhttp/webGui/scripts/notify" # ------------------------------------------------------------- end configuration log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } # Unraid's notify passes its arguments on the command line; an over-long -d blows # past ARG_MAX and the notification is silently lost. Keep the description short. notify() { local importance="$1" subject="$2" desc="$3" [ -x "$NOTIFY" ] || return 0 desc="${desc:0:200}" "$NOTIFY" -e "Flash backup" -s "$subject" -d "$desc" -i "$importance" >/dev/null 2>&1 } fail() { log "ERROR: $*" notify "alert" "Flash backup FAILED" "$*" exit 1 } # Only one copy at a time; the plugin and a manual run can otherwise collide. exec 9>"$LOCK" || fail "cannot create lock file $LOCK" flock -n 9 || { log "another flash backup is already running -- exiting"; exit 0; } # ---------------------------------------------------------------- sanity checks mountpoint -q "$SRC" || fail "$SRC is not mounted" [ -r "$SRC/config/ident.cfg" ] || fail "$SRC does not look like an Unraid boot device" command -v zip >/dev/null || fail "zip not found" command -v unzip >/dev/null || fail "unzip not found" mkdir -p "$DEST" || fail "cannot create destination $DEST" [ -w "$DEST" ] || fail "destination $DEST is not writable" free_mb=$(df -Pm "$DEST" | awk 'NR==2 {print $4}') [ "${free_mb:-0}" -ge "$MIN_FREE_MB" ] \ || fail "only ${free_mb}MB free at $DEST (need ${MIN_FREE_MB}MB)" # ------------------------------------------------------- derive the archive name host=$(hostname -s) [ -n "$host" ] || fail "could not determine hostname" version=$(sed -n 's/^version="\(.*\)"$/\1/p' /etc/unraid-version) [ -n "$version" ] || fail "could not read version from /etc/unraid-version" # Strip anything that has no business in a filename. host="${host//[^A-Za-z0-9._-]/_}" version="${version//[^A-Za-z0-9._-]/_}" stamp=$(date +%Y%m%d-%H%M) name="${host}-v${version}-boot-backup-${stamp}.zip" final="$DEST/$name" tmp="$DEST/.${name}.partial" [ -e "$final" ] && fail "archive already exists: $final" # ------------------------------------------------------------------- build it excludes=() (( EXCLUDE_PREVIOUS )) && excludes+=( -x 'previous/*' ) log "source : $SRC" log "destination : $final" log "excluding : $( (( EXCLUDE_PREVIOUS )) && echo 'previous/' || echo '(nothing)' )" rm -f "$tmp" start=$SECONDS # cd into /boot so stored paths are relative (config/, bzimage, ...). # -r recurse, -q quiet, -X drop extra file attributes, -y store symlinks as links. ( cd "$SRC" && zip -r -q -X -y "$tmp" . "${excludes[@]}" ) rc=$? # zip: 0 = ok, 12 = "nothing to do". Anything else is a real failure. if [ "$rc" -ne 0 ] && [ "$rc" -ne 12 ]; then rm -f "$tmp" fail "zip failed with exit code $rc" fi [ -s "$tmp" ] || { rm -f "$tmp"; fail "archive is empty"; } # ------------------------------------------------------------------- verify it log "verifying archive integrity..." if ! unzip -t -qq "$tmp" >/dev/null 2>&1; then rm -f "$tmp" fail "integrity check failed -- archive discarded" fi # The whole point is recovering the array config; refuse to keep an archive # that somehow lacks it. for must in "config/ident.cfg" "config/super.dat"; do unzip -l "$tmp" "$must" 2>/dev/null | grep -q "$must" \ || { rm -f "$tmp"; fail "archive is missing $must -- discarded"; } done mv -f "$tmp" "$final" || fail "could not move archive into place" elapsed=$(( SECONDS - start )) size=$(du -h "$final" | cut -f1) count=$(unzip -l "$final" 2>/dev/null | tail -1 | awk '{print $2}') log "created $name ($size, ${count:-?} entries) in ${elapsed}s" # --------------------------------------------------------------------- prune mapfile -t old < <(ls -1t "$DEST"/*-boot-backup-*.zip 2>/dev/null | tail -n +$(( KEEP + 1 ))) if [ "${#old[@]}" -gt 0 ]; then for f in "${old[@]}"; do log "pruning old archive: $(basename "$f")" rm -f "$f" done fi # Clean up any partials left behind by an interrupted earlier run. find "$DEST" -maxdepth 1 -name '.*.partial' -mtime +1 -delete 2>/dev/null kept=$(ls -1 "$DEST"/*-boot-backup-*.zip 2>/dev/null | wc -l) log "done -- $kept archive(s) retained in $DEST" notify "normal" "Flash backup OK" "$name ($size) in ${elapsed}s; $kept retained" exit 0
Join the conversation
You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.