GICT knowledge base
Build Versioned File Snapshots with rsync and Cron
Published
rsync with --link-dest can build separate directory snapshots while sharing unchanged files through hard links. This differs from repeatedly running rsync --delete into one directory: a mirror propagates unwanted edits and removals, whereas retained snapshots allow selection of an earlier state.
Define the backup boundary
Use this pattern only for a deliberately selected file tree on a self-managed system. It is not a complete VPS image or a consistent database backup. Export databases through their supported tools and preserve required configuration and recovery secrets separately. A second directory on the same disk does not survive disk loss or administrator compromise; retain an independently protected copy outside that failure domain.
The example needs Bash, GNU coreutils, flock and rsync. Source and backup directories must already exist and be accessible to a dedicated authorized account. If the destination is meant to be a mounted backup device, independently verify the expected mount before every run; an ordinary directory left behind after a missing mount is not a safe substitute.
A guarded snapshot script
Save the following as a private executable script in an administrator-selected location. It accepts the source and backup root as arguments. Test on synthetic files first. The script refuses a filesystem-root source, overlapping trees and a backup root containing line breaks; it does not delete retained snapshots.
#!/usr/bin/env bash
set -euo pipefail
umask 077
test "$#" -eq 2 || { echo 'Usage: snapshot SOURCE BACKUP_ROOT' >&2; exit 2; }
source_dir=$(realpath -e -- "$1")
backup_root=$(realpath -e -- "$2")
test -d "$source_dir"
test -d "$backup_root"
case "$source_dir:$backup_root" in *$'\n'*) exit 2;; esac
test "$source_dir" != /
test "$backup_root" != /
case "$backup_root/" in "$source_dir/"*) echo 'Overlapping paths' >&2; exit 2;; esac
case "$source_dir/" in "$backup_root/"*) echo 'Overlapping paths' >&2; exit 2;; esac
exec 9>"$backup_root/.snapshot.lock"
flock -n 9 || { echo 'Snapshot already running' >&2; exit 1; }
previous=()
if test -e "$backup_root/latest" || test -L "$backup_root/latest"; then
test -L "$backup_root/latest"
previous_path=$(realpath -e -- "$backup_root/latest")
test -d "$previous_path"
test "$(dirname -- "$previous_path")" = "$backup_root"
case "$(basename -- "$previous_path")" in snapshot-*) ;; *) exit 2;; esac
previous=("--link-dest=$previous_path")
fi
pending=$(mktemp -d "$backup_root/.pending.XXXXXXXX")
rsync -a "${previous[@]}" -- "$source_dir/" "$pending/"
final="$backup_root/snapshot-$(date -u +%Y%m%dT%H%M%SZ)-${pending##*.}"
test ! -e "$final"
mv -T -- "$pending" "$final"
pointer=$(mktemp -d "$backup_root/.pointer.XXXXXXXX")
ln -s -- "$(basename -- "$final")" "$pointer/latest"
mv -Tf -- "$pointer/latest" "$backup_root/latest"
rmdir -- "$pointer"
printf 'Completed snapshot: %s\n' "$final"
A failed rsync stops before publishing the snapshot as latest. It may leave a private .pending. directory for inspection; it is not a completed recovery point. Fix the cause rather than blindly retrying and reporting success. The destination root must not be writable by untrusted users, and all writers must respect the same lock.
Do not add --inplace or edit retained snapshot files: hard-linked files share content, so writes can alter earlier snapshots too. Files that change while being copied still require application-consistent capture. Archive mode alone does not cover every ACL, extended attribute or ownership requirement.
Schedule only after manual verification
After testing the executable, paths, expected mount, permissions and restore, create one account-scoped scheduler entry using absolute paths. For example, this template runs daily at 02:00 in the scheduler’s configured timezone:
0 2 * * * /home/backupuser/bin/snapshot /srv/example-data /mnt/verified-backup >> /home/backupuser/logs/snapshot.log 2>&1
Create and protect the log directory first, arrange log rotation and alert on failures or missing completions. A lock failure is also a failure to create this scheduled snapshot. Cron does not generally replay jobs missed while the machine was off. No customer crontab is created by these examples.
Verify recovery and retention separately
Confirm a new completed directory, inspect logs and compare representative files restored into a fresh location. Test changed, deleted and hidden files across at least two snapshots; do not overwrite live data to test. Keep a documented off-server recovery route. Retention deletion needs an independently reviewed allowlisted policy; this guide intentionally does not provide an unattended recursive deletion command.
References: rsync –link-dest and archive-mode documentation and crontab semantics.
Reviewed October 15, 2026. The script was exercised on disposable local files, including unchanged hard links, changed-file isolation, removed-file retention, rejected overlap and failed-copy handling. No remote storage, customer backup or real scheduled run was tested.
Related guides
How to Restrict an SSH Key to One Command
Restrict an SSH key to one command with authorized_keys options. Protect the command and check that unwanted shell and forwarding access are denied.
Linux and CommandsSelf-Hosted Video Processing With FFmpeg: A Practical Guide
Learn how to self-host video processing on a VPS using FFmpeg, with practical commands for conversion, compression, thumbnails, and automated workflows.
Linux and CommandsHow to use nohup: Commands in Linux That Survive Terminal Closes
Keep commands running after you close the terminal. nohup examples with wget, Python, rsync, and Docker on any VPS.
Linux and CommandsHow to Fix “Too Many Open Files” Error on Linux Servers
Learn how to fix the "Too Many Open Files" error on Linux servers by understanding file descriptors, adjusting limits, and diagnosing leaks effectively.
