Configuration Snapshotter: What That File Looked Like Before
Runtime verified against a live filesystem: snapshot, diff, retention and list. shellcheck clean; 6 bats tests passing in CI.
The problem
The drift reporter answers "did anything change." This tool answers the question that arrives ten minutes after a yes: "what did it look like before?" They sound like the same job and are deliberately not. The drift reporter keeps hashes and inventories, enough to detect change but not to reverse it. When a config edit goes sideways, detection without a restorable copy is a smoke alarm in a house with no exits: fully informed, still on fire. The snapshotter is the exits. Before a change window, one command captures pristine copies of every watched file into a dated directory with a hash manifest, and rollback stops being reconstruction from memory and becomes a copy command.
The deeper habit this encodes is one operations work beats into you: the cheapest moment to make a backup is the moment before you need one, and that moment is always, without exception, before the change. After is too late by definition, and "the previous version is probably in someone's terminal scrollback" is a sentence I never want to say again.
Design decisions
Snapshots are immutable and dated; nothing is ever overwritten. Each run creates a new timestamped directory, ISO date format so directory listings sort into a timeline for free (a habit argued elsewhere), with an optional label for human context: 20261207T140152_before_smb_signing_change. The label turns the snapshot list into a rough change journal as a side effect, which is more change tracking than many environments have.
Every snapshot carries a hash manifest, and missing files are recorded, not skipped. A sha256 manifest written at capture time means a restore two weeks later can verify the copy is intact, and it means the snapshot testifies about what existed: a watched file absent at capture is written into the manifest as MISSING, because "this file did not exist on the 7th" is exactly the kind of fact that settles arguments later. Silence about missing files would convert the snapshot from evidence into a partial story.
Diff compares live files against the latest snapshot, read-only. The diff command answers "what has changed since I last snapshotted" in unified diff, the format every engineer already reads, without touching anything. Restore, by contrast, is deliberately not a command. The copies sit in plain directories, restoring is one cp that a human types on purpose, and a tool that can overwrite live configuration automatically is a second incident waiting for its first. This is the same observe-don't-remediate line the drift reporter holds, and it is the same line for the same reason.
Retention is bounded and pruning is automatic. Unbounded snapshot directories become a disk incident with extra steps, so each run prunes to the newest N (default ten). The count is deliberately generous relative to the size of config files, because the marginal cost of a snapshot is kilobytes and the marginal cost of the missing one is unbounded.
The script
#!/usr/bin/env bash
# License header omitted here; the full file is in the repository.
#
# config_backup_snapshotter.sh
# Dated, manifested snapshots of watched configuration files.
#
# Usage:
# config_backup_snapshotter.sh snapshot [label]
# config_backup_snapshotter.sh diff
# config_backup_snapshotter.sh list
#
# Exit codes: snapshot/list 0 success, 2 error.
# diff 0 no changes, 1 changes found, 2 error.
#
# Configuration via environment (with sane defaults):
# CONFIG_SNAPSHOT_ROOT where snapshots are stored
# CONFIG_SNAPSHOT_RETENTION how many snapshots to keep
# CONFIG_SNAPSHOT_WATCHED colon-separated list of files to watch
#
set -u
snapshot_root="${CONFIG_SNAPSHOT_ROOT:-/var/lib/config_backup_snapshotter}"
retention_count="${CONFIG_SNAPSHOT_RETENTION:-10}"
if [ -n "${CONFIG_SNAPSHOT_WATCHED:-}" ]; then
IFS=':' read -r -a watched_configuration_files <<< "${CONFIG_SNAPSHOT_WATCHED}"
else
watched_configuration_files=(
"/etc/fstab"
"/etc/exports"
"/etc/samba/smb.conf"
"/etc/chrony.conf"
)
fi
fail_run() {
printf 'ERROR: %s\n' "$1" >&2
exit 2
}
latest_snapshot_directory() {
find "${snapshot_root}" -maxdepth 1 -type d -name '2*' 2>/dev/null | sort | tail -n 1
}
command_snapshot() {
local snapshot_label="${1:-}"
local snapshot_name
snapshot_name="$(date +%Y%m%dT%H%M%S)"
[ -n "$snapshot_label" ] && snapshot_name+="_${snapshot_label}"
local snapshot_directory="${snapshot_root}/${snapshot_name}"
mkdir -p "$snapshot_directory" || fail_run "cannot create ${snapshot_directory}"
local manifest_file="${snapshot_directory}/manifest.sha256"
: > "$manifest_file"
local watched_file
for watched_file in "${watched_configuration_files[@]}"; do
if [ -f "$watched_file" ]; then
mkdir -p "${snapshot_directory}$(dirname "$watched_file")"
cp -p "$watched_file" "${snapshot_directory}${watched_file}" \
|| fail_run "copy failed: ${watched_file}"
sha256sum "$watched_file" >> "$manifest_file"
else
printf 'MISSING %s\n' "$watched_file" >> "$manifest_file"
fi
done
printf 'snapshot created: %s\n' "$snapshot_directory"
local pruned_directory
while read -r pruned_directory; do
[ -n "$pruned_directory" ] && rm -rf "$pruned_directory" \
&& printf 'pruned: %s\n' "$pruned_directory"
done < <(find "${snapshot_root}" -maxdepth 1 -type d -name '2*' 2>/dev/null | sort | head -n -"$retention_count")
}
command_diff() {
local baseline_directory
baseline_directory=$(latest_snapshot_directory)
[ -n "$baseline_directory" ] || fail_run "no snapshots exist yet; run snapshot first"
local changes_found=0
local watched_file
for watched_file in "${watched_configuration_files[@]}"; do
local snapshot_copy="${baseline_directory}${watched_file}"
if [ -f "$snapshot_copy" ] && [ -f "$watched_file" ]; then
if ! diff -u "$snapshot_copy" "$watched_file"; then
changes_found=1
fi
elif [ -f "$snapshot_copy" ] && [ ! -f "$watched_file" ]; then
printf 'DELETED since snapshot: %s\n' "$watched_file"
changes_found=1
elif [ ! -f "$snapshot_copy" ] && [ -f "$watched_file" ]; then
printf 'CREATED since snapshot: %s\n' "$watched_file"
changes_found=1
fi
done
if [ "$changes_found" -eq 0 ]; then
printf 'no changes since %s\n' "$baseline_directory"
fi
return "$changes_found"
}
command_list() {
local snapshot_directory
local file_count
# Read line by line rather than word-splitting a command substitution, so
# snapshot labels containing spaces stay intact. Mirrors the prune path.
while IFS= read -r snapshot_directory; do
[ -n "$snapshot_directory" ] || continue
file_count=$(grep -c -v '^MISSING' "${snapshot_directory}/manifest.sha256" 2>/dev/null || echo 0)
printf '%s (%s files)\n' "$snapshot_directory" "$file_count"
done < <(find "${snapshot_root}" -maxdepth 1 -type d -name '2*' 2>/dev/null | sort)
}
# Run the dispatcher only when executed directly, so tests can source the
# functions without triggering a run.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
case "${1:-}" in
snapshot) command_snapshot "${2:-}" ;;
diff) command_diff ;;
list) command_list ;;
*) printf 'usage: %s snapshot [label] | diff | list\n' "$0" >&2; exit 2 ;;
esac
fi
Using it
# Before a change window, by hand, with a label that explains itself
config_backup_snapshotter.sh snapshot before_smb_signing_change
# ... make the changes, test, and later:
config_backup_snapshotter.sh diff # what did I actually change?
config_backup_snapshotter.sh list # the accidental change journal
# Rollback is deliberately manual and boring:
cp -p /var/lib/config_backup_snapshotter/20261207T140152_before_smb_signing_change/etc/samba/smb.conf /etc/samba/smb.conf
A nightly safety-net snapshot from cron alongside the manual before-change habit costs kilobytes and means the worst case is never more than a day of drift, and the diff-versus-latest behavior keeps working as expected either way.
Known limitations
Files only, from a static watchlist shared in spirit with the drift reporter; the two lists want to be one file eventually, which is the config-file consolidation the whole toolset keeps promising. Whole-file copies, not deltas, which at config-file sizes is a feature (restores are trivial) rather than a cost. Snapshots live on the same host they protect, so this is rollback insurance, not disaster recovery; anything that must survive the host belongs in real backups. cp -p preserves mode, ownership, and timestamps but not extended ACLs or xattrs, which matters on systems using rich ACLs on config files (rare, but real).
Roadmap
A shared watchlist file consumed by both this and the drift reporter. Optional xattr and ACL preservation via a tar-based capture mode. A restore command that refuses to run without an explicit snapshot path and prints the diff it is about to apply, which might thread the needle between convenience and the observe-only principle. Off-host sync of the snapshot root, at which point this quietly becomes the config tier of a real backup strategy.