open atlas
↑ Back to track
Command line CLI · 10 · 02

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.

CLI Senior ◷ 35 min
Level
FoundationsJuniorMiddleSenior

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.

Goal

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.

1

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 of if, ||, &&, or a while condition).
  • -u — treat unset variables as errors instead of silently expanding to empty. rm -rf "$MISSING_VAR/data" expands to rm -rf "/data" without -u; with it, bash exits before running the command.
  • -o pipefail — without this, a pipeline like broken_command | tee output.log returns 0 if tee succeeds, hiding the failure. With pipefail, the pipeline’s exit code is the rightmost non-zero status.
#!/usr/bin/env bash
set -euo pipefail

Put 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.

2

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 EXIT

Order 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 registered
3

Handle 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.

4

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.

5

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.

Worked example

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" >&2

Run 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.

Check yourself
Quiz

A script uses set -euo pipefail. The line rm -rf $WORKDIR/tmp runs and WORKDIR is unset. What happens?

Recap

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.

recallapplystretch0 of 6 done

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.