Why this matters
Everyone agrees backups matter; far fewer people have backups that run every night, keep a sensible history, never overwrite a good copy, and can be tested without risk. This project builds exactly that tool. It is small enough to understand completely, and it uses the habits that make any destructive script trustworthy: validate everything first, refuse to overwrite, delete only what you can identify with certainty, and offer a dry run that shows the decisions without acting on them.
The design
usage: backup.sh [-n] [-k KEEP] [-d YYYY-MM-DD] SRC DEST
backup.sh "/work/photos" /work/backups
→ /work/backups/backup-2026-11-01.tar.gz (today's date)
→ removes the oldest archives beyond the newest 7
Decisions, and why:
- One file per day, named by ISO date. The name says what is inside, sorts chronologically as plain text, and makes "already backed up today" a simple file test.
-d DATEto override the date. Useful for testing (and for this lesson's checks), and essential for reproducibility: a script that reads the clock is hard to test. Inject the clock.- Never overwrite. If today's archive exists, stop with an error. Overwriting a good backup with a broken one is the worst possible failure of a backup tool.
- Rotation by count (
-k KEEP): keep the newest N archives. Simpler and more robust than by age, because a gap in backups (machine off for a week) never deletes the last good copies. - Dry run (
-n): print exactly what would happen.
Step 1: tar, and why -C
tar (tape archive) bundles a directory tree into one file; -z compresses it with gzip:
$ tar -czf backups/test.tar.gz photos
$ tar -tzf backups/test.tar.gz
photos/
photos/summer 2026/
photos/summer 2026/beach.jpg
photos/summer 2026/sunset at pier.jpg
photos/receipts/
photos/receipts/2026-10-02 hardware.txt
photos/.albumrc
-c create, -t list, -x extract, -f FILE the archive file, -z gzip. Hidden files are included: tar walks the directory itself rather than relying on a glob.
tar stores names exactly as you give them. If the script receives /work/photos and passes it straight to tar, the archive contains work/photos/... (GNU tar strips the leading / and warns Removing leading '/' from member names). The fix is -C: change to the parent directory first, then archive just the last component:
tar -czf "$archive" -C "$(dirname -- "$src")" "$(basename -- "$src")"
For src=/work/photos that is tar -czf ... -C /work photos. The archive extracts to a single photos/ directory wherever you unpack it. dirname and basename with -- are safe for names starting with a dash; ${src%/*} and ${src##*/} would also work, except for a source without any slash (photos), where dirname correctly gives ..
The script strips one trailing slash first (src=${1%/}), so photos/ and photos behave the same.
Step 2: validate before acting
Everything that can be checked is checked before the first change to the disk:
(( $# == 2 )) || usage
[[ $date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || usage
[[ -d $src ]] || die "source not found: $1"
[[ -e $archive ]] && die ... # written as an if-statement in the solution
The order of operations matters in destructive scripts: once you start changing things, a failure halfway leaves a mess. Up-front validation makes most failures happen while nothing has been touched yet. Strict mode (set -euo pipefail) is the safety net for the rest: if tar fails (disk full, unreadable file), the script stops before rotation deletes older archives.
That last point deserves emphasis. If the new archive could not be created, deleting old ones would reduce your history for nothing. With set -e, the failed tar ends the script, and rotation never runs.
Step 3: rotation
shopt -s nullglob
archives=("$dest"/backup-[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].tar.gz)
excess=$(( ${#archives[@]} - keep ))
for (( i = 0; i < excess; i++ )); do
rm -- "${archives[i]}"
echo "removed ${archives[i]}"
done
Three ideas are packed in here:
- A precise glob.
backup-*.tar.gzwould also matchbackup-old.tar.gzorbackup-DO-NOT-DELETE.tar.gz. Spelling out the digit positions means the script only ever deletes files it created. Deleting by a loose pattern is how backup scripts destroy things they should not. - Sorted for free. bash sorts glob results, and ISO dates sort chronologically, so index 0 is the oldest. No date arithmetic, no
ls -t(which would depend on modification times, which a copy or restore can change). nullglobmakes an empty or missing DEST produce an empty array, not the literal pattern.
If excess is zero or negative the loop does not run.
Step 4: dry run done right
A dry run is only useful if it makes the same decisions as the real run. The trap is writing a separate "preview" branch that drifts from the real logic over time. Instead, keep one code path and switch only at the points that change the disk:
if [[ $dry == yes ]]; then
echo "would create $archive"
else
mkdir -p -- "$dest"
tar ...
echo "created $archive"
fi
One subtlety: in a real run the new archive exists when rotation counts files; in a dry run it does not. So the dry run adds it to the list by hand and re-sorts, and the counts agree:
if [[ $dry == yes ]]; then
archives+=("$archive")
mapfile -t archives < <(printf '%s\n' "${archives[@]}" | sort)
fi
The checks for this part compare the directory listing before and after a dry run: not a single file may change.
Running it for real
On a real system you would schedule it with cron, for example at 02:30 every night:
30 2 * * * /usr/local/bin/backup.sh -k 14 /home/ana /mnt/backup >> /var/log/backup.log 2>&1
and you would test a restore regularly (tar -xzf backup-2026-11-01.tar.gz -C /tmp/restore-test): an untested backup is a hope, not a backup. A local archive also protects only against mistakes, not against losing the disk; copy archives to another machine or medium too.
Common mistakes
- Archiving absolute paths. Restores then land in odd places. Use
tar -C. - Loose deletion patterns.
rm "$dest"/backup-*can delete unrelated files. Match exactly. - Rotating before verifying the new archive exists. Under
set -ea failed tar stops the script; without strict mode, checktar's status explicitly. - Using
ls -tor modification times to find the oldest. Times change on copy and restore; names do not. - A dry run that takes a different path. It shows decisions the real run would not make.
- Reading the clock everywhere. Pass dates in (
-d), so tests are deterministic.
Exercises
Two design quizzes, then /work/backup.sh grows in three parts. Each check builds its own source directories and archive folders (with spaces in their names) in a temporary directory and runs your script against them, including the error cases. Try your script on /work/photos and /work/backups as you go: bash backup.sh -n -k 3 -d 2026-11-01 photos backups.
Recap
tar -czf ARCHIVE -C PARENT NAMEstores a tree under its own name.- Validate arguments, inputs and "already exists" before touching the disk; strict mode stops the script before rotation if creating the archive fails.
- ISO-dated names sort chronologically, so a precise glob gives the archives oldest-first.
- Delete only names that match your exact pattern.
- A dry run shares the real code path and differs only where the disk would change.
- Inject the date (
-d) instead of reading the clock everywhere, for testability.
Going further: add a -v option that runs tar -tzf on the new archive and fails if it cannot be read back, and an exclusion list (tar --exclude='*.tmp'). Or rotate by a scheme of "7 daily, 4 weekly, 12 monthly", which is a nice exercise in date handling with date -d.