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 neglecttrapcleanup 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,getoptsCLI flag parsing, and atomictrap ... EXITtemporary 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
trapCleanup
Guarantee that temporary credentials, lockfiles, and
mktemp -ddirectories are scrubbed onEXIT,INT, andTERM.
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): EnsuresERRtraps are inherited by shell functions, command substitutions, and subshells.-u(nounset): Treats unset variables as a fatal error. Prevents the infamousrm -rf "${BUILD_DIR}/"*bug from executingrm -rf /*whenBUILD_DIRis misspelled or unset!-o pipefail: Sets the return code of a pipelinea | b | cto the rightmost non-zero exit code instead of masking failures behindc.
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. |
# 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:
[[ ... ]]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.(( ... ))for Integer Comparisons: Inside(( count > 10 && status == 0 )), variables do not even need$prefixes and standard mathematical operators (<,>,<=,>=,==,!=) work intuitively.
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
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/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 "$@"