A maintenance script
A robust maintenance script uses set -euo pipefail to fail fast, a trap on EXIT for guaranteed cleanup, named functions for readability, and disciplined quoting throughout. These four habits together prevent the silent data-loss bugs that haunt naive scripts.
You have written one-liner shell commands for years. Now a teammate asks you to automate the nightly backup of /var/app/data with rotation — keep the last seven days, delete the rest, log what happened. You start writing, and within three lines you hit the classic trap: a missing directory silently skips the backup, a failed cp leaves half-written files, and rm -rf on an unset variable deletes the root of a production filesystem. Every one of these is a real war story. This lesson builds the script correctly from the start, using the four habits that separate safe automation from a time bomb.
After this lesson you can write a production-safe bash maintenance script using set -euo pipefail to exit on any error, trap cleanup EXIT for guaranteed cleanup on any exit path, named functions for organisation, timestamped backup rotation with find -mtime, and correct quoting throughout.
Open every script with set -euo pipefail. This one line changes bash from permissive to strict:
-e— exit immediately if any command returns a non-zero status (unless it’s part ofif,||,&&, or awhilecondition).-u— treat unset variables as errors instead of silently expanding to empty.rm -rf "$MISSING_VAR/data"expands torm -rf "/data"without-u; with it, bash exits before running the command.-o pipefail— without this, a pipeline likebroken_command | tee output.logreturns0ifteesucceeds, hiding the failure. Withpipefail, the pipeline’s exit code is the rightmost non-zero status.
#!/usr/bin/env bash
set -euo pipefailPut this on line 2 of every script that runs unattended. The shebang #!/usr/bin/env bash is preferred over #!/bin/bash because it finds the bash from PATH, which matters on systems where bash lives outside /bin.
Register a trap on EXIT for guaranteed cleanup. trap COMMAND SIGNAL runs COMMAND when the script receives SIGNAL. EXIT is special — it fires on every exit: normal completion, exit N, error exit from -e, and even SIGTERM. This makes trap cleanup EXIT the correct pattern for cleanup logic.
TMPDIR_WORK=""
cleanup() {
if [[ -n "$TMPDIR_WORK" && -d "$TMPDIR_WORK" ]]; then
rm -rf "$TMPDIR_WORK"
echo "[cleanup] removed $TMPDIR_WORK" >&2
fi
}
trap cleanup EXITOrder matters: register the trap before creating the resource. If the script fails between mktemp and trap, cleanup never runs. Always trap first, then allocate.
TMPDIR_WORK=$(mktemp -d) # safe: trap already registeredHandle arguments with validation. A script that silently ignores a missing argument or operates on the wrong path is dangerous. Check $# (argument count) early and print a usage line to stderr.
usage() {
echo "Usage: $0 <src_dir> <backup_dir> [keep_days]" >&2
exit 1
}
[[ $# -lt 2 ]] && usage
SRC="${1:?source directory required}"
DEST="${2:?backup directory required}"
KEEP="${3:-7}" # default: keep 7 days of backups${VAR:?message} is a parameter expansion that exits with an error message if VAR is empty or unset — a tighter check than a manual [[ -z ]] guard. ${VAR:-default} supplies a fallback without failing.
Always quote every variable that holds a path: "$SRC", "$DEST". Unquoted paths with spaces in them split into multiple arguments — the most common cause of “works on my laptop” failures.
Write the backup function. A function keeps the logic readable and lets you test it independently. Name functions as verbs.
do_backup() {
local src="$1"
local dest="$2"
local ts
ts=$(date +%Y%m%d_%H%M%S)
local target="${dest}/backup_${ts}"
echo "[backup] copying ${src} → ${target}" >&2
cp -a "${src}/." "${target}"
echo "[backup] done: ${target}" >&2
}local prevents variables from leaking into the global scope. cp -a (archive mode) preserves permissions, timestamps, and symlinks — the correct choice for a backup. cp -a "${src}/." "${target}" copies the contents of src into target (the trailing /. trick); using "${src}" without the slash copies the directory itself, which creates an extra nesting level.
Rotate old backups with find -mtime. -mtime +N matches files modified more than N×24 hours ago. -maxdepth 1 prevents recursion into subdirectories, which is critical when dest contains the current backup you just wrote.
rotate_backups() {
local dest="$1"
local keep="$2"
echo "[rotate] removing backups older than ${keep} days in ${dest}" >&2
find "${dest}" -maxdepth 1 -name 'backup_*' -type d -mtime "+${keep}" \
-exec rm -rf {} +
}-exec rm -rf {} + batches all matches into one rm call (more efficient than -exec ... \; which spawns a process per match). The pattern backup_* protects against accidentally deleting unrelated directories in dest.
Complete script: nightly backup with rotation.
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 <src_dir> <backup_dir> [keep_days]" >&2
exit 1
}
[[ $# -lt 2 ]] && usage
SRC="${1:?source directory required}"
DEST="${2:?backup directory required}"
KEEP="${3:-7}"
TMPDIR_WORK=""
cleanup() {
if [[ -n "$TMPDIR_WORK" && -d "$TMPDIR_WORK" ]]; then
rm -rf "$TMPDIR_WORK"
echo "[cleanup] removed $TMPDIR_WORK" >&2
fi
}
trap cleanup EXIT
do_backup() {
local src="$1"
local dest="$2"
local ts
ts=$(date +%Y%m%d_%H%M%S)
local target="${dest}/backup_${ts}"
mkdir -p "${dest}"
echo "[backup] ${src} → ${target}" >&2
cp -a "${src}/." "${target}"
echo "[backup] done" >&2
}
rotate_backups() {
local dest="$1"
local keep="$2"
echo "[rotate] removing backups older than ${keep} days" >&2
find "${dest}" -maxdepth 1 -name 'backup_*' -type d -mtime "+${keep}" \
-exec rm -rf {} +
echo "[rotate] done" >&2
}
do_backup "$SRC" "$DEST"
rotate_backups "$DEST" "$KEEP"
echo "[main] backup complete" >&2Run it: ./backup.sh /var/app/data /mnt/backups 7
The trap fires at the end regardless — in this minimal example, TMPDIR_WORK is empty, so cleanup is a no-op. Extend the script by creating a temp staging directory with TMPDIR_WORK=$(mktemp -d) before the backup, do work inside it, then mv atomically — the trap will clean up the staging dir if anything fails mid-copy.
▸Why this works
On macOS / BSD, find uses a slightly different -mtime interpretation in some edge cases, and cp -a is not available — use cp -pR instead for archive-like copies. The date +%Y%m%d_%H%M%S format works identically on both GNU and BSD date. For rotation using find -mtime, test with -print before -exec rm -rf — always verify what would be deleted.
▸Common mistake
A common mistake is registering the trap after the resource it protects. If the script fails between TMPDIR_WORK=$(mktemp -d) and trap cleanup EXIT, the temp directory leaks. Register the trap first, initialise TMPDIR_WORK="", then assign it — so the [[ -n "$TMPDIR_WORK" ]] guard in cleanup handles the empty case safely. A second common mistake: forgetting quotes around "${dest}" in the find command. A space in the path splits it into two arguments and find errors out, leaving old backups unrotated.
A script uses set -euo pipefail. The line rm -rf $WORKDIR/tmp runs and WORKDIR is unset. What happens?
A robust maintenance script rests on four habits: set -euo pipefail fails fast on errors, unset variables, and hidden pipeline failures; trap cleanup EXIT guarantees cleanup runs on every exit path; named functions with local variables keep logic readable and scoped; consistent quoting of all path variables prevents word-splitting bugs. Register the trap before allocating resources. Use ${VAR:?message} to validate required arguments and ${VAR:-default} for optional ones. For rotation, find -maxdepth 1 -mtime +N -exec rm -rf {} + is safe and efficient. These habits compose — each one removes a class of failure that would be invisible without it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.