Write a bash backup script that checks its inputs, stops on errors and verifies its own result.
Why it matters
Anything you type more than twice is a candidate for a script. Administrators script backups, user setup, log clean-up, health checks and deployments. A good script does the same thing the same way every time, which is more reliable than a person following notes at 2 a.m. A bad script, one that keeps going after an error, can do real damage quickly. This lesson focuses on writing scripts that check before they act.
Anatomy of a script
#!/usr/bin/env bash
# backup.sh: archive a folder into a dated tar.gz
set -euo pipefail
echo "Hello from $0"
- The first line, the shebang, tells the system which interpreter runs the file.
set -estops the script when a command fails.set -utreats unset variables as errors (so a typo cannot expand to an empty string).set -o pipefailmakes a pipeline fail if any command in it fails.- Make it executable and run it:
chmod u+x backup.sh, then./backup.sh.
The building blocks
Variables and quoting
name="web logs"
echo "Backing up $name" # double quotes expand variables
echo 'Literal $name' # single quotes do not
today=$(date +%F) # command substitution: 2026-10-23
No spaces around =. Always quote variables ("$file"), or a value containing a space becomes two arguments.
Arguments
$1, $2... are the script's arguments, $# is how many there are, and "$@" is all of them.
src="${1:-}" # first argument, or empty if missing
dest="${2:-$HOME/backups}" # default value when not given
Tests and if
if [[ -d "$src" ]]; then echo "folder exists"; fi
if [[ ! -r "$src" ]]; then echo "cannot read $src" >&2; exit 1; fi
Useful tests: -d directory, -f regular file, -e exists, -r readable, -w writable, -z empty string, -n non-empty, and -eq/-lt/-gt for numbers. >&2 sends a message to standard error, where error messages belong.
Exit codes
Every command returns an exit code: 0 means success, anything else means failure. $? holds the last one. Your scripts should exit 0 on success and a non-zero code on failure, so other tools (and cron, next lesson) can tell what happened.
Loops and functions
for f in "$dest"/*.tar.gz; do
echo "found $f"
done
log() { echo "$(date '+%F %T') $*"; }
log "starting"
Worked example: a backup script with checks
#!/usr/bin/env bash
# backup.sh SRC [DEST] - archive SRC into DEST/name-YYYY-mm-dd-HHMMSS.tar.gz
set -euo pipefail
log() { echo "$(date '+%F %T') $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
src="${1:-}"
dest="${2:-$HOME/backups}"
keep=7
[[ -n "$src" ]] || die "usage: $0 SRC [DEST]"
[[ -d "$src" ]] || die "source folder not found: $src"
[[ -r "$src" ]] || die "cannot read: $src"
mkdir -p "$dest"
[[ -w "$dest" ]] || die "cannot write to: $dest"
name="$(basename "$src")-$(date +%F-%H%M%S).tar.gz"
archive="$dest/$name"
log "archiving $src to $archive"
tar -czf "$archive" -C "$(dirname "$src")" "$(basename "$src")"
# verify: the archive must exist, be non-empty and be readable by tar
[[ -s "$archive" ]] || die "archive is empty: $archive"
tar -tzf "$archive" > /dev/null || die "archive is corrupt: $archive"
# keep only the newest $keep archives for this source
ls -1t "$dest"/"$(basename "$src")"-*.tar.gz | tail -n +$((keep + 1)) | xargs -r rm --
log "done: $(du -h "$archive" | cut -f1)"
What makes it trustworthy:
- Inputs are checked before anything happens, with clear messages and a non-zero exit.
set -euo pipefailstops on the first unexpected failure.- The result is verified: the archive exists, is not empty and can be listed by
tar. A backup you have not tested restoring is only a hope. - Deletion is narrow: it only removes old archives matching this source's name pattern, in the destination folder.
Run it:
./backup.sh ~/projects ~/backups
./backup.sh /does/not/exist ; echo "exit code: $?" # ERROR ... exit code: 1
Testing a restore
mkdir -p /tmp/restore-test
tar -xzf ~/backups/projects-2026-10-23-104500.tar.gz -C /tmp/restore-test
diff -r ~/projects /tmp/restore-test/projects && echo "restore matches"
Check your script with ShellCheck
shellcheck (sudo apt install shellcheck) reads a script and points out unquoted variables, common traps and portability problems. Run shellcheck backup.sh before you trust any script.
Common mistakes
- Unquoted variables, which break on spaces and can turn
rm -r $dir/intorm -r /if$diris empty. Quote everything and useset -u. - No checks before destructive commands such as
rm,mvor overwriting with>. - Relative paths in scripts that later run from a different folder. Use absolute paths or paths built from arguments.
- Ignoring exit codes, so a failed backup still prints "done".
- Backups stored on the same disk as the data in real life. For practice it is fine; at work, copy them elsewhere.
Recap
- Start scripts with a shebang and
set -euo pipefail. - Quote variables, check arguments with
[[ ... ]]tests, and send errors to stderr with a non-zero exit. - Verify what the script produced, and test restores.
- Run
shellcheckon everything you write.
Try it
The Do tab has you write, break and verify your own backup script on your practice system.