Every hosting company has a story about the backup job that ran for months, reported success, and produced 0-byte files because a disk was full or a credential expired. The only fix is a separate check, run by a separate job, that looks at the result and not at the log. This backup verify script is that check, and version 2 is built around one rule: it never reports success for something it did not actually test.
Table of Contents
It groups files into series, one per account or database across runs, and checks each series on its own. A missing tool or an unreadable directory is a problem, not a silent skip.
What it checks
- Recursive search (default depth 5), so
/backup/DATE/accounts/user.tar.gzlayouts are found. - Series: the file name without dates, times and extensions.
alice.tar.gzin every dated run is one series;db_2026-09-29.sql.gzanddb_2026-09-30.sql.gzare another. - Per series: newest file younger than
--max-age, not tiny, and not more than--shrinkpercent smaller than the previous run of the same series. - Integrity of the newest file of each series (all files with
--all):gzip/zstd/xz/bzip2 -t,unzip -t, atarlisting for plain.tar, theDump completedtrailer for.sql. - **
--deep**: archives are decompressed and listed throughtar, compressed dumps are checked for the trailer, and an error from any pipeline stage is a FAIL. - Account coverage: each expected account needs a file newer than
--max-age. The list comes from--accounts-file,--expect,/var/cpanel/usersor DirectAdmin’s user directory. A fresh file in any series counts, so to require coverage per source server, run the script once per source directory.
Usage
curl -fsSL https://srvscripts.com/get/backup-verify/ -o backup-verify.sh
bash backup-verify.sh /backup # every series, newest file tested
bash backup-verify.sh /backup --deep # also list archives, check dump trailers
bash backup-verify.sh /backup --deep --all # test every file, not only the newest (slow)
bash backup-verify.sh /backup/daily --max-age 26 --shrink 15
# off-site box: no panel here, so say which accounts must be present
bash backup-verify.sh /srv/offsite --accounts-file accounts.txt --require-coverage
bash backup-verify.sh /srv/offsite --archives-only # archive checks only; coverage reported as not checked
bash backup-verify.sh /backup -q # only problems (for cron)
The directory is the only required argument.
Sample output
== Series (age limit 26h, shrink limit 20%, deep test) ==
OK @date/accounts/alice.tar.gz: 3 file(s), newest 2h old, 197KB (prev 197KB, gzip + tar listing OK, 6 entries)
FAIL @date/accounts/bob.tar.gz: newest file is 50h old (limit 26h): /backup/2026-09-29/accounts/bob.tar.gz
FAIL @date/accounts/carol.tar.gz: newest is 69% smaller than the previous run (197KB -> 61KB): /backup/2026-09-30/accounts/carol.tar.gz
FAIL @date/accounts/dave.tar.gz: archive listing FAILED (gzip/tar returned an error): /backup/2026-09-30/accounts/dave.tar.gz
FAIL @date/accounts/erin.tar.gz: archive listing FAILED (gzip/tar returned an error): /backup/2026-09-30/accounts/erin.tar.gz
OK @date/accounts/frank.tar.zst: 3 file(s), newest 2h old, 148KB (prev 148KB, zstd + tar listing OK, 6 entries)
FAIL mysql/wpdb.sql.gz: dump has no 'Dump completed' trailer (truncated, or dumped with --skip-comments): /backup/mysql/wpdb_2026-09-30.sql.gz
== Account coverage ==
INFO Expected accounts: 7 (from accounts.txt); each needs a file newer than 26h
FAIL bob: missing from the latest run, newest backup is 50h old: /backup/2026-09-29/accounts/bob.tar.gz
FAIL grace: no backup file at all under /backup
== Summary ==
Series checked: 7 OK: 2 FAIL: 5 UNVERIFIED: 0 Scan: complete
Test level: deep test: archives listed, SQL dump trailers checked
Account coverage: 5/7 (from accounts.txt)
7 problem(s).
This shortened run is against a test tree of three dated cPanel runs: one account missing from the latest run, one that shrank by two thirds, a CRC error, a truncated tar stream inside a gzip file that still passes gzip -t (only --deep catches it), a cut-off database dump and a new account never backed up. Version 1 reported this tree as 0 problem(s). Output from version 2.1.0. Series names include the folder below the checked directory, with dated folders collapsed to @date, so the same account backed up from two different servers stays two separate series.
Tested on a real server
We ran this script on our lab server on 5 October 2026: AlmaLinux 9.8 with cPanel & WHM 11.138, MariaDB 10.11 and three WordPress test accounts. The screenshot is the real terminal output; only IP addresses are masked.

Second run, on our DirectAdmin test server (DirectAdmin 1.712, 6 October 2026): an admin-level backup made with da admin-backup (DirectAdmin now writes .tar.zst), then a deep check. This run found a bug: version 2.1.0 did not recognise DirectAdmin’s admin.root.admin file name and reported the admin account as missing. Version 2.1.1 fixes it; the screenshot is from 2.1.1.

Options
--max-age H: the newest file of each series must be younger than H hours (default 26).--shrink PCT: FAIL when a series shrinks more than PCT percent against its previous run (default 20).--min-size BYTES: FAIL for files below this size (default 1024).--min-count N: FAIL when fewer than N files are found (default 1).--depth N: search depth under the directory (default 5).--deep,--all: full archive listing; test every file of every series.--accounts-file FILE,--expect "u1 u2": expected accounts (one per line,#comments allowed).--require-coverage: FAIL (instead of UNVERIFIED) when there is no list of expected accounts.--archives-onlyskips the coverage check, and the summary then says that coverage was not verified (--no-coveragestill works as the old name).--allow-unverified: UNVERIFIED lines are still printed but do not set exit 1.-q,--no-color,-h,--version.
Running the backup verify script from cron
Exit code 0 means every series was tested and passed, 1 means at least one FAIL or UNVERIFIED line, 2 is a usage error.
15 8 * * * /root/bin/backup-verify.sh /backup -q --deep || mail -s "BACKUP PROBLEM on $(hostname)" you@example.com
Run it on the machine that receives the backups, after the backup window has closed. A file that is still being written will fail its integrity test.
Notes
The script is read-only apart from one temporary file it removes on exit. Age comes from modification times, so copy backups with rsync -a; plain cp makes every copy look fresh. Without an account list, account coverage is reported as UNVERIFIED and the exit code is 1. Use --archives-only when you only want the archive checks; the summary then states exactly what was checked and never claims that every backup was verified.
Limitations
- Only files are checked. cPanel incremental backups (one directory per account) and Borg or restic repositories are not; use
borg checkorrestic checkfor those. - Daily, weekly and monthly runs under one directory merge into one series per account. Point the script at each retention directory separately if you want a per-retention age limit.
- Series grouping relies on date stamps in the file or directory names. Names it cannot parse become one-file series, which then fail the age check: noisy, never silent.
- A terminated account keeps failing the age check until its old runs are pruned.
- An archive that lists cleanly can still hold bad data. Only a real restore proves that.
- No JSON output yet.
Changelog
- 2.1.1 — DirectAdmin admin-level and reseller backups (admin.root.NAME, reseller.CREATOR.NAME) now count toward account coverage; 2.1.0 reported those accounts as missing (found on a DirectAdmin 1.712 test server).
- 2.1.0 — Without a list of expected accounts, account coverage is UNVERIFIED (exit 1) instead of a warning followed by “All backups verified”. New
--archives-onlymode for archive checks only. - 2.1.0 — The summary states the test level (quick or deep) and the account coverage result, and the success line only claims the checks that ran.
- 2.1.0 — Series are keyed by folder as well as file name (dated folders collapse to
@date), so the same account from two sources is no longer merged into one series. - 2.0.0 — Recursive search (
--depth, default 5) so cPanelDATE/accounts/USER.tar.gzlayouts are found. - 2.0.0 — Checks run per series (same account or database across runs) instead of on the single newest file; size is compared with the previous run of the same series.
- 2.0.0 — Account coverage works off-site with
--accounts-file/--expect, reads DirectAdmin users too, requires a fresh file, and warns (or fails with--require-coverage) when it cannot run. - 2.0.0 — A missing zstd, xz, bzip2, unzip or gzip, or an unreadable directory, is reported as UNVERIFIED and sets exit 1 unless
--allow-unverified. - 2.0.0 —
--deepchecks the exit status of every pipeline stage;.tar.bz2and.sql.zstsupport; summary counts;--all,--min-size,--no-color,--help,--version. - 1.0.0 — Initial release.
Backup verify script at a glance

Official documentation: DirectAdmin documentation, cPanel & WHM documentation, AlmaLinux wiki.
Related guides: Install DirectAdmin on AlmaLinux 9/10 and Debian 13: Easy 2026 Guide · CSF Fork 2026: Which Reliable Replacement After ConfigServer? · Install cPanel AlmaLinux 10: Easy 2026 Checklist.
The script
#!/usr/bin/env bash
# Backup Verify Script (v2.1.1) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/backup-verify/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
# backup-verify.sh — prove every backup series exists, is fresh, is not shrinking, and opens
# https://srvscripts.com/scripts/backup-verify/ License: MIT
# Version: 2.1.0
#
# Works on any directory of backup files (cPanel /backup/DATE/accounts, DirectAdmin
# admin_backups, JetBackup local destinations, mysqldump .sql.gz, tar, zip). Read-only.
# bash backup-verify.sh /backup # cPanel/DirectAdmin: accounts read from the panel
# bash backup-verify.sh /backup --deep --all # list every archive (slow)
# bash backup-verify.sh /srv/offsite --accounts-file accounts.txt --require-coverage
# bash backup-verify.sh /srv/offsite --archives-only # archive checks only, no account coverage
# bash backup-verify.sh /backup -q # only problems (for cron)
# Exit: 0 = every check that ran passed (the summary says which checks ran),
# 1 = any FAIL or UNVERIFIED, including account coverage that could not be checked,
# 2 = usage error.
set -uo pipefail
export LC_ALL=C
VERSION=2.1.1
usage() {
cat <<'EOF'
Usage: backup-verify.sh DIR [options]
--max-age H newest file of every series must be younger than H hours (26)
--shrink PCT FAIL when a series' newest file is PCT% smaller than its previous one (20)
--min-size BYTES FAIL for backup files smaller than this (1024)
--min-count N FAIL when fewer than N backup files are found (1)
--depth N how deep to search under DIR (5)
--deep list every archive with tar/unzip and check SQL dump trailers
--all test every file of every series, not only the newest
--accounts-file F expected account names, one per line (# comments allowed)
--expect "u1 u2" expected account names on the command line
--require-coverage FAIL (not just UNVERIFIED) when there is no list of expected accounts
--archives-only check the archives only; account coverage is skipped and the summary
says so (--no-coverage is accepted as the old name)
--allow-unverified do not count UNVERIFIED (tool missing, unreadable dir) as a problem
-q, --quiet print only problems and the summary line when there are any
--no-color no colours (colour is only used on a terminal anyway)
-h, --help this help; --version print the version
Without --accounts-file or --expect, expected accounts come from /var/cpanel/users or the
DirectAdmin user list. If neither exists, account coverage is UNVERIFIED (exit 1) unless you
pass --archives-only or --allow-unverified. A fresh backup in any series counts toward
coverage, so to require coverage per source server, run once per source directory.
Series are grouped by folder below DIR (dated folder names collapsed to @date) plus the file
name without dates, so the same account name from two sources stays two separate series.
EOF
}
DIR=""; MAXAGE=26; SHRINK=20; MINSIZE=1024; MINCOUNT=1; DEPTH=5
DEEP=0; ALL=0; QUIET=0; COLOR=1; ALLOW_UNV=0; REQ_COV=0; NO_COV=0; ACCT_FILE=""; EXPECT=""
die() { echo "backup-verify: $*" >&2; exit 2; }
num() { [[ ${2:-} =~ ^[0-9]+$ ]] || die "$1 needs a whole number, got '${2:-}'"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--max-age) num "$1" "${2:-}"; MAXAGE=$2; shift 2 ;;
--shrink) num "$1" "${2:-}"; SHRINK=$2; shift 2 ;;
--min-size) num "$1" "${2:-}"; MINSIZE=$2; shift 2 ;;
--min-count) num "$1" "${2:-}"; MINCOUNT=$2; shift 2 ;;
--depth) num "$1" "${2:-}"; DEPTH=$2; shift 2 ;;
--accounts-file) [[ -n ${2:-} ]] || die "$1 needs a file"; ACCT_FILE=$2; shift 2 ;;
--expect) [[ -n ${2:-} ]] || die "$1 needs a list of accounts"; EXPECT="$EXPECT ${2//,/ }"; shift 2 ;;
--deep) DEEP=1; shift ;;
--all) ALL=1; shift ;;
--require-coverage) REQ_COV=1; shift ;;
--no-coverage|--archives-only) NO_COV=1; shift ;;
--allow-unverified) ALLOW_UNV=1; shift ;;
-q|--quiet) QUIET=1; shift ;;
--no-color) COLOR=0; shift ;;
-h|--help) usage; exit 0 ;;
--version) echo "backup-verify $VERSION"; exit 0 ;;
-*) echo "Unknown option $1" >&2; usage >&2; exit 2 ;;
*) [[ -z $DIR ]] || die "only one directory can be checked per run"; DIR=$1; shift ;;
esac
done
[[ -n $DIR ]] || { usage >&2; exit 2; }
[[ -d $DIR ]] || die "not a directory: $DIR"
[[ $DIR == /* ]] || DIR="./$DIR" # paths never start with '-' when handed to tools
(( DEPTH >= 1 )) || die "--depth must be at least 1"
[[ -z $ACCT_FILE || -r $ACCT_FILE ]] || die "cannot read accounts file: $ACCT_FILE"
# ---- output helpers ---------------------------------------------------------------------
if (( COLOR )) && [[ -t 1 ]]; then R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; N=$'\e[0m'; else R=""; G=""; Y=""; N=""; fi
PROBLEMS=0
line() { printf ' %s%-10s%s %s\n' "$1" "$2" "$N" "$3"; }
ok() { (( QUIET )) || line "$G" OK "$*"; }
info() { (( QUIET )) || line "" INFO "$*"; }
fail() { PROBLEMS=$((PROBLEMS+1)); line "$R" FAIL "$*"; }
unver() { (( ALLOW_UNV )) || PROBLEMS=$((PROBLEMS+1)); line "$Y" UNVERIFIED "$*"; }
warn() { line "$Y" WARN "$*"; }
section() { (( QUIET )) || printf '\n== %s ==\n' "$*"; }
human() { numfmt --to=iec --suffix=B "$1" 2>/dev/null || echo "$1 B"; }
need() { command -v "$1" >/dev/null 2>&1; }
# ---- find backup files ------------------------------------------------------------------
EXTS=(tar.gz tgz tar.zst tar.xz tar.bz2 tbz2 tar zip sql.gz sql.zst sql gz zst xz bz2)
name_args=(); for e in "${EXTS[@]}"; do name_args+=(-o -name "*.$e"); done
TMPD=$(mktemp -d) || die "mktemp failed"; trap 'rm -rf "$TMPD"' EXIT
mapfile -d '' -t recs < <(find "$DIR" -maxdepth "$DEPTH" -type f \( "${name_args[@]:1}" \) \
-printf '%T@\t%s\t%p\0' 2>"$TMPD/find.err" | sort -z -n)
now=$(date +%s)
# Series key = basename with the extension and any date/time stamps removed, plus the
# extension: user.tar.gz, db_2026-09-28.sql.gz -> db.sql.gz, backup-9.28.2026_14-05-33_bob.tar.gz -> backup-bob.tar.gz
# Each pattern is (non-digit or start)(STAMP)(non-digit or end); group 2 is removed.
RE_ISO='(^|[^0-9])((19|20)[0-9]{2}[-_.]?(0[1-9]|1[0-2])[-_.]?(0[1-9]|[12][0-9]|3[01])([T_ .-]?[0-2][0-9][-_:.]?[0-5][0-9]([-_:.]?[0-5][0-9])?)?)($|[^0-9])'
RE_DMY='(^|[^0-9])((0?[1-9]|[12][0-9]|3[01])[-.](0?[1-9]|[12][0-9]|3[01])[-.](19|20)[0-9]{2}([_ -][0-2][0-9][-_:.][0-5][0-9]([-_:.][0-5][0-9])?)?)($|[^0-9])'
RE_EPOCH='(^|[^0-9])(1[0-9]{9})($|[^0-9])'
RE_SEP2='[-_. ][-_. ]'; RE_SEPL='^[-_. ]'; RE_SEPR='[-_. ]$'
strip_stamps() { # sets STRIPPED: $1 without date/time stamps and stray separators
local b=$1 re
for re in "$RE_ISO" "$RE_DMY" "$RE_EPOCH"; do
while [[ $b =~ $re ]]; do b=${b/"${BASH_REMATCH[2]}"/}; done
done
while [[ $b =~ $RE_SEP2 ]]; do b=${b/"${BASH_REMATCH[0]}"/${BASH_REMATCH[0]:0:1}}; done
while [[ $b =~ $RE_SEPL ]]; do b=${b:1}; done
while [[ $b =~ $RE_SEPR ]]; do b=${b:0:${#b}-1}; done
STRIPPED=$b
}
series_key() { # sets KEY_STEM and KEY_EXT
local b=$1 e
KEY_EXT=""
for e in "${EXTS[@]}"; do [[ $b == *."$e" ]] && { KEY_EXT=$e; b=${b%."$e"}; break; }; done
strip_stamps "$b"
KEY_STEM=${STRIPPED:-[date-named]}
}
# Folder part of the series key: the path below DIR with dated folder names collapsed to @date,
# so /backup/2026-09-28/accounts and /backup/2026-09-29/accounts are one source, while
# /offsite/serverA and /offsite/serverB (or cPanel weekly/ and monthly/) stay separate.
series_dir() { # sets KEY_DIR
local rel=${1#"$DIR"} c out="" parts=()
rel=${rel#/}; KEY_DIR=""
[[ $rel == */* ]] || return 0
IFS=/ read -r -a parts <<<"${rel%/*}"
for c in "${parts[@]}"; do
[[ -n $c ]] || continue
strip_stamps "$c"; [[ -n $STRIPPED ]] || STRIPPED=@date
out+="${out:+/}$STRIPPED"
done
KEY_DIR=$out
}
declare -A S_IDX=() S_STEM=()
F_TS=(); F_SIZE=(); F_PATH=()
for i in "${!recs[@]}"; do
IFS=$'\t' read -r ts size path <<<"${recs[i]}"
F_TS[i]=${ts%.*}; F_SIZE[i]=$size; F_PATH[i]=$path
series_key "${path##*/}"; series_dir "$path"
k="${KEY_DIR:+$KEY_DIR/}$KEY_STEM.$KEY_EXT"; S_IDX[$k]+="$i "; S_STEM[$k]=$KEY_STEM
done
KEYS=(); (( ${#S_IDX[@]} )) && mapfile -t KEYS < <(printf '%s\n' "${!S_IDX[@]}" | sort)
section "Backup files"
info "Directory: $DIR Depth: $DEPTH Files: ${#recs[@]} Series: ${#KEYS[@]} Free space: $(df -hP "$DIR" 2>/dev/null | awk 'NR==2{print $4}')"
if [[ -s $TMPD/find.err ]]; then
SCAN="INCOMPLETE"; unver "find could not read part of $DIR, files there were NOT checked: $(head -1 "$TMPD/find.err")"
fi
if (( ${#recs[@]} < MINCOUNT || ${#recs[@]} == 0 )); then
fail "Only ${#recs[@]} backup file(s) found under $DIR (depth $DEPTH), expected at least $(( MINCOUNT > 0 ? MINCOUNT : 1 ))"
else
last=$(( ${#recs[@]} - 1 ))
info "Newest file: ${F_PATH[last]} ($(( (now - F_TS[last]) / 3600 ))h old)"
fi
# ---- integrity of one file: sets RES (OK|FAIL|UNVERIFIED) and MSG -----------------------
list_tar() { # decompress (if needed) and list; pipefail makes any failing stage the status
if [[ -n $1 ]]; then "$1" -dc "$2" 2>/dev/null | tar -tf - 2>/dev/null; else tar -tf "$2" 2>/dev/null; fi
}
tail_of() { # last 400 bytes of the (decompressed) file
if [[ -n $1 ]]; then "$1" -dc "$2" 2>/dev/null | tail -c 400 | tr -d '\000'; else tail -c 400 "$2" | tr -d '\000'; fi
}
verify_file() {
local f=$1 dec="" kind=raw n t
case "$f" in
*.tar.gz|*.tgz) dec=gzip; kind=tar ;; *.tar.zst) dec=zstd; kind=tar ;;
*.tar.xz) dec=xz; kind=tar ;; *.tar.bz2|*.tbz2) dec=bzip2; kind=tar ;;
*.tar) kind=tar ;; *.zip) dec=unzip; kind=zip ;;
*.sql.gz) dec=gzip; kind=sql ;; *.sql.zst) dec=zstd; kind=sql ;;
*.sql) kind=sql ;;
*.gz) dec=gzip ;; *.zst) dec=zstd ;; *.xz) dec=xz ;; *.bz2) dec=bzip2 ;;
esac
if [[ ! -r $f ]]; then RES=UNVERIFIED; MSG="file is not readable by this user, integrity NOT tested"; return; fi
if [[ -n $dec ]] && ! need "$dec"; then RES=UNVERIFIED; MSG="$dec is not installed, integrity NOT tested"; return; fi
if [[ $kind == tar ]] && ! need tar; then RES=UNVERIFIED; MSG="tar is not installed, integrity NOT tested"; return; fi
if [[ $kind == zip ]]; then # unzip -t checks the CRC of every member
if unzip -tqq "$f" >/dev/null 2>&1; then RES=OK; MSG="unzip -t OK"; else RES=FAIL; MSG="unzip -t FAILED"; fi
return
fi
if [[ $kind == tar ]] && { (( DEEP )) || [[ -z $dec ]]; }; then
if ! n=$(list_tar "$dec" "$f" | wc -l); then RES=FAIL; MSG="archive listing FAILED (${dec:+$dec/}tar returned an error)"; return; fi
if (( n == 0 )); then RES=FAIL; MSG="archive lists 0 entries"; return; fi
RES=OK; MSG="${dec:+$dec + }tar listing OK, $n entries"; return
fi
if [[ $kind == sql ]] && { (( DEEP )) || [[ -z $dec ]]; }; then
if ! t=$(tail_of "$dec" "$f"); then RES=FAIL; MSG="${dec:-read} FAILED while reading the dump"; return; fi
if [[ $t == *"Dump completed"* || $t == *"database dump complete"* ]]; then RES=OK; MSG="dump trailer found${dec:+, $dec stream OK}"
else RES=FAIL; MSG="dump has no 'Dump completed' trailer (truncated, or dumped with --skip-comments)"; fi
return
fi
if "$dec" -t "$f" >/dev/null 2>&1; then RES=OK; MSG="$dec -t OK"; else RES=FAIL; MSG="$dec -t FAILED"; fi
}
# ---- per-series checks ------------------------------------------------------------------
if (( DEEP )); then TEST_LEVEL="deep test: archives listed, SQL dump trailers checked"
else TEST_LEVEL="quick test: compression streams and zip CRCs only, archive contents not listed (use --deep)"; fi
section "Series (age limit ${MAXAGE}h, shrink limit ${SHRINK}%, $( (( DEEP )) && echo deep || echo quick) test)"
S_OK=0; S_FAIL=0; S_UNV=0
declare -A ACCT_TS=() ACCT_PATH=()
for k in "${KEYS[@]}"; do
read -r -a idx <<<"${S_IDX[$k]}"
cnt=${#idx[@]}; nw=${idx[cnt-1]}; age=$(( now - F_TS[nw] ))
nfail=0; nunv=0; detail=""
# remember the newest file per account-like name for the coverage check
a=${S_STEM[$k]}
if [[ $a =~ ^(backup|cpmove)[-_.](.+)$ ]]; then a=${BASH_REMATCH[2]}
elif [[ $a =~ ^(user|reseller|admin)\.[^.]+\.(.+)$ ]]; then a=${BASH_REMATCH[2]}; fi # DirectAdmin user.CREATOR.NAME, reseller.CREATOR.NAME, admin.root.NAME
if [[ -z ${ACCT_TS[$a]:-} ]] || (( F_TS[nw] > ACCT_TS[$a] )); then ACCT_TS[$a]=${F_TS[nw]}; ACCT_PATH[$a]=${F_PATH[nw]}; fi
if (( age > MAXAGE * 3600 )); then fail "$k: newest file is $(( age / 3600 ))h old (limit ${MAXAGE}h): ${F_PATH[nw]}"; nfail=$((nfail+1)); fi
if (( cnt > 1 )); then
pv=${idx[cnt-2]}
if (( F_SIZE[pv] > 0 )); then
drop=$(( (F_SIZE[pv] - F_SIZE[nw]) * 100 / F_SIZE[pv] ))
if (( drop > SHRINK )); then
fail "$k: newest is ${drop}% smaller than the previous run ($(human "${F_SIZE[pv]}") -> $(human "${F_SIZE[nw]}")): ${F_PATH[nw]}"; nfail=$((nfail+1))
fi
fi
detail="prev $(human "${F_SIZE[pv]}"), "
fi
if (( ALL )); then todo=("${idx[@]}"); else todo=("$nw"); fi
vmsg=""
for j in "${todo[@]}"; do
if (( F_SIZE[j] < MINSIZE )); then fail "$k: only ${F_SIZE[j]} bytes: ${F_PATH[j]}"; nfail=$((nfail+1)); continue; fi
verify_file "${F_PATH[j]}"
case $RES in
OK) [[ $j == "$nw" ]] && vmsg=$MSG ;;
FAIL) fail "$k: $MSG: ${F_PATH[j]}"; nfail=$((nfail+1)) ;;
*) unver "$k: $MSG: ${F_PATH[j]}"; nunv=$((nunv+1)) ;;
esac
done
if (( nfail )); then S_FAIL=$((S_FAIL+1))
elif (( nunv )); then S_UNV=$((S_UNV+1))
else
S_OK=$((S_OK+1))
ok "$k: $cnt file(s), newest $(( age / 3600 ))h old, $(human "${F_SIZE[nw]}") (${detail}$vmsg$( (( ALL && cnt > 1 )) && echo ", all $cnt tested"))"
fi
done
# ---- account coverage -------------------------------------------------------------------
section "Account coverage"
declare -A SEEN=(); EXP=(); src=""
add_exp() { local u; for u in "$@"; do [[ -n $u && -z ${SEEN[$u]:-} ]] && { SEEN[$u]=1; EXP+=("$u"); }; done; }
if (( NO_COV )); then
info "Account coverage not checked (--archives-only)"; COVERAGE="not checked (--archives-only)"
else
if [[ -n $ACCT_FILE ]]; then
while IFS= read -r l || [[ -n $l ]]; do l=${l%%#*}; read -r -a w <<<"$l"; add_exp "${w[@]}"; done <"$ACCT_FILE"
src="$ACCT_FILE"
fi
if [[ -n ${EXPECT// /} ]]; then read -r -a w <<<"$EXPECT"; add_exp "${w[@]}"; src="${src:+$src + }--expect"; fi
if [[ -z $src && -d /var/cpanel/users ]]; then
for p in /var/cpanel/users/*; do [[ -f $p ]] || continue; u=${p##*/}; [[ $u == system || $u == .* ]] || add_exp "$u"; done
src=/var/cpanel/users
elif [[ -z $src && -d /usr/local/directadmin/data/users ]]; then
for p in /usr/local/directadmin/data/users/*/; do [[ -d $p ]] || continue; u=${p%/}; add_exp "${u##*/}"; done
src=/usr/local/directadmin/data/users
fi
if (( ${#EXP[@]} == 0 )); then
msg="account coverage NOT checked: no list of expected accounts${src:+ ($src is empty)} - use --accounts-file or --expect, or --archives-only"
if (( REQ_COV )); then fail "$msg"; else unver "$msg"; fi
COVERAGE="UNVERIFIED (no list of expected accounts)"
else
info "Expected accounts: ${#EXP[@]} (from $src); each needs a file newer than ${MAXAGE}h"
covered=0
for u in "${EXP[@]}"; do
if [[ -z ${ACCT_TS[$u]:-} ]]; then fail "$u: no backup file at all under $DIR"
elif (( now - ACCT_TS[$u] > MAXAGE * 3600 )); then fail "$u: missing from the latest run, newest backup is $(( (now - ACCT_TS[$u]) / 3600 ))h old: ${ACCT_PATH[$u]}"
else covered=$((covered+1)); fi
done
(( covered == ${#EXP[@]} )) && ok "All ${#EXP[@]} expected accounts have a fresh backup"
COVERAGE="$covered/${#EXP[@]} (from $src)"
fi
fi
# ---- summary ----------------------------------------------------------------------------
if (( QUIET && PROBLEMS == 0 )); then exit 0; fi
section "Summary"
printf 'Series checked: %d OK: %d FAIL: %d UNVERIFIED: %d Scan: %s\n' \
"${#KEYS[@]}" "$S_OK" "$S_FAIL" "$S_UNV" "${SCAN:-complete}"
printf 'Test level: %s\nAccount coverage: %s\n' "$TEST_LEVEL" "${COVERAGE:-not checked}"
if (( PROBLEMS )); then printf '%d problem(s).\n' "$PROBLEMS"; exit 1; fi
# Success wording names exactly what was checked; it never claims more.
if (( S_UNV )); then msg="No failures, but $S_UNV series were NOT verified (--allow-unverified)."
else msg="All ${#KEYS[@]} series passed the archive checks (${TEST_LEVEL%%:*})."; fi
case ${COVERAGE:-} in
[0-9]*) msg+=" Every expected account has a fresh backup." ;;
*) msg+=" Account coverage was not verified." ;;
esac
echo "$msg"
exit 0
d5f31c76f0228b27741187e0002c836f85f5881bf1e496d6dbb2eb0922db1515curl -fsSL -o backup-verify.sh https://scr.srvscripts.com/backup-verify/backup-verify.sh && curl -fsSL https://scr.srvscripts.com/backup-verify/backup-verify.sh.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/backup-verify/backup-verify.sh' -OutFile 'backup-verify.sh'; if ((Get-FileHash 'backup-verify.sh' -Algorithm SHA256).Hash -eq 'D5F31C76F0228B27741187E0002C836F85F5881BF1E496D6DBB2EB0922DB1515') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
Why is a missing zstd a failure and not just a note?
Because the file was not tested. Install the tool, or pass --allow-unverified if you accept the gap.
How do I check backups on a remote storage server?
Run it there, pointed at the backup directory, with --accounts-file listing the accounts you expect (on a cPanel source, ls /var/cpanel/users | grep -vx system gives the list) and --require-coverage.
Is `–deep` necessary?
It is the only way to catch a truncated tar stream inside a valid gzip file. It reads every byte, so run it outside peak hours.
Why does an account I deleted keep failing?
Its older backups are still in earlier runs, so its series has no fresh file. The failure clears when retention removes those runs.