devops

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.

Terminal window
# Each command in a pipeline runs in its own subshell
echo "hello" | tr 'a-z' 'A-Z'
# Subshells don't affect the parent
(cd /tmp && ls) # current directory unchanged after
echo $PWD # still original directory
# Source runs in the SAME shell (affects current environment)
source ./config.sh
. ./config.sh # same thing, dot notation

Login shell vs interactive shell vs script:

  • Login shell: reads /etc/profile, ~/.bash_profile
  • Interactive (non-login): reads ~/.bashrc
  • Script: reads neither β€” set PATH explicitly in scripts

Exit Codes & Error Handling

Every command exits with a code. 0 = success, anything else = failure.

Terminal window
# Check exit code of last command
ls /nonexistent
echo $? # prints 1 (or 2)
# Use in conditionals
if 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 bash
set -euo pipefail
FlagMeaning
set -eExit immediately on any error
set -uError on undefined variable (instead of empty string)
set -o pipefailPipe fails if any command in it fails
Terminal window
# Without pipefail, this succeeds even though grep fails
ls /nonexistent | grep "something"
echo $? # 0! (only captures grep's exit code)
# With pipefail
set -o pipefail
ls /nonexistent | grep "something"
echo $? # non-zero

Variables, Quoting, Conditionals

Variables

Terminal window
# Assignment β€” no spaces around =
NAME="alice"
COUNT=42
# Access
echo $NAME
echo ${NAME} # explicit braces β€” use this in strings
# Default values
echo ${NAME:-"default"} # use default if unset/empty
echo ${NAME:="default"} # assign default if unset/empty
echo ${NAME:?"NAME is required"} # error if unset/empty
# Command substitution
TODAY=$(date +%Y-%m-%d)
FILES=$(ls /etc/*.conf | wc -l)
# Arithmetic
NUM=$((COUNT + 10))
((COUNT++))

Quoting Rules

Terminal window
NAME="John Doe"
# Double quotes β€” expands variables and command substitution
echo "Hello $NAME" # Hello John Doe
echo "Today is $(date)" # Today is ...
# Single quotes β€” no expansion whatsoever
echo 'Hello $NAME' # Hello $NAME
# ALWAYS quote variables to prevent word splitting
cp "$SOURCE" "$DEST" # correct
cp $SOURCE $DEST # breaks if paths have spaces

Conditionals

Terminal window
# 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

Terminal window
# For loop over list
for user in alice bob charlie; do
echo "Processing $user"
done
# For loop over files
for config in /etc/nginx/conf.d/*.conf; do
echo "Checking: $config"
nginx -t -c "$config"
done
# C-style for loop
for ((i=0; i<10; i++)); do
echo $i
done
# While loop
while [ $COUNT -gt 0 ]; do
echo "Count: $COUNT"
((COUNT--))
done
# Read file line by line
while IFS= read -r line; do
echo "Line: $line"
done < /etc/hosts
# Loop with break/continue
for 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"
done

Functions

Terminal window
# Define function
greet() {
local name="$1" # local = scoped to function
local greeting="${2:-Hello}"
echo "$greeting, $name!"
}
# Call it
greet "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 substitution
get_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
;;
esac
done

Pipes & Redirection

Terminal window
# Redirect stdout
command > output.txt # overwrite
command >> output.txt # append
# Redirect stderr
command 2> error.txt
# Redirect both stdout and stderr
command > output.txt 2>&1
command &> output.txt # bash shorthand
# Discard output
command > /dev/null
command > /dev/null 2>&1 # discard both
# Pipe stdout to next command
ps aux | grep nginx | grep -v grep
# Pipe stderr through pipe (bash 4+)
command 2>&1 | grep "ERROR"
# tee β€” write to file AND stdout
command | tee output.txt
command | tee -a output.txt # append
# Process substitution
diff <(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

Terminal window
# Edit crontab
crontab -e
# List current crontab
crontab -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.sh

Cron pitfalls:

PitfallFix
Empty PATH β€” commands not foundUse full paths: /usr/bin/python3
No home directory contextSet HOME and PATH at top of cron
Output lost (no indication of failure)Redirect to log: >> /var/log/backup.log 2>&1
Timezone surprisesCheck cron’s timezone vs date in script
Race conditions with overlapping runsUse flock for locking
Terminal window
# Good cron entry
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# With flock to prevent overlap
0 2 * * * flock -n /tmp/backup.lock /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

Idempotent Scripting Mindset

An idempotent script produces the same result whether run once or ten times. This is critical for automation.

Terminal window
# NOT idempotent β€” fails if dir already exists
mkdir /opt/myapp
# Idempotent
mkdir -p /opt/myapp
# NOT idempotent β€” duplicates entry on every run
echo "alias ll='ls -la'" >> ~/.bashrc
# Idempotent β€” only adds if not present
grep -qF "alias ll=" ~/.bashrc || echo "alias ll='ls -la'" >> ~/.bashrc
# Idempotent user creation
id myapp &>/dev/null || useradd -m -s /bin/bash myapp
# Idempotent service enable
systemctl is-enabled nginx &>/dev/null || systemctl enable nginx

Logging in Scripts

#!/usr/bin/env bash
set -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; }
# Usage
log_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 bash
set -euo pipefail
IFS=$'\n\t' # safer word splitting
# Script metadata at top
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_NAME="$(basename "$0")"
# Cleanup on exit
cleanup() {
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 signals
trap 'log_error "Interrupted"; exit 130' INT TERM
# Validate required tools
require_command() {
command -v "$1" &>/dev/null || { log_error "Required command not found: $1"; exit 1; }
}
require_command curl
require_command jq
require_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 safely
TMPFILE=$(mktemp /tmp/deploy.XXXXXX)
trap 'rm -f "$TMPFILE"' EXIT # always clean up