Level 2 capstone: a backup script¶
Level 2 ยท Capstone ยท โฑ๏ธ 3โ6 hours ยท Prerequisites: all of Level 2: Shell scripting
Build backup.sh, a real tool you'll keep using: it archives a directory into a timestamped .tar.gz, keeps only the newest N archives, logs what it does, supports a dry-run mode, and passes ShellCheck with no warnings. It pulls together every chapter in Level 2.
The scenario¶
Alex keeps work in ~/projects: ETL code, SQL, notebooks, and a small web app. There's an external drive mounted at /mnt/backup. Alex wants one command, safe to run by hand or from a scheduler, that:
- makes a compressed snapshot of
~/projectson the drive, - never fills the drive, because old snapshots get cleaned up automatically,
- explains exactly what it did, or would do, and when,
- refuses clearly, instead of guessing, when something is wrong.
In Level 4 you'll run this script nightly with a systemd timer. Build it well now.
Requirements¶
Command line¶
| Option | Required | Meaning |
|---|---|---|
-s SOURCE |
yes | Directory to back up |
-d DEST |
yes | Directory to store archives in. Create it if it doesn't exist |
-k KEEP |
no | How many archives of SOURCE to keep, at least 1. Default: 7 |
-n |
no | Dry run: log what would happen, create and delete nothing |
-v |
no | Verbose: extra detail (debug lines) in the log |
-h |
no | Print usage to stdout and exit 0 |
Parse options with getopts in silent mode.
Behavior¶
- Validate everything before doing anything.
-sand-dare both given.KEEPis a whole number โฅ 1 (and08means 8).- SOURCE exists, is a directory, and is readable.
- DEST is not SOURCE itself and not inside SOURCE, or the backup would contain itself.
- DEST, if it exists, is a directory and is writable.
- Usage errors (bad options, missing or invalid values) print a message and a hint to stderr and exit 2. Runtime failures exit 1.
- Create the archive.
- Name:
<name>-YYYYmmdd-HHMMSS.tar.gz, where<name>is SOURCE's base name (for exampleprojects-20261002-110930.tar.gz). - The archive must contain the SOURCE directory itself as its top-level folder, not absolute paths.
tar -tzfshould showprojects/..., nothome/alex/projects/.... - A failed or interrupted run must never leave a file that looks like a valid backup. Write to a temporary name in DEST and rename it into place only when it's complete.
- Name:
- Rotate old archives.
- After a successful archive, delete the oldest archives of this SOURCE in DEST so that exactly KEEP remain.
- Never touch any other file in DEST: other sources' archives, notes, anything that doesn't match your naming pattern exactly.
- Log.
- Every log line has a timestamp and a level, for example
2026-10-02 11:09:30 [INFO ] created projects-20261002-110930.tar.gz (123KiB). - Logs go to stderr.
-vaddsDEBUGlines. - Log at least: what is being backed up and where, the archive created and its size, each deleted archive, and the total run time.
- Every log line has a timestamp and a level, for example
- Dry run (
-n).- Performs all validation.
- Logs the archive it would create and each archive it would delete, computing rotation as if the new archive existed.
- Creates, modifies, and deletes nothing, not even DEST.
- Clean up on failure.
- Use
set -euo pipefailand atrap ... EXITcleanup. - On any failure, including Ctrl+C and
kill, the temporary archive is removed and the exit status is non-zero.
- Use
- Quality.
shellcheck backup.shprints nothing. Install it withsudo apt install shellcheckif you haven't.- Works with SOURCE and DEST paths containing spaces.
- Has a header comment, a
usagefunction, and uses functions withlocalvariables.
Acceptance criteria¶
Tick each one off with a real test in a scratch directory. Don't point it at anything you care about until every box is ticked.
-
./backup.sh -hprints usage to stdout and exits 0. -
./backup.shwith no options exits 2 with a message on stderr. -
-k 0,-k abc, an unknown option like-x, and-swith no value each exit 2. - A missing SOURCE, or a SOURCE that is a file, exits 1 with a clear message.
-
-dpointing inside SOURCE is refused before anything is created. -
-nwith a DEST that doesn't exist logs "would create" and leaves no DEST behind. - A normal run creates exactly one
NAME-YYYYmmdd-HHMMSS.tar.gzin DEST. -
gzip -tpasses on the archive, andtar -tzflistsNAME/...paths. - With five older archives present and
-k 3, the dry run names exactly the three oldest as "would delete," and the real run then leaves exactly 3 archives of SOURCE: the newest ones. - Unrelated files in DEST (
notes.txt,other-20260101-000000.tar.gz) survive every run. - Pressing Ctrl+C during a large backup leaves no temporary or partial archive, and the exit status is 130.
- A file in SOURCE that you can't read (
chmod 000) makes the run fail with exit 1, and leaves no partial archive. - Every log line has a timestamp and a level, and nothing but the help text goes to stdout.
- Paths with spaces work for both SOURCE and DEST.
-
shellcheck backup.shprints nothing. -
bash -n backup.shpasses.
Stretch goals¶
Optional, but each one teaches something:
-
-l LOGFILE: also append log lines to a file. - A lock so two runs can't write to the same DEST at once (
flock). The second run should exit 1 with "already running." - Treat tar's exit status 1 ("file changed as we read it") as a warning, and 2 as a failure.
- Make Ctrl+C and
killtake effect immediately, even in the middle of a longtar(hint 6). - An automated test script that runs your acceptance checks in a temporary sandbox and prints PASS/FAIL.
Hints¶
Try each part yourself first. Open a hint only when you're stuck.
Hint 1: Structure
Build it in stages, running ShellCheck after each:
- Skeleton: shebang, header,
set -euo pipefail, defaults,usage,main "$@". parse_argswithgetopts ":s:d:k:nvh"andshift $((OPTIND - 1)).validate: every check, withusage_error(exit 2) anddie(exit 1) helpers.- Logging:
log LEVEL message, withinfo,warn,debug, anddiebuilt on it. create_archive, thenrotate, then the dry-run branches, then traps.
Keep each function small. main should read like the list above.
Hint 2: Archive paths and names
tar -czf ARCHIVE -C PARENT BASENAME changes into PARENT before adding BASENAME, so the archive contains projects/.... Get the parts with dirname -- "$src" and basename -- "$src", after normalizing SOURCE with realpath -- "$src", so . or ~/projects/ work too.
For the timestamp without running date: printf -v stamp '%(%Y%m%d-%H%M%S)T' -1. This format sorts alphabetically in time order, which makes rotation easy.
If SOURCE's name can contain odd characters, sanitize it for the archive name: name=${name//[^A-Za-z0-9._-]/_}.
Hint 3: Never leave a fake backup
tmp_archive=$(mktemp -- "$dest/.$archive.XXXXXX")
tar -czf "$tmp_archive" ...
gzip -t -- "$tmp_archive"
mv -- "$tmp_archive" "$dest/$archive"
tmp_archive=""
Put the temp file in DEST, not /tmp, so mv is an atomic rename on the same filesystem. The leading dot keeps it out of your rotation pattern. In the EXIT trap, delete $tmp_archive if it's set. Clear the variable after the mv so a successful run doesn't try to delete it.
Hint 4: Rotation
List only your archives, safely, oldest first:
mapfile -d '' -t backups < <(
find "$dest" -maxdepth 1 -type f \
-name "$name-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9].tar.gz" \
-print0 | sort -z
)
Then excess=$(( ${#backups[@]} - keep )), and delete backups[0] through backups[excess-1] if excess > 0. In dry-run mode the new archive doesn't exist yet, so add 1 to the count before computing excess.
Why not ls -t | tail? ls output breaks on unusual names, and modification times can be changed by copying. Your timestamped names are the source of truth.
Hint 5: Dry run
Decide at each state-changing step: mkdir, tar, mv, and rm. A simple pattern:
if (( dry_run )); then
info "[dry run] would delete old backup ${f##*/}"
else
rm -f -- "$f"
info "deleted old backup ${f##*/}"
fi
Don't create DEST, take a lock, or write temp files in dry-run mode. Then test: find DEST before and after a dry run must be identical.
Hint 6: Traps that work during a long tar
where on_signal logs and calls exit "$2", which fires cleanup.
There's a catch. Bash runs a trap only after the current foreground command finishes. A kill sent to your script during a 10-minute tar is handled 10 minutes later. The fix is to run tar in the background and wait for it, because wait is interrupted by signals:
tar -czf "$tmp_archive" -C "$parent" -- "$base" &
child_pid=$!
wait "$child_pid" || status=$?
child_pid=""
In cleanup, if child_pid is set, kill it and wait for it before deleting the temp file.
Hint 7: Common ShellCheck complaints you may meet
- SC2086/SC2046: quote every expansion.
- SC2155:
local x=$(cmd)โlocal x; x=$(cmd). - SC2015:
A && B || Cโ useif. - SC2064: use single quotes in
trapstrings. - SC2162:
read -r. - SC2034: a variable you set but never use. Usually a typo.
Look up any other code at https://www.shellcheck.net/wiki/SC####.
Solution¶
Finished, or truly stuck? Compare with the reference solution. It includes the full script, a section-by-section explanation, and a test plan. Your version doesn't need to match. It needs to pass every acceptance criterion.