Losing a week of work because "the backup script" was actually just something you meant to write is a surprisingly common story. is one of the oldest, most bat…
Losing a week of work because "the backup script" was actually just something you meant to write is a surprisingly common story. rsync is one of the oldest, most battle-tested tools in Unix for exactly this problem — efficient, incremental file syncing that only transfers what's changed. Paired with cron, it turns backups from a task you remember (or don't) into something that just quietly happens every night. This guide builds a complete, incremental backup system from scratch.
Why rsync
Unlike a naive cp -r, rsync compares source and destination and transfers only the differences — file by file, and even block by block within a file. That makes it fast enough to run nightly even on large directories, and it preserves permissions, timestamps, and symlinks by default when used correctly. It also works equally well backing up to a local disk, an external drive, or a remote server over SSH.
Prerequisites / What You'll Need
- A Linux or macOS system with
rsync installed (rsync --version to check — it's preinstalled on most distros)
- A source directory you want backed up
- A destination: either a second local disk/mount, or a remote server reachable via SSH
- SSH key access to the remote server, if backing up offsite
- Basic comfort with cron
Step 1: Run a Basic rsync Backup Manually First
Before automating anything, run it by hand so you understand exactly what it does:
rsync -avh --delete /home/deploy/myproject/ /mnt/backup/myproject/
Breaking down the flags:
-a (archive) — preserves permissions, timestamps, symlinks, and recurses through subdirectories; this is the flag that makes rsync behave like a proper backup tool rather than a flat copy
-v — verbose output, useful while testing
-h — human-readable sizes in the output
--delete — removes files from the destination that no longer exist in the source, keeping the backup a true mirror rather than an ever-growing pile
Trailing slash matters. /home/deploy/myproject/ (with the trailing slash) copies the contents of the directory into the destination. Without it, rsync creates a myproject subdirectory inside the destination instead. This trips up almost everyone at least once.
Step 2: Back Up to a Remote Server over SSH
For offsite protection, point rsync at a remote host instead of a local path:
rsync -avh --delete -e ssh /home/deploy/myproject/ deploy@backup-server:/mnt/backups/myproject/
The -e ssh flag tells rsync to tunnel the transfer through SSH, so it's encrypted in transit. Set up key-based authentication first so this can run unattended:
ssh-copy-id deploy@backup-server
Test that ssh deploy@backup-server logs in without a password prompt before moving on — if cron tries to run this and hits a password prompt, it'll simply hang or fail silently.
Step 3: Write a Backup Script
Wrap the command in a script so it's reusable, loggable, and easy to extend. Create /opt/scripts/backup.sh:
#!/usr/bin/env bash
set -euo pipefail
SOURCE="/home/deploy/myproject/"
DEST="deploy@backup-server:/mnt/backups/myproject/"
LOG="/var/log/rsync-backup.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting backup" >> "$LOG"
rsync -avh --delete -e ssh "$SOURCE" "$DEST" >> "$LOG" 2>&1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup complete" >> "$LOG"
Make it executable:
chmod +x /opt/scripts/backup.sh
set -euo pipefail at the top means the script exits immediately (and loudly, into the log) if rsync fails, rather than silently reporting success on a broken run.
Step 4: Add Snapshot-Style Versioning with --link-dest
A plain mirror backup only keeps the latest state — if you accidentally delete a file and don't notice for a few days, --delete will have already removed it from the backup too. rsync can create dated snapshots that share unchanged files via hard links, giving you daily restore points without duplicating storage:
#!/usr/bin/env bash
set -euo pipefail
SOURCE="/home/deploy/myproject/"
DEST_BASE="/mnt/backups/myproject"
DATE=$(date +%F)
LATEST_LINK="$DEST_BASE/latest"
mkdir -p "$DEST_BASE/$DATE"
rsync -avh --delete \
--link-dest="$LATEST_LINK" \
"$SOURCE" "$DEST_BASE/$DATE/"
rm -f "$LATEST_LINK"
ln -s "$DEST_BASE/$DATE" "$LATEST_LINK"
Each day gets its own folder, but files unchanged since the previous backup are hard-linked rather than copied — meaning a week of daily backups of a mostly-static project might only use a little more disk than a single copy, while still letting you restore any given day independently.
Step 5: Schedule It with Cron
Open the crontab for the user that should run the backup:
crontab -e
Add a line to run it every night at 2 AM:
0 2 * * * /opt/scripts/backup.sh
Confirm it's registered:
crontab -l
Step 6: Rotate Old Snapshots
If you're using the dated-snapshot approach from Step 4, add a cleanup step so storage doesn't grow forever. Append this to your script to keep only the last 14 days:
find "$DEST_BASE" -maxdepth 1 -type d -mtime +14 -exec rm -rf {} \;
Run this carefully — test it with find ... -print first (without -exec rm) to confirm it's matching exactly the directories you expect before it starts deleting anything.
Step 7: Verify Backups Actually Work
A backup you've never restored from is a backup you don't really have. Periodically test a real restore:
rsync -avh /mnt/backups/myproject/latest/ /tmp/restore-test/
diff -rq /home/deploy/myproject/ /tmp/restore-test/
An empty diff output confirms the backup is a faithful copy. Do this at least once after setting the system up, and occasionally afterward — don't wait for an actual emergency to discover a broken backup chain.
Common Pitfalls / Troubleshooting
- Forgetting the trailing slash. As noted above, this is the single most common rsync mistake and produces a nested directory structure nobody expects.
- Cron running with a different
PATH or environment. A script that works fine when run manually can fail under cron because rsync or ssh isn't found in cron's minimal environment. Use absolute paths to binaries inside scripts run via cron, or explicitly set PATH at the top of the script.
- SSH key not set up for the cron user. If your script runs as a different user than the one you tested
ssh-copy-id with, the key won't be found. Confirm which user's crontab you edited and that the same user has working passwordless SSH access.
--delete deleting more than expected. If the source path is ever wrong or temporarily empty (e.g., during a deploy), --delete will faithfully mirror that emptiness onto your backup. Consider --delete-after instead of the default --delete-during, which is slightly safer, or add a sanity check that aborts if the source directory looks suspiciously small.
- No alerting on failure. A cron job that silently fails is worse than no backup, because you assume you're covered. Add a simple check at the end of the script that pings a monitoring service (like a healthchecks.io URL) on success, so you're alerted by its absence if the job doesn't run.
- Backing up to the same physical disk as the source. This protects against accidental deletion but nothing else — a disk failure takes out both. Always have at least one backup destination that's physically separate from the source.
Wrapping Up
This setup — a tested rsync command, wrapped in a script, scheduled with cron, with snapshot rotation and a real restore test — covers what the vast majority of small projects and personal servers actually need. It's not flashy, but it's transparent, debuggable, and doesn't depend on any third-party service staying in business.
From here, reasonable next steps include pushing an additional copy to cloud storage with rclone for true offsite redundancy, and wiring the failure case into the monitoring/alerting setup if you're already running something like Prometheus and Grafana.
Further Reading