Linux and Bash · Intermediate
A backup script that fails safely
Why it matters
Bash scripting appeared in 48% of the DevOps job descriptions we read. Scripts glue systems together: backups, deployments, log clean-up. A careless script can delete the wrong files, carry on after a failed step, or leave half-written output behind. A few habits prevent almost all of that.
The concept
Strict mode:
set -euo pipefailstops on errors (-e), on unset variables (-u) and on failures inside pipelines (pipefail).Quote every variable:
"$src"`, not `$src. Unquoted variables split on spaces and expand wildcards.Validate input early and exit with a meaningful code and a message on standard error (
>&2).Clean up with
trap: run a command on exit, whatever the reason, for example to remove temporary files.Write atomically: write to a temporary file, then
mvit into place, so readers never see a half-written file.Arrays hold lists of file names safely; loop over
"${array[@]}".Lint with ShellCheck in your editor and in CI.
Worked example: rotating backups
#!/usr/bin/env bash
# Back up a directory to a dated archive and keep only the newest N archives.
set -euo pipefail
usage() { echo "usage: $0 <source-dir> <backup-dir> [keep]" >&2; exit 64; }
[[ $# -ge 2 ]] || usage
src=$1
dest=$2
keep=${3:-7}
[[ -d $src ]] || { echo "error: $src is not a directory" >&2; exit 66; }
[[ $keep =~ ^[0-9]+$ && $keep -ge 1 ]] || { echo "error: keep must be a positive number" >&2; exit 64; }
mkdir -p "$dest"
tmp=$(mktemp "$dest/.partial.XXXXXX")
trap 'rm -f "$tmp"' EXIT # never leave half-written archives behind
stamp=$(date +%Y%m%d-%H%M%S)
tar -czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
mv "$tmp" "$dest/backup-$stamp.tar.gz"
echo "created backup-$stamp.tar.gz ($(du -h "$dest/backup-$stamp.tar.gz" | cut -f1))"
# Names sort by timestamp, so glob order (alphabetical) is oldest first.
shopt -s nullglob
archives=("$dest"/backup-*.tar.gz)
excess=$(( ${#archives[@]} - keep ))
for (( i = 0; i < excess; i++ )); do
rm -- "${archives[i]}"
echo "deleted $(basename "${archives[i]}")"
doneRun five times, keeping three archives, on a directory whose path contains a space:
$ for i in 1 2 3 4 5; do ./rotate-backups.sh "data dir/app" bk 3; sleep 1; done
created backup-20260930-075542.tar.gz (4.0K)
created backup-20260930-075543.tar.gz (4.0K)
created backup-20260930-075544.tar.gz (4.0K)
created backup-20260930-075545.tar.gz (4.0K)
deleted backup-20260930-075542.tar.gz
created backup-20260930-075546.tar.gz (4.0K)
deleted backup-20260930-075543.tar.gz
$ ls bk | wc -l
3
$ ./rotate-backups.sh nope bk; echo "exit $?"
error: nope is not a directory
exit 66
$ ./rotate-backups.sh "data dir/app" bk zero; echo "exit $?"
error: keep must be a positive number
exit 64
$ shellcheck rotate-backups.sh && echo clean
cleanPoints to notice:
The path with a space works because every variable is quoted.
The archive is written to a hidden temporary file and only renamed when
tarsucceeds. Iftarfails, strict mode stops the script and thetrapremoves the partial file.Exit codes 64 (usage error) and 66 (missing input) follow the conventions in
sysexits.h, so callers and cron can tell failures apart.The first version of this script listed archives with
ls -1t | tail, and ShellCheck warned that parsinglsoutput is fragile (SC2012). The glob version above has no such problem.
Common mistakes
**
rm -rf $dir/\*` with an unset or empty variable**, which becomes `rm -rf /*`. Strict mode's `-u` and quoting protect you; `${dir:?}makes the intent explicit.Parsing
ls. Use globs orfind -print0.Ignoring exit codes in pipelines. Without
pipefail,false | truesucceeds.Scripts that grow into programs. Past about 100 lines or with complex data, switch to Python.
Interview angle
"What does set -euo pipefail do?", "why quote variables?", "how would you make a cron job safe to run twice at the same time?" (a lock with flock), and "how do you debug a script?" (bash -x, ShellCheck, small test inputs).
Practice task
Add a lock with
flockso two copies of the script cannot run at once, and prove it by starting two in parallel.Add a
--dry-runflag that prints what would be deleted without deleting it.