Bash & Automation Mindset
Shell execution model, exit codes, variables, loops, pipes, cron, and defensive scripting
The Automation Mindset
Bash scripting isnβt about memorizing syntax β itβs about thinking in terms of idempotent, observable, failure-safe operations. A script that works once but breaks on the second run (or leaves things half-done on failure) is worse than no script at all.
Shell Execution Model
When you run a command, the shell forks a child process, execβs your command, and waits for it to exit.
# Each command in a pipeline runs in its own subshellecho "hello" | tr 'a-z' 'A-Z'
# Subshells don't affect the parent(cd /tmp && ls) # current directory unchanged afterecho $PWD # still original directory
# Source runs in the SAME shell (affects current environment)source ./config.sh. ./config.sh # same thing, dot notationLogin shell vs interactive shell vs script:
- Login shell: reads
/etc/profile,~/.bash_profile - Interactive (non-login): reads
~/.bashrc - Script: reads neither β set
PATHexplicitly in scripts
Exit Codes & Error Handling
Every command exits with a code. 0 = success, anything else = failure.
# Check exit code of last commandls /nonexistentecho $? # prints 1 (or 2)
# Use in conditionalsif ls /etc/nginx/nginx.conf 2>/dev/null; then echo "nginx config exists"else echo "nginx not found"fi
# Common exit codes# 0 = success# 1 = general error# 2 = misuse of shell/command# 126 = command found but not executable# 127 = command not found# 128 = invalid exit argument# 130 = script terminated by Ctrl+C (128+2)set -e, set -u, set -o pipefail
The three most important lines in any serious script:
#!/usr/bin/env bashset -euo pipefail| Flag | Meaning |
|---|---|
set -e | Exit immediately on any error |
set -u | Error on undefined variable (instead of empty string) |
set -o pipefail | Pipe fails if any command in it fails |
# Without pipefail, this succeeds even though grep failsls /nonexistent | grep "something"echo $? # 0! (only captures grep's exit code)
# With pipefailset -o pipefaills /nonexistent | grep "something"echo $? # non-zeroVariables, Quoting, Conditionals
Variables
# Assignment β no spaces around =NAME="alice"COUNT=42
# Accessecho $NAMEecho ${NAME} # explicit braces β use this in strings
# Default valuesecho ${NAME:-"default"} # use default if unset/emptyecho ${NAME:="default"} # assign default if unset/emptyecho ${NAME:?"NAME is required"} # error if unset/empty
# Command substitutionTODAY=$(date +%Y-%m-%d)FILES=$(ls /etc/*.conf | wc -l)
# ArithmeticNUM=$((COUNT + 10))((COUNT++))Quoting Rules
NAME="John Doe"
# Double quotes β expands variables and command substitutionecho "Hello $NAME" # Hello John Doeecho "Today is $(date)" # Today is ...
# Single quotes β no expansion whatsoeverecho 'Hello $NAME' # Hello $NAME
# ALWAYS quote variables to prevent word splittingcp "$SOURCE" "$DEST" # correctcp $SOURCE $DEST # breaks if paths have spacesConditionals
# File tests[ -f /etc/passwd ] # is a regular file?[ -d /etc/nginx ] # is a directory?[ -e /etc/hosts ] # exists (any type)?[ -r /etc/shadow ] # readable?[ -x /usr/bin/curl ] # executable?[ -s /var/log/app.log ] # exists and non-empty?
# String tests[ -z "$VAR" ] # empty string?[ -n "$VAR" ] # non-empty string?[ "$A" = "$B" ] # strings equal?[ "$A" != "$B" ] # strings not equal?
# Numeric tests[ $NUM -eq 42 ] # equal[ $NUM -gt 10 ] # greater than[ $NUM -le 100 ] # less than or equal
# Combine with &&, ||[ -f "$FILE" ] && [ -r "$FILE" ]
# Prefer [[ for bash scripts (more features, fewer gotchas)[[ "$NAME" == John* ]] # glob pattern matching[[ "$NAME" =~ ^[A-Z] ]] # regex matching[[ -f "$FILE" && -r "$FILE" ]]Loops & Functions
Loops
# For loop over listfor user in alice bob charlie; do echo "Processing $user"done
# For loop over filesfor config in /etc/nginx/conf.d/*.conf; do echo "Checking: $config" nginx -t -c "$config"done
# C-style for loopfor ((i=0; i<10; i++)); do echo $idone
# While loopwhile [ $COUNT -gt 0 ]; do echo "Count: $COUNT" ((COUNT--))done
# Read file line by linewhile IFS= read -r line; do echo "Line: $line"done < /etc/hosts
# Loop with break/continuefor file in /tmp/*.tmp; do [[ -f "$file" ]] || continue # skip if not a file size=$(stat -c %s "$file") [[ $size -gt 1000000 ]] && { echo "Large file: $file"; break; } rm "$file"doneFunctions
# Define functiongreet() { local name="$1" # local = scoped to function local greeting="${2:-Hello}" echo "$greeting, $name!"}
# Call itgreet "Alice"greet "Bob" "Hi"
# Return values (only exit codes β 0/1)is_running() { pgrep -x "$1" > /dev/null 2>&1}
if is_running nginx; then echo "nginx is running"fi
# "Return" strings via echo + command substitutionget_hostname() { hostname -f}HOST=$(get_hostname)Positional Parameters
#!/usr/bin/env bash# Usage: ./deploy.sh myapp production
APPNAME="$1"ENVIRONMENT="$2"
echo "Script name: $0"echo "First arg: $1"echo "All args: $@"echo "Arg count: $#"
# Shift removes $1, moves $2 -> $1, etc.while [[ $# -gt 0 ]]; do case "$1" in --env|-e) ENV="$2" shift 2 ;; --dry-run) DRY_RUN=true shift ;; *) echo "Unknown argument: $1" exit 1 ;; esacdonePipes & Redirection
# Redirect stdoutcommand > output.txt # overwritecommand >> output.txt # append
# Redirect stderrcommand 2> error.txt
# Redirect both stdout and stderrcommand > output.txt 2>&1command &> output.txt # bash shorthand
# Discard outputcommand > /dev/nullcommand > /dev/null 2>&1 # discard both
# Pipe stdout to next commandps aux | grep nginx | grep -v grep
# Pipe stderr through pipe (bash 4+)command 2>&1 | grep "ERROR"
# tee β write to file AND stdoutcommand | tee output.txtcommand | tee -a output.txt # append
# Process substitutiondiff <(ls /etc) <(ls /tmp)Pipeline mental model: Each | creates a pipe between processes. Processes run concurrently β the right side starts reading before the left side finishes writing.
Cron & Scheduling Pitfalls
# Edit crontabcrontab -e
# List current crontabcrontab -l
# Cron syntax: min hour day month weekday# ββ minute (0-59)# β ββ hour (0-23)# β β ββ day of month (1-31)# β β β ββ month (1-12)# β β β β ββ day of week (0-7, 0 and 7 = Sunday)# β β β β β 0 2 * * 0 /opt/scripts/weekly-backup.shCron pitfalls:
| Pitfall | Fix |
|---|---|
Empty PATH β commands not found | Use full paths: /usr/bin/python3 |
| No home directory context | Set HOME and PATH at top of cron |
| Output lost (no indication of failure) | Redirect to log: >> /var/log/backup.log 2>&1 |
| Timezone surprises | Check cronβs timezone vs date in script |
| Race conditions with overlapping runs | Use flock for locking |
# Good cron entry0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# With flock to prevent overlap0 2 * * * flock -n /tmp/backup.lock /opt/scripts/backup.sh >> /var/log/backup.log 2>&1Idempotent Scripting Mindset
An idempotent script produces the same result whether run once or ten times. This is critical for automation.
# NOT idempotent β fails if dir already existsmkdir /opt/myapp
# Idempotentmkdir -p /opt/myapp
# NOT idempotent β duplicates entry on every runecho "alias ll='ls -la'" >> ~/.bashrc
# Idempotent β only adds if not presentgrep -qF "alias ll=" ~/.bashrc || echo "alias ll='ls -la'" >> ~/.bashrc
# Idempotent user creationid myapp &>/dev/null || useradd -m -s /bin/bash myapp
# Idempotent service enablesystemctl is-enabled nginx &>/dev/null || systemctl enable nginxLogging in Scripts
#!/usr/bin/env bashset -euo pipefail
LOG_FILE="/var/log/myapp-deploy.log"TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
log() { local level="$1" shift echo "[$TIMESTAMP] [$level] $*" | tee -a "$LOG_FILE"}
log_info() { log "INFO " "$@"; }log_warn() { log "WARN " "$@" >&2; }log_error() { log "ERROR" "$@" >&2; }
# Usagelog_info "Starting deployment of version $VERSION"log_warn "Skipping health check (--skip-health passed)"log_error "Failed to pull image: $IMAGE"Defensive Scripting
#!/usr/bin/env bashset -euo pipefailIFS=$'\n\t' # safer word splitting
# Script metadata at topSCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"SCRIPT_NAME="$(basename "$0")"
# Cleanup on exitcleanup() { local exit_code=$? # Remove temp files, release locks, etc. rm -f /tmp/myapp.lock [[ $exit_code -ne 0 ]] && log_error "Script failed with exit code $exit_code"}trap cleanup EXIT
# Trap specific signalstrap 'log_error "Interrupted"; exit 130' INT TERM
# Validate required toolsrequire_command() { command -v "$1" &>/dev/null || { log_error "Required command not found: $1"; exit 1; }}require_command curlrequire_command jqrequire_command docker
# Validate required arguments[[ $# -lt 2 ]] && { echo "Usage: $SCRIPT_NAME <app> <env>"; exit 1; }APP="$1"ENV="$2"
# Validate values[[ "$ENV" =~ ^(dev|staging|prod)$ ]] || { log_error "Invalid env: $ENV"; exit 1; }
# Use temp files safelyTMPFILE=$(mktemp /tmp/deploy.XXXXXX)trap 'rm -f "$TMPFILE"' EXIT # always clean up