Emergency server help: get in touch

Backup Verify Script

A backup verify script that checks every account and database series: fresh, not shrinking, opens cleanly, and present for every expected account. Anything it cannot test is a failure.

Version
2.1.1
Last updated
October 6, 2026
Language
Bash
Tested on
AlmaLinux 9.8 with DirectAdmin 1.712 (lab test, 6 Oct 2026); AlmaLinux 9.8 with cPanel & WHM 11.138 (lab test, 5 Oct 2026); Ubuntu 24.04 (Bash 5.2, GNU tar 1.35, zstd 1.5.5) against cPanel-style, DirectAdmin-style and mysqldump fixture trees; 2.1.0 also against fixtures for a missing account list, missing accounts, two sources with the same account name and dated cPanel folders (container tests, not yet run on a production server)
License
MIT
Pricing
Free

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.

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.gz layouts are found.
  • Series: the file name without dates, times and extensions. alice.tar.gz in every dated run is one series; db_2026-09-29.sql.gz and db_2026-09-30.sql.gz are another.
  • Per series: newest file younger than --max-age, not tiny, and not more than --shrink percent 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, a tar listing for plain .tar, the Dump completed trailer for .sql.
  • **--deep**: archives are decompressed and listed through tar, 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/users or 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.

Terminal output of bash backup-verify.sh /root/srvs-lab/bk --deep --accounts-file /root/srvs-lab/accounts.txt --require-coverage on AlmaLinux 9.8 with cPanel and WHM 11.138
bash backup-verify.sh /root/srvs-lab/bk --deep --accounts-file /root/srvs-lab/accounts.txt --require-coverage — exit code 0, 4.8 s. AlmaLinux 9.8, cPanel & WHM 11.138, 5 Oct 2026. IP addresses 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.

Terminal output of da admin-backup and backup-verify.sh on DirectAdmin 1.712
da admin-backup, then bash backup-verify.sh –deep (2.1.1) — exit codes 0, 0. DirectAdmin 1.712, AlmaLinux 9.8, 6 Oct 2026.

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-only skips the coverage check, and the summary then says that coverage was not verified (--no-coverage still 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 check or restic check for 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-only mode 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 cPanel DATE/accounts/USER.tar.gz layouts 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 — --deep checks the exit status of every pipeline stage; .tar.bz2 and .sql.zst support; summary counts; --all, --min-size, --no-color, --help, --version.
  • 1.0.0 — Initial release.

Backup verify script at a glance

Backup Verify Script summary card: Every hosting company has a story about the backup job that ran for months, reported success, and produced 0-byte files…
In short: 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.

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

backup-verify.shDownload
#!/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
Version 2.1.1 · SHA-256 d5f31c76f0228b27741187e0002c836f85f5881bf1e496d6dbb2eb0922db1515
Download and verify on Linux or macOS
curl -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 -c
Download and verify in Windows PowerShell
Invoke-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.

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-only mode.
  • 2.1.0 — The summary states the test level (quick or deep) and the coverage result; 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.
  • 2.0.0 — Recursive search (--depth, default 5) so cPanel DATE/accounts/USER.tar.gz layouts 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 — --deep checks the exit status of every pipeline stage; .tar.bz2 and .sql.zst support; summary counts; --all, --min-size, --no-color, --help, --version.
  • 1.0.0 — Initial release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.