the shed // linux // sysadmin

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:




backup_verify.rb

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
Get the code

Full script + README on GitHub: ruby-devops-toolkit/backup-verify

Prerequisites
  • Ruby 3.0 or newer (developed and tested against Ruby 3.0.2). Standard library only (digest, find, fileutils, optparse, json, tmpdir, open3) — nothing to gem install.
  • A POSIX tar and gzip on PATH (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).
walkthrough

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.

backup_verify.rbruby
#!/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
output

Example output

bash
$ 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

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.

Troubleshooting
  • "tar failed" with a permission error — the user running the script needs read access to every file under the source directory. Unreadable files make tar exit 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.
Extending this script
  • Encrypt the archive — pipe tar output through gpg --symmetric or shell out to age before 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 simple scp call, 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.