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
TestMeaning
-f FILERegular file exists
-d DIRDirectory exists
-e PATHPath exists (any type)
-z STRINGString is empty
-n STRINGString is not empty
-r FILEFile is readable
-w FILEFile is writable
NUM1 -eq NUM2Numbers are equal
NUM1 -gt NUM2Num1 greater than num2
NUM1 -lt NUM2Num1 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

References