Skip to content
Linux Administration
Lab 18 of 27·40mIntermediate

Write a script that fails safely

Take a script that silently corrupts things and fix it with strict mode, quoting, traps, and a real argument check.

You need

  • A Linux system with bash 4+

Do first

A shell script's default behaviour is to keep going after an error and carry on with an empty variable. That combination is how a backup script deletes a directory. Four habits remove almost all of it.

1. Watch the default behaviour fail

mkdir -p ~/labs/script && cd ~/labs/script
mkdir -p data && touch data/keep.txt
cat > bad.sh <<'SCRIPT'
#!/bin/bash
DEST=$1
mkdir $DEST/output
cp data/*.txt $DEST/output/
echo "copied to $DEST/output"
rm -rf $DEST/tmp
echo "done"
SCRIPT
chmod +x bad.sh
./bad.sh
ls

Run with no argument, $1 is empty. mkdir /output fails with permission denied — and the script keeps going. cp fails. Then rm -rf /tmp runs against the real /tmp, because $DEST/tmp expanded to /tmp.

It printed "done". Every step failed and the exit status was zero.

Verify

./bad.sh >/dev/null 2>&1; echo "exit=$?" # exit=0 — the script reported success after failing at every step

2. Turn on strict mode

cat > better.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail

DEST=$1
mkdir "$DEST/output"
echo "done"
SCRIPT
chmod +x better.sh
./better.sh; echo "exit=$?"

Three settings, each closing a different hole:

  • -e exits on any command that returns non-zero
  • -u treats an unset variable as an error instead of an empty string
  • -o pipefail makes a pipeline fail if any stage fails, not just the last

Without pipefail, false | true succeeds — which is why a broken curl piped into jq can look fine.

Verify

./better.sh 2>&1 | tail -n 1 # better.sh: line 4: DEST: unbound variable

3. Check arguments properly and quote everything

cat > good.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail

usage() {
  echo "usage: $(basename "$0") <destination-dir>" >&2
  exit 2
}

[[ $# -eq 1 ]] || usage
DEST=$1

[[ -d $DEST ]] || { echo "error: '$DEST' is not a directory" >&2; exit 1; }

mkdir -p "$DEST/output"
cp data/*.txt "$DEST/output/"
echo "copied $(ls -1 "$DEST/output" | wc -l) file(s) to $DEST/output"
SCRIPT
chmod +x good.sh
./good.sh; echo "exit=$?"
./good.sh /nonexistent; echo "exit=$?"
mkdir -p out && ./good.sh out; echo "exit=$?"

Every variable is quoted. An unquoted $DEST with a space in it becomes two arguments, and unquoted is also how rm -rf $DEST/tmp became rm -rf /tmp above. Usage goes to stderr and exits non-zero, so a caller can tell the difference between "wrong invocation" and "worked".

Verify

./good.sh out >/dev/null && echo "exit=$?" # exit=0

Verify

./good.sh 2>&1 >/dev/null | head -n 1 # usage: good.sh <destination-dir>

4. Clean up after yourself with a trap

A script that creates a temp directory must remove it even when it fails halfway.

cat > trapped.sh <<'SCRIPT'
#!/bin/bash
set -euo pipefail

WORK=$(mktemp -d)
cleanup() {
  local code=$?
  rm -rf "$WORK"
  if [[ $code -eq 0 ]]; then
    echo "ok, cleaned $WORK"
  else
    echo "failed ($code), cleaned $WORK"
  fi
  exit $code
}
trap cleanup EXIT

echo "working in $WORK"
touch "$WORK/artifact"
[[ "${1:-}" == "--fail" ]] && exit 7
echo "work finished"
SCRIPT
chmod +x trapped.sh
./trapped.sh
./trapped.sh --fail; echo "outer exit=$?"
ls /tmp | grep -c "^tmp\." || echo "no leftovers"

trap cleanup EXIT runs on any exit — success, error, or set -e abort. Capturing $? as the first line of the handler preserves the original status; doing anything before that overwrites it. ${1:-} is how you read a possibly-unset argument while -u is on.

Verify

./trapped.sh --fail >/dev/null 2>&1; echo "exit=$?" # exit=7 — the trap cleaned up and preserved the original status

5. Lint it, because you will miss something

sudo apt-get install -y shellcheck >/dev/null 2>&1 || true
shellcheck bad.sh || true
shellcheck good.sh && echo "good.sh is clean"

ShellCheck finds unquoted expansions, useless cat, and the $?-after-something bug. Run it in CI on every script you commit; it catches the class of fault this whole lab is about, before review.

Verify

shellcheck good.sh && echo "clean" # clean

The four habits

  1. set -euo pipefail on line two, always.
  2. Quote every expansion. "$var", "$@", "${arr[@]}".
  3. Validate arguments and exit non-zero with a message on stderr.
  4. trap cleanup EXIT whenever the script creates anything temporary.

Clean up

cd ~ && rm -rf ~/labs/script

Where this goes next

You have a script worth running unattended. Next: making something run it on a schedule, and finding out when it did not.