Zum Inhalt

Defensive Shell Scripting Foundations: Strict Mode (set -Eeuo pipefail), Quoting, [[ ]] & Cleanup Traps

Executive Summary: Unhardened shell scripts that omit set -Eeuo pipefail, leave variables unquoted (rm -rf $DIR/*), or neglect trap cleanup handlers are a leading cause of catastrophic production outages. This engineering guide establishes a defensive foundation for Bash systems scripting: strict execution flags, safe parameter expansion defaults (${var:?}), [[ ... ]] vs. [ ... ] conditionals, indexed and associative arrays, getopts CLI flag parsing, and atomic trap ... EXIT temporary directory cleanup.

  • Strict Mode (set -Eeuo pipefail)


    Abort immediately on unhandled command errors (-e), undefined variable typos (-u), and upstream pipeline failures (-o pipefail).

  • Quoting & Parameter Expansion


    Eliminate word-splitting and glob-injection bugs by quoting "${var}" and validating mandatory inputs via ${1:?Error message}.

  • Modern [[ ... ]] Conditionals


    Use Bash's lexical [[ ... ]] keyword for safe string/regex matching (=~) without word splitting or path-globbing hazards.

  • Deterministic trap Cleanup


    Guarantee that temporary credentials, lockfiles, and mktemp -d directories are scrubbed on EXIT, INT, and TERM.


1. Architecture of a Production-Grade Bash Script Header

Every production script should start with a portable #!/usr/bin/env bash shebang, strict execution flags, a safe IFS definition, and an atomic trap handler:

%%{init: {'flowchart': {'wrappingWidth': 600, 'subGraphTitleMargin': {'top': 15, 'bottom': 15}}}}%%
flowchart TB
    subgraph Bootstrap["1. Defensive Initialization"]
        Shebang["#!/usr/bin/env bash<br/>set -Eeuo pipefail"]
        TrapSetup["trap cleanup EXIT ERR<br/>mktemp -d Workspace"]
    end

    subgraph Validation["2. Input & Prerequisite Gate"]
        ArgCheck["Validate Flags (getopts)<br/>& Mandatory ${1:?}"]
        DepCheck["Verify Binaries<br/>(command -v jq curl)"]
    end

    subgraph Execution["3. Idempotent Core Logic"]
        MainFunc["main() Scoped with<br/>'local' Variables Only"]
    end

    subgraph Teardown["4. Guaranteed Exit Hook"]
        Cleanup["cleanup() Removes<br/>Temp Files & Lock"]
    end

    Bootstrap --> Validation --> Execution --> Teardown

What Each Flag in set -Eeuo pipefail Prevents

  • -e (errexit): Exits immediately if any untested command returns a non-zero exit status.
  • -E (errtrace): Ensures ERR traps are inherited by shell functions, command substitutions, and subshells.
  • -u (nounset): Treats unset variables as a fatal error. Prevents the infamous rm -rf "${BUILD_DIR}/"* bug from executing rm -rf /* when BUILD_DIR is misspelled or unset!
  • -o pipefail: Sets the return code of a pipeline a | b | c to the rightmost non-zero exit code instead of masking failures behind c.

2. Parameter Expansion Operators & Safe Array Iteration

Never use external sed/awk/basename subshells for simple string slicing when native Bash parameter expansion executes in-process in microseconds:

Expansion Syntax Behavior & Production Use Case
"${VAR:-default}" Returns default if VAR is unset or empty "" (VAR remains unchanged).
"${VAR:=default}" Assigns and returns default if VAR is unset or empty.
"${VAR:?Error message}" Aborts script immediately with Error message on stderr if VAR is unset or empty.
"${FILEPATH##*/}" Strips longest leading prefix matching */ (in-process equivalent of basename "$FILEPATH").
"${FILEPATH%/*}" Strips shortest trailing suffix matching /* (in-process equivalent of dirname "$FILEPATH").
"${FILENAME%.tar.gz}" Strips .tar.gz extension from the end of FILENAME.
Safe Array Construction & Whitespace-Safe Iteration
# Store command arguments or file lists in an Array—NEVER in a space-separated string!
BACKUP_PATHS=(
    "/etc/nginx"
    "/var/www/My Application"
    "/opt/vaultwarden"
)

# Iterate using "${ARRAY[@]}" (with double quotes!) so elements with spaces stay intact:
for target_dir in "${BACKUP_PATHS[@]}"; do
    printf "Validating path: '%s'\n" "${target_dir}"
done

3. Conditionals: [[ ... ]] vs. [ ... ] vs. (( ... ))

In Bash scripts, prefer [[ ... ]] for strings and files, and (( ... )) for integer arithmetic:

  1. [[ ... ]] is a shell keyword, not a builtin command ([ / test): Word splitting and pathname globbing do not occur inside [[ ... ]], and you can use logical &&, ||, pattern globbing (== *.conf), and POSIX Extended Regular Expressions (=~) natively.
  2. (( ... )) for Integer Comparisons: Inside (( count > 10 && status == 0 )), variables do not even need $ prefixes and standard mathematical operators (<, >, <=, >=, ==, !=) work intuitively.
Checking Files, Directories & Symlinks
CONFIG_FILE="/etc/myapp/config.yaml"

if [[ -f "${CONFIG_FILE}" && -r "${CONFIG_FILE}" ]]; then
    echo "Readable regular file exists"
fi

if [[ -d "/var/log/myapp" && -w "/var/log/myapp" ]]; then
    echo "Writable log directory exists"
fi

if [[ -L "/srv/current" ]]; then
    echo "/srv/current is a symbolic link"
fi
String Matching, Regex Captures (BASH_REMATCH) & Integer Math
RELEASE_TAG="v2.14.3"

# Native POSIX ERE matching via =~ (NOTE: Never quote the regex on the right side of =~!)
if [[ "${RELEASE_TAG}" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
    MAJOR="${BASH_REMATCH[1]}"
    MINOR="${BASH_REMATCH[2]}"
    PATCH="${BASH_REMATCH[3]}"
    echo "Parsed SemVer: Major=${MAJOR}, Minor=${MINOR}, Patch=${PATCH}"
fi

# Native Integer Arithmetic Comparison
RETRY_COUNT=3
if (( RETRY_COUNT >= 3 )); then
    echo "Maximum retries reached"
fi

4. Production Script Template (getopts, Logging, mktemp & trap)

Use the following battle-tested template as the starting point for any administrative script, and validate every script with shellcheck (sudo apt install shellcheck):

/usr/local/bin/deploy-artifact.sh (Reference Production Template)
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

readonly SCRIPT_NAME="${0##*/}"
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
WORK_DIR=""

log_info()  { printf "\033[1;34m[INFO]\033[0m  %s %s\n" "$(date -Iseconds)" "$*" >&2; }
log_error() { printf "\033[1;31m[ERROR]\033[0m %s %s\n" "$(date -Iseconds)" "$*" >&2; }

cleanup() {
    local exit_code=$?
    if [[ -n "${WORK_DIR}" && -d "${WORK_DIR}" ]]; then
        rm -rf -- "${WORK_DIR}"
    fi
    exit "${exit_code}"
}
trap cleanup EXIT

usage() {
    cat << EOF >&2
Usage: ${SCRIPT_NAME} -e <environment> [-n] <artifact_path>
  -e  Target environment (staging|production)
  -n  Dry-run mode
EOF
    exit 1
}

main() {
    local env_target=""
    local dry_run=0

    while getopts ":e:nh" opt; do
        case "${opt}" in
            e) env_target="${OPTARG}" ;;
            n) dry_run=1 ;;
            h|*) usage ;;
        esac
    done
    shift $((OPTIND - 1))

    local artifact_path="${1:?Missing required <artifact_path> argument}"
    [[ "${env_target}" =~ ^(staging|production)$ ]] || { log_error "Invalid -e environment"; usage; }
    [[ -f "${artifact_path}" ]] || { log_error "Artifact not found: ${artifact_path}"; exit 1; }

    WORK_DIR="$(mktemp -d -t "${SCRIPT_NAME}.XXXXXXXXXX")"
    log_info "Initialized isolated workspace at ${WORK_DIR} (dry_run=${dry_run})"
}

main "$@"