Skip to main content
Back to module
Linux Administration · Automation and Access

Bash scripting

Reading1h 20mLesson 2 of 3 in this module

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 -e stops the script when a command fails. set -u treats unset variables as errors (so a typo cannot expand to an empty string). set -o pipefail makes 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:

  1. Inputs are checked before anything happens, with clear messages and a non-zero exit.
  2. set -euo pipefail stops on the first unexpected failure.
  3. 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.
  4. 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/ into rm -r / if $dir is empty. Quote everything and use set -u.
  • No checks before destructive commands such as rm, mv or 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 shellcheck on everything you write.

Try it

The Do tab has you write, break and verify your own backup script on your practice system.

Also in this lesson: