Linux and Bash · Intermediate

A backup script that fails safely

4 min read

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 pipefail stops 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 mv it 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

rotate-backups.sh
#!/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]}")"
done

Run five times, keeping three archives, on a directory whose path contains a space:

shellsession
$ 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
clean

Points 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 tar succeeds. If tar fails, strict mode stops the script and the trap removes 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 parsing ls output 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 or find -print0.

  • Ignoring exit codes in pipelines. Without pipefail, false | true succeeds.

  • 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

  1. Add a lock with flock so two copies of the script cannot run at once, and prove it by starting two in parallel.

  2. Add a --dry-run flag that prints what would be deleted without deleting it.

Further reading