Shell Scripting
Shell scripts are glue. They're not elegant programs — they're what you write at 11pm to automate the thing you just did by hand three times in a row. Once you get the basics solid, a lot of tedious sysadmin work just disappears. This covers the parts you actually use, with the gotchas that take a while to learn by burning yourself on them.
The Shebang and Basic Structure
#!/bin/bash # Always start with a shebang — tells the system which interpreter to use. # Without it, the script may run under /bin/sh, which has fewer features. echo "Hello from a shell script"
# Make executable before running $ chmod +x myscript.sh $ ./myscript.sh # Or run explicitly with bash (doesn't need +x): $ bash myscript.sh
Error Handling — Do This First
By default, bash keeps going even when commands fail. This silently corrupts data and makes debugging miserable. Put these at the top of every serious script:
#!/bin/bash set -e # exit immediately on error set -u # treat unset variables as errors (catches typos) set -o pipefail # catch errors in pipes (not just the last command) # Shorthand: set -euo pipefail
# Cleanup on exit with trap
TMPFILE=$(mktemp)
cleanup() {
rm -f "$TMPFILE"
echo "Cleaned up" >&2
}
trap cleanup EXIT # runs on any exit, including errors and signals
trap cleanup SIGINT SIGTERM # also on Ctrl+C and kill signals
Variables
NAME="jason" # string
COUNT=42 # integer (all variables are strings internally)
EMPTY=""
# No spaces around the = (NAME = "jason" tries to run a command called NAME)
echo "Hello, $NAME"
echo "Count is ${COUNT}" # braces: useful adjacent to other text
echo "Value: ${COUNT}px" # without braces: ${COUNT}px vs $COUNTpx (broken)
# Default values
echo "${NAME:-unknown}" # use "unknown" if NAME is unset or empty
echo "${NAME:=default}" # assign default if unset
Control Flow
If / Elif / Else
if [ -f "/etc/hostname" ]; then
echo "hostname file exists"
elif [ -d "/etc/hostname" ]; then
echo "that's a directory"
else
echo "not found"
fi
| Test | Meaning |
|---|---|
-f FILE | Regular file exists |
-d DIR | Directory exists |
-e PATH | Path exists (any type) |
-z STRING | String is empty |
-n STRING | String is not empty |
-r FILE | File is readable |
-w FILE | File is writable |
NUM1 -eq NUM2 | Numbers are equal |
NUM1 -gt NUM2 | Num1 greater than num2 |
NUM1 -lt NUM2 | Num1 less than num2 |
Loops
# Over a list
for COLOR in red green blue; do
echo "Color: $COLOR"
done
# Over files (handles the case of no matches gracefully with nullglob)
shopt -s nullglob
for FILE in /var/log/*.log; do
echo "Processing $FILE"
done
# C-style numeric loop
for ((i=0; i<10; i++)); do
echo "i = $i"
done
# While loop
COUNT=0
while [ $COUNT -lt 5 ]; do
echo "Count: $COUNT"
COUNT=$((COUNT + 1))
done
# Read lines from a file (safest method — handles spaces, special chars)
while IFS= read -r line; do
echo "Line: $line"
done < file.txt
Functions
log() {
# $1 is the first argument, $2 is the second, etc.
echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" >&2
}
backup() {
local SOURCE="$1" # local: scoped to function (no local = global, causes bugs)
local DEST="$2"
local TIMESTAMP
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
log "INFO" "Backing up $SOURCE to $DEST"
tar -czf "$DEST/backup_${TIMESTAMP}.tar.gz" "$SOURCE"
log "INFO" "Done"
}
backup /home/jason /mnt/backups
Working with Command Output
# Capture output into a variable
HOSTNAME=$(hostname)
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')
echo "Host: $HOSTNAME, Root disk: $DISK_USAGE"
# Check exit status (0 = success, non-zero = failure)
if grep -q "ERROR" /var/log/app.log; then
echo "Errors found in app log" >&2
fi
# Combine: fail fast if the command fails
OUTPUT=$(some_command) || { echo "some_command failed" >&2; exit 1; }
Rotation Mechanics: Hand Off Retention
#!/bin/bash
set -euo pipefail
LOG_DIR="/var/log/myapp"
LOG_FILE="$LOG_DIR/app.log"
if [ ! -f "$LOG_FILE" ]; then
echo "No log file at $LOG_FILE" >&2
exit 0
fi
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mv "$LOG_FILE" "$LOG_DIR/app_${TIMESTAMP}.log"
touch "$LOG_FILE" # recreate the log file
chmod 644 "$LOG_FILE" # restore permissions
# Retention is deliberately not implemented with "ls | xargs rm".
# Filenames are data, and parsing ls output is unsafe. Use the operating
# system's log service (such as logrotate or newsyslog) for retention.
echo "Rotated log to app_${TIMESTAMP}.log."
This example demonstrates the mechanics of closing one log and creating another. On an administered system, use the platform's established rotation service—commonly logrotate on Linux or newsyslog on BSD systems—for retention, compression, ownership, and signalling the service to reopen its log. Do not parse ls output into rm.
Useful Patterns
# Process files safely (handles spaces in filenames — use find + xargs -0)
find /path -name "*.txt" -print0 | xargs -0 wc -l
# Check if a command exists before using it
if ! command -v jq &>/dev/null; then
echo "jq is required but not installed" >&2
exit 1
fi
# Prevent a script from running twice simultaneously
LOCKFILE="/tmp/myscript.lock"
if ! mkdir "$LOCKFILE" 2>/dev/null; then
echo "Script already running" >&2
exit 1
fi
trap "rmdir $LOCKFILE" EXIT # clean up lock on exit
# Parse simple arguments
VERBOSE=false
DRY_RUN=false
while [[ "$#" -gt 0 ]]; do
case "$1" in
-v|--verbose) VERBOSE=true ;;
-n|--dry-run) DRY_RUN=true ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
shift
done
dispelled