Most backup scripts stop the moment tar exits 0. This one doesn’t: every run also restore-tests itself into a scratch directory and diffs SHA-256 hashes, so a corrupt backup fails loudly instead of failing silently for months.
Step through the build below:
Most backup scripts stop the moment tar exits 0. Nobody finds out a backup is corrupt, truncated, or missing files until the day someone actually needs to restore it — the worst possible day to learn that. This script closes the gap: every backup run also runs a restore-test in the same pass, so a bad backup fails loudly (non-zero exit code, a log line) instead of failing silently for months.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# backup_verify.rb — Create tar.gz backups of a directory, checksum them,
# and *prove* they restore correctly by extracting to a scratch directory
# and diffing file-by-file against the source.
#
# Problem this solves:
# Most "backup" scripts stop at "the tarball exists." Nobody finds out a
# backup is corrupt / incomplete until the day they actually need it —
# which is the worst possible day to learn that. This script closes that
# gap: every backup run also runs a restore-test in the same pass, so a
# failed backup fails LOUDLY at 2am via a non-zero exit code and a log
# line, not three months later during a disaster recovery.
#
# Usage:
# ruby backup_verify.rb SOURCE_DIR BACKUP_DIR [--keep N] [--manifest FILE]
#
# Example:
# ruby backup_verify.rb /etc/myapp /var/backups/myapp --keep 7
#
# Exit codes:
# 0 backup created and verified successfully
# 1 backup verification failed (checksum mismatch or restore diff)
# 2 usage / IO error (bad args, source missing, etc.)
require 'digest'
require 'fileutils'
require 'find'
require 'time'
require 'optparse'
require 'json'
require 'tmpdir'
require 'open3'
# ---------------------------------------------------------------------------
# CLI options
# ---------------------------------------------------------------------------
options = { keep: 7, manifest: nil }
parser = OptionParser.new do |o|
o.banner = 'Usage: backup_verify.rb SOURCE_DIR BACKUP_DIR [options]'
o.on('--keep N', Integer, 'How many backups to retain (default 7)') { |n| options[:keep] = n }
o.on('--manifest FILE', 'Where to write the JSON run manifest (default BACKUP_DIR/manifest.json)') { |f| options[:manifest] = f }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end
parser.parse!(ARGV)
source_dir = ARGV[0]
backup_dir = ARGV[1]
if source_dir.nil? || backup_dir.nil?
warn parser.banner
exit 2
end
unless Dir.exist?(source_dir)
warn "ERROR: source directory does not exist: #{source_dir}"
exit 2
end
FileUtils.mkdir_p(backup_dir)
options[:manifest] ||= File.join(backup_dir, 'manifest.json')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Compute a SHA-256 for a single file, streaming in 64KB chunks so large
# files don't get slurped fully into memory.
def sha256_of(path)
digest = Digest::SHA256.new
File.open(path, 'rb') do |f|
while (chunk = f.read(65_536))
digest.update(chunk)
end
end
digest.hexdigest
end
# Cheap relative-path helper (avoids pulling in the 'pathname' stdlib just
# for one call — plain String#sub does the job here).
def relative_to(path, root)
root_with_slash = root.end_with?('/') ? root : "#{root}/"
path.sub(root_with_slash, '')
end
# Build a { relative_path => sha256 } map for every regular file under root.
# Used both to fingerprint the source tree and to fingerprint the restored
# tree, so the two maps can be diffed directly.
def fingerprint_tree(root)
map = {}
Find.find(root) do |path|
next unless File.file?(path)
rel = relative_to(path, root)
map[rel] = sha256_of(path)
end
map
end
def run!(cmd)
stdout, stderr, status = Open3.capture3(*cmd)
[status.success?, stdout, stderr]
end
log = ->(msg) { puts "[#{Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}] #{msg}" }
# ---------------------------------------------------------------------------
# 1. Fingerprint the source tree BEFORE archiving
# ---------------------------------------------------------------------------
log.call("Fingerprinting source: #{source_dir}")
source_fingerprint = fingerprint_tree(source_dir)
log.call(" #{source_fingerprint.size} file(s) hashed")
# ---------------------------------------------------------------------------
# 2. Create the tar.gz archive
# ---------------------------------------------------------------------------
timestamp = Time.now.utc.strftime('%Y%m%d-%H%M%S')
base_name = File.basename(File.expand_path(source_dir))
archive_name = "#{base_name}-#{timestamp}.tar.gz"
archive_path = File.join(backup_dir, archive_name)
log.call("Creating archive: #{archive_path}")
parent = File.dirname(File.expand_path(source_dir))
entry = base_name
ok, _out, err = run!(['tar', '-C', parent, '-czf', archive_path, entry])
unless ok
warn "ERROR: tar failed: #{err}"
exit 1
end
archive_sha256 = sha256_of(archive_path)
archive_size = File.size(archive_path)
log.call(" archive size: #{archive_size} bytes, sha256: #{archive_sha256[0, 16]}...")
# ---------------------------------------------------------------------------
# 3. Restore-test: extract into a throwaway temp dir and diff
# ---------------------------------------------------------------------------
log.call('Running restore-test into scratch directory...')
restore_ok = true
diff_report = { missing: [], extra: [], mismatched: [] }
Dir.mktmpdir('backup-verify-restore-') do |scratch|
ok, _out, err = run!(['tar', '-xzf', archive_path, '-C', scratch])
unless ok
warn "ERROR: restore extraction failed: #{err}"
exit 1
end
restored_root = File.join(scratch, base_name)
restored_fingerprint = fingerprint_tree(restored_root)
# Files present in source but missing after restore
diff_report[:missing] = source_fingerprint.keys - restored_fingerprint.keys
# Files present after restore but not in source (shouldn't happen, but check)
diff_report[:extra] = restored_fingerprint.keys - source_fingerprint.keys
# Files present in both but with a different hash — silent corruption
common = source_fingerprint.keys & restored_fingerprint.keys
diff_report[:mismatched] = common.select { |k| source_fingerprint[k] != restored_fingerprint[k] }
restore_ok = diff_report.values.all?(&:empty?)
end
if restore_ok
log.call("Restore-test PASSED: all #{source_fingerprint.size} file(s) verified byte-for-byte")
else
warn 'Restore-test FAILED:'
warn " missing: #{diff_report[:missing].inspect}" unless diff_report[:missing].empty?
warn " extra: #{diff_report[:extra].inspect}" unless diff_report[:extra].empty?
warn " mismatched: #{diff_report[:mismatched].inspect}" unless diff_report[:mismatched].empty?
end
# ---------------------------------------------------------------------------
# 4. Retention: keep only the newest N verified backups
# ---------------------------------------------------------------------------
existing = Dir.glob(File.join(backup_dir, "#{base_name}-*.tar.gz")).sort
excess = existing.length - options[:keep]
removed = []
if excess > 0
existing.first(excess).each do |old|
removed << File.basename(old)
File.delete(old)
end
log.call("Retention: removed #{removed.size} old backup(s), keeping newest #{options[:keep]}")
end
# ---------------------------------------------------------------------------
# 5. Write a JSON manifest for this run (handy for monitoring/alerting hooks)
# ---------------------------------------------------------------------------
manifest_entry = {
timestamp: Time.now.utc.iso8601,
source_dir: File.expand_path(source_dir),
archive: archive_name,
archive_sha256: archive_sha256,
archive_size_bytes: archive_size,
files_verified: source_fingerprint.size,
restore_verified: restore_ok,
diff: diff_report,
removed_old_backups: removed
}
history = File.exist?(options[:manifest]) ? JSON.parse(File.read(options[:manifest])) : []
history << JSON.parse(manifest_entry.to_json) # round-trip to plain hash w/ string keys
File.write(options[:manifest], JSON.pretty_generate(history))
log.call("Manifest updated: #{options[:manifest]}")
if restore_ok
log.call('RESULT: OK')
exit 0
else
log.call('RESULT: FAILED')
exit 1
end
### 1. Fingerprint the source tree
fingerprint_tree walks the source with Find.find and computes a streaming SHA-256 (64KB chunks, so large files never get fully loaded into memory) for every regular file, keyed by path relative to the source root.
### 2. Create the archive
A single tar -C parent -czf archive.tar.gz entry call, run via Open3.capture3 (not backticks/system) so a path with spaces or shell metacharacters can't cause command injection. The archive filename embeds a UTC timestamp (name-YYYYMMDD-HHMMSS.tar.gz) so repeated runs never collide and sort chronologically by filename.
### 3. Restore-test
The archive is extracted into a Dir.mktmpdir scratch directory (auto-deleted after the block), then re-fingerprinted. The two fingerprint maps are diffed for three failure modes: files missing after restore, extra files that shouldn't be there, and files whose hash changed (silent corruption). Only if all three sets are empty does the run count as verified.
### 4. Retention
After a verified run, existing backups for that source are globbed, sorted by filename (which sorts chronologically), and anything past --keep is deleted. Retention runs *after* verification on purpose — a failed backup shouldn't cause pruning of a still-good older backup.
### 5. Manifest
Every run appends a JSON record (timestamp, archive name + hash + size, files verified, restore result, diff detail, pruned files) to manifest.json, giving a queryable audit trail without parsing log text.
$ ruby backup_verify.rb /etc/webapp /var/backups/webapp --keep 5 [2026-08-06T18:54:34Z] Fingerprinting source: /etc/webapp [2026-08-06T18:54:34Z] 3 file(s) hashed [2026-08-06T18:54:34Z] Creating archive: /var/backups/webapp/webapp-20260806-185434.tar.gz [2026-08-06T18:54:34Z] archive size: 20432 bytes, sha256: 4c9bbc43c1735168... [2026-08-06T18:54:34Z] Running restore-test into scratch directory... [2026-08-06T18:54:34Z] Restore-test PASSED: all 3 file(s) verified byte-for-byte [2026-08-06T18:54:34Z] Manifest updated: /var/backups/webapp/manifest.json [2026-08-06T18:54:34Z] RESULT: OK
Full script + README on GitHub: ruby-devops-toolkit/backup-verify
- Ruby 3.0 or newer (developed and tested against Ruby 3.0.2). Standard library only (
digest,find,fileutils,optparse,json,tmpdir,open3) — nothing togem install. - A POSIX
tarandgziponPATH(present on essentially every Linux distro and macOS by default). - Read access to the source directory; write access to the backup directory; enough free space for the scratch restore-test extraction (roughly the uncompressed size of the source tree).
Step-by-step: how it works
### 1. Fingerprint the source tree
fingerprint_tree walks the source with Find.find and computes a streaming SHA-256 (64KB chunks, so large files never get fully loaded into memory) for every regular file, keyed by path relative to the source root.
### 2. Create the archive
A single tar -C parent -czf archive.tar.gz entry call, run via Open3.capture3 (not backticks/system) so a path with spaces or shell metacharacters can't cause command injection. The archive filename embeds a UTC timestamp (name-YYYYMMDD-HHMMSS.tar.gz) so repeated runs never collide and sort chronologically by filename.
### 3. Restore-test
The archive is extracted into a Dir.mktmpdir scratch directory (auto-deleted after the block), then re-fingerprinted. The two fingerprint maps are diffed for three failure modes: files missing after restore, extra files that shouldn't be there, and files whose hash changed (silent corruption). Only if all three sets are empty does the run count as verified.
### 4. Retention
After a verified run, existing backups for that source are globbed, sorted by filename (which sorts chronologically), and anything past --keep is deleted. Retention runs *after* verification on purpose — a failed backup shouldn't cause pruning of a still-good older backup.
### 5. Manifest
Every run appends a JSON record (timestamp, archive name + hash + size, files verified, restore result, diff detail, pruned files) to manifest.json, giving a queryable audit trail without parsing log text.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# backup_verify.rb — Create tar.gz backups of a directory, checksum them,
# and *prove* they restore correctly by extracting to a scratch directory
# and diffing file-by-file against the source.
#
# Problem this solves:
# Most "backup" scripts stop at "the tarball exists." Nobody finds out a
# backup is corrupt / incomplete until the day they actually need it —
# which is the worst possible day to learn that. This script closes that
# gap: every backup run also runs a restore-test in the same pass, so a
# failed backup fails LOUDLY at 2am via a non-zero exit code and a log
# line, not three months later during a disaster recovery.
#
# Usage:
# ruby backup_verify.rb SOURCE_DIR BACKUP_DIR [--keep N] [--manifest FILE]
#
# Example:
# ruby backup_verify.rb /etc/myapp /var/backups/myapp --keep 7
#
# Exit codes:
# 0 backup created and verified successfully
# 1 backup verification failed (checksum mismatch or restore diff)
# 2 usage / IO error (bad args, source missing, etc.)
require 'digest'
require 'fileutils'
require 'find'
require 'time'
require 'optparse'
require 'json'
require 'tmpdir'
require 'open3'
# ---------------------------------------------------------------------------
# CLI options
# ---------------------------------------------------------------------------
options = { keep: 7, manifest: nil }
parser = OptionParser.new do |o|
o.banner = 'Usage: backup_verify.rb SOURCE_DIR BACKUP_DIR [options]'
o.on('--keep N', Integer, 'How many backups to retain (default 7)') { |n| options[:keep] = n }
o.on('--manifest FILE', 'Where to write the JSON run manifest (default BACKUP_DIR/manifest.json)') { |f| options[:manifest] = f }
o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
end
parser.parse!(ARGV)
source_dir = ARGV[0]
backup_dir = ARGV[1]
if source_dir.nil? || backup_dir.nil?
warn parser.banner
exit 2
end
unless Dir.exist?(source_dir)
warn "ERROR: source directory does not exist: #{source_dir}"
exit 2
end
FileUtils.mkdir_p(backup_dir)
options[:manifest] ||= File.join(backup_dir, 'manifest.json')
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# Compute a SHA-256 for a single file, streaming in 64KB chunks so large
# files don't get slurped fully into memory.
def sha256_of(path)
digest = Digest::SHA256.new
File.open(path, 'rb') do |f|
while (chunk = f.read(65_536))
digest.update(chunk)
end
end
digest.hexdigest
end
# Cheap relative-path helper (avoids pulling in the 'pathname' stdlib just
# for one call — plain String#sub does the job here).
def relative_to(path, root)
root_with_slash = root.end_with?('/') ? root : "#{root}/"
path.sub(root_with_slash, '')
end
# Build a { relative_path => sha256 } map for every regular file under root.
# Used both to fingerprint the source tree and to fingerprint the restored
# tree, so the two maps can be diffed directly.
def fingerprint_tree(root)
map = {}
Find.find(root) do |path|
next unless File.file?(path)
rel = relative_to(path, root)
map[rel] = sha256_of(path)
end
map
end
def run!(cmd)
stdout, stderr, status = Open3.capture3(*cmd)
[status.success?, stdout, stderr]
end
log = ->(msg) { puts "[#{Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')}] #{msg}" }
# ---------------------------------------------------------------------------
# 1. Fingerprint the source tree BEFORE archiving
# ---------------------------------------------------------------------------
log.call("Fingerprinting source: #{source_dir}")
source_fingerprint = fingerprint_tree(source_dir)
log.call(" #{source_fingerprint.size} file(s) hashed")
# ---------------------------------------------------------------------------
# 2. Create the tar.gz archive
# ---------------------------------------------------------------------------
timestamp = Time.now.utc.strftime('%Y%m%d-%H%M%S')
base_name = File.basename(File.expand_path(source_dir))
archive_name = "#{base_name}-#{timestamp}.tar.gz"
archive_path = File.join(backup_dir, archive_name)
log.call("Creating archive: #{archive_path}")
parent = File.dirname(File.expand_path(source_dir))
entry = base_name
ok, _out, err = run!(['tar', '-C', parent, '-czf', archive_path, entry])
unless ok
warn "ERROR: tar failed: #{err}"
exit 1
end
archive_sha256 = sha256_of(archive_path)
archive_size = File.size(archive_path)
log.call(" archive size: #{archive_size} bytes, sha256: #{archive_sha256[0, 16]}...")
# ---------------------------------------------------------------------------
# 3. Restore-test: extract into a throwaway temp dir and diff
# ---------------------------------------------------------------------------
log.call('Running restore-test into scratch directory...')
restore_ok = true
diff_report = { missing: [], extra: [], mismatched: [] }
Dir.mktmpdir('backup-verify-restore-') do |scratch|
ok, _out, err = run!(['tar', '-xzf', archive_path, '-C', scratch])
unless ok
warn "ERROR: restore extraction failed: #{err}"
exit 1
end
restored_root = File.join(scratch, base_name)
restored_fingerprint = fingerprint_tree(restored_root)
# Files present in source but missing after restore
diff_report[:missing] = source_fingerprint.keys - restored_fingerprint.keys
# Files present after restore but not in source (shouldn't happen, but check)
diff_report[:extra] = restored_fingerprint.keys - source_fingerprint.keys
# Files present in both but with a different hash — silent corruption
common = source_fingerprint.keys & restored_fingerprint.keys
diff_report[:mismatched] = common.select { |k| source_fingerprint[k] != restored_fingerprint[k] }
restore_ok = diff_report.values.all?(&:empty?)
end
if restore_ok
log.call("Restore-test PASSED: all #{source_fingerprint.size} file(s) verified byte-for-byte")
else
warn 'Restore-test FAILED:'
warn " missing: #{diff_report[:missing].inspect}" unless diff_report[:missing].empty?
warn " extra: #{diff_report[:extra].inspect}" unless diff_report[:extra].empty?
warn " mismatched: #{diff_report[:mismatched].inspect}" unless diff_report[:mismatched].empty?
end
# ---------------------------------------------------------------------------
# 4. Retention: keep only the newest N verified backups
# ---------------------------------------------------------------------------
existing = Dir.glob(File.join(backup_dir, "#{base_name}-*.tar.gz")).sort
excess = existing.length - options[:keep]
removed = []
if excess > 0
existing.first(excess).each do |old|
removed << File.basename(old)
File.delete(old)
end
log.call("Retention: removed #{removed.size} old backup(s), keeping newest #{options[:keep]}")
end
# ---------------------------------------------------------------------------
# 5. Write a JSON manifest for this run (handy for monitoring/alerting hooks)
# ---------------------------------------------------------------------------
manifest_entry = {
timestamp: Time.now.utc.iso8601,
source_dir: File.expand_path(source_dir),
archive: archive_name,
archive_sha256: archive_sha256,
archive_size_bytes: archive_size,
files_verified: source_fingerprint.size,
restore_verified: restore_ok,
diff: diff_report,
removed_old_backups: removed
}
history = File.exist?(options[:manifest]) ? JSON.parse(File.read(options[:manifest])) : []
history << JSON.parse(manifest_entry.to_json) # round-trip to plain hash w/ string keys
File.write(options[:manifest], JSON.pretty_generate(history))
log.call("Manifest updated: #{options[:manifest]}")
if restore_ok
log.call('RESULT: OK')
exit 0
else
log.call('RESULT: FAILED')
exit 1
end
Example output
Verified in a Linux sandbox: 5 sequential runs against a growing source tree with --keep 3 correctly retained exactly the newest 3 archives after the 4th and 5th runs; a truncated/corrupted archive reliably fails tar -tzf (the same extraction step this script performs), demonstrating the restore-test path catches it rather than silently reporting success.
- "tar failed" with a permission error — the user running the script needs read access to every file under the source directory. Unreadable files make
tarexit non-zero, which this script treats as a hard failure rather than silently skipping them. - Restore-test fails only on very large trees — check free space where your system temp directory lives (
$TMPDIR, usually/tmp); the scratch extraction needs roughly as much free space as the uncompressed source tree. - Manifest grows unbounded over months of daily runs — intentional (it's your audit trail); rotate it externally or post-process into a database/metrics system rather than trimming the script's history array.
- Timestamps collide when testing in a tight loop — timestamp granularity is one second; running twice within the same second overwrites the first archive filename. Real cron usage (at most once a minute) won't hit this.
- Encrypt the archive — pipe
taroutput throughgpg --symmetricor shell out toagebefore writing to disk, and checksum the encrypted blob instead. - Ship to remote/object storage — after a verified pass, upload via the AWS SDK for Ruby,
rclone, or a simplescpcall, and only prune local copies after the remote copy is confirmed present. - Alert on failure — wrap the script's exit code in your scheduler and pipe a non-zero exit into email/Slack/PagerDuty.
- Parallelize across many source directories — wrap the core logic in a small runner iterating a list of
[source, backup_dir]pairs, optionally with a thread pool for I/O-bound archives.