Shell Scripting

Shell scripts are glue. They're not elegant programs — they're the thing you write at 11pm to automate the thing you just did by hand three times in a row. Once you get comfortable with the basics, a lot of tedious sysadmin work just disappears. This covers the parts you actually use.

The Shebang and Basic Structure

Always start with a shebang so the system knows which interpreter to use:

#!/bin/bash

# This is a comment
echo "Hello from a shell script"

Make it executable before running:

$ chmod +x myscript.sh
$ ./myscript.sh

Variables

NAME="jason"
COUNT=42

echo "Hello, $NAME"
echo "Count is ${COUNT}"    # braces help when adjacent to other text
echo "Value: ${COUNT}px"

No spaces around the =. This is the most common beginner mistake — NAME = "jason" tries to run a command called NAME.

Control Flow

If / Else

if [ -f "/etc/hostname" ]; then
    echo "hostname file exists"
elif [ -d "/etc/hostname" ]; then
    echo "that's a directory, weird"
else
    echo "not found"
fi

Common test flags: -f (regular file), -d (directory), -e (exists), -z (string is empty), -n (string is not empty). For numbers use -eq, -gt, -lt.

For Loops

# Loop over a list
for COLOR in red green blue; do
    echo "Color: $COLOR"
done

# Loop over files
for FILE in /var/log/*.log; do
    echo "Processing $FILE"
done

# C-style 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

Functions

log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') - $1"
}

backup() {
    local SOURCE=$1
    local DEST=$2
    log "Backing up $SOURCE to $DEST"
    tar -czf "$DEST/backup_$(date +%Y%m%d).tar.gz" "$SOURCE"
}

backup /home/jason /mnt/backups

Use local for variables inside functions — otherwise they're global and you'll create hard-to-find bugs.

Error Handling

By default, bash keeps going even when commands fail. That's almost never what you want in a script.

#!/bin/bash
set -e          # exit on error
set -u          # treat unset variables as errors
set -o pipefail # catch errors in pipes too

# Now this will stop the script instead of silently continuing:
cp nonexistent_file /tmp/
echo "This won't run if cp failed"

The trap command lets you run cleanup code on exit:

TMPFILE=$(mktemp)

cleanup() {
    rm -f "$TMPFILE"
    echo "Cleaned up"
}

trap cleanup EXIT   # runs on any exit, including errors

Working with Command Output

# Capture output into a variable
HOSTNAME=$(hostname)
DISK_USAGE=$(df -h / | awk 'NR==2 {print $5}')

echo "Running on $HOSTNAME"
echo "Root disk usage: $DISK_USAGE"

# Check exit status of last command
if grep -q "error" /var/log/syslog; then
    echo "Errors found in syslog"
fi

A Real Script: Rotating Log Files

Something actually useful — rotate a log file by date and keep only the last 7:

#!/bin/bash
set -euo pipefail

LOG_DIR="/var/log/myapp"
LOG_FILE="$LOG_DIR/app.log"
MAX_KEEP=7

if [ ! -f "$LOG_FILE" ]; then
    echo "No log file at $LOG_FILE"
    exit 0
fi

# Rotate
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mv "$LOG_FILE" "$LOG_DIR/app_$TIMESTAMP.log"
touch "$LOG_FILE"

# Clean up old rotated logs, keep only MAX_KEEP newest
ls -t "$LOG_DIR"/app_*.log 2>/dev/null | tail -n +"$((MAX_KEEP + 1))" | xargs rm -f

echo "Rotated log. Kept last $MAX_KEEP archives."

Useful One-Liners to Know

# Run a command for every line in a file
while IFS= read -r line; do echo "$line"; done < file.txt

# Process all files in a directory safely (handles spaces in filenames)
find /path -name "*.txt" -print0 | xargs -0 wc -l

# Check if a script is already running
[ -f /tmp/myscript.pid ] && kill -0 $(cat /tmp/myscript.pid) 2>/dev/null && exit 1
echo $$ > /tmp/myscript.pid