Tutorials

Bash Scripting for Linux SysAdmins: From Beginner to Dangerous

Bash scripting is the foundational automation skill for Linux administrators. Before you reach for Python or Ansible, a well-written shell script can handle the majority of day-to-day automation tasks — and it runs natively on every Linux system without installing anything. This guide takes you from variables to a complete monitoring script that would be at home on any production server.

Variables, Input, and Output

Bash variables are untyped strings by default. Assign without spaces around the equals sign, and reference with a dollar sign prefix. Double-quote your variable references to prevent word splitting on values that contain spaces. Use $(command) syntax (command substitution) to capture command output into a variable. The readonly keyword makes a variable immutable — useful for constants in your scripts.

The read builtin captures user input interactively. For scripts that shouldn’t be interactive, pass values via positional parameters ($1, $2, etc.) or environment variables. Always validate input: check that required arguments were provided, that file paths exist before operating on them, and that numeric values are actually numeric. Failing fast with a clear error message is far better than letting a script proceed with bad input and silently corrupt data.

#!/usr/bin/env bash
# Variables and I/O basics
set -euo pipefail   # exit on error, undefined vars, pipe failures

# Variables
SCRIPT_NAME="$(basename "$0")"
LOG_DIR="/var/log/myscripts"
DATESTAMP="$(date +%Y%m%d_%H%M%S)"
readonly LOG_DIR

# Positional arguments with default
TARGET_HOST="${1:-localhost}"
PORT="${2:-80}"

# Validate numeric
if ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
    echo "ERROR: PORT must be a number, got: $PORT" >&2
    exit 1
fi

# Read from user interactively
read -rp "Enter backup label: " LABEL
echo "Backing up $TARGET_HOST:$PORT labeled '$LABEL' at $DATESTAMP"

Conditionals and File Tests

Bash conditionals use the test command or its synonym [[ ]] (prefer the double-bracket form — it handles empty strings and special characters more safely). File tests are especially useful in sysadmin scripts: -f tests for a regular file, -d for a directory, -r for readable, -w for writable, -x for executable, and -s for non-empty. Combining tests with && and || inside [[ ]] avoids spawning subshells for each test.

Always check for file existence before operating on it, check for required commands before assuming they’re available, and check return codes after operations that can fail. The command -v pattern checks whether a command exists in PATH without executing it. Exit codes matter: 0 is success, anything else is failure. Functions should return meaningful exit codes so callers can react appropriately.

#!/usr/bin/env bash
# Conditionals and file tests

config_file="/etc/myapp/config.yaml"
backup_dir="/backup/$(date +%Y%m%d)"

# Check file exists and is readable
if [[ -f "$config_file" && -r "$config_file" ]]; then
    echo "Config found: $config_file"
elif [[ -e "$config_file" ]]; then
    echo "ERROR: Config exists but is not readable" >&2; exit 1
else
    echo "ERROR: Config not found at $config_file" >&2; exit 1
fi

# Create backup dir if missing
[[ -d "$backup_dir" ]] || mkdir -p "$backup_dir"

# Check required command exists
if ! command -v rsync &>/dev/null; then
    echo "ERROR: rsync not installed" >&2; exit 1
fi

# Numeric comparison
disk_used=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if (( disk_used > 90 )); then
    echo "WARNING: Root partition ${disk_used}% full"
fi

Loops: for, while, until

Bash provides three loop constructs. The for loop iterates over a list or glob expansion — invaluable for processing multiple files, hosts, or values. The while loop continues as long as a condition is true, commonly used with read to process file contents line by line. The until loop is the inverse: it continues until a condition becomes true, useful for polling until a service becomes available or a file appears.

Avoid the common pitfall of for file in $(ls /path/) — it breaks on filenames with spaces. Use glob expansion directly: for file in /path/*.log. When reading files line by line, use the while IFS= read -r line pattern — the IFS= prevents leading/trailing whitespace from being stripped, and -r prevents backslash interpretation. These small details matter when your scripts need to handle real-world filenames and content.

#!/usr/bin/env bash
# Loop examples

# for loop — process multiple servers
servers=("web01" "web02" "db01" "db02")
for server in "${servers[@]}"; do
    echo "Checking $server..."
    if ping -c1 -W2 "$server" &>/dev/null; then
        echo "  $server: ONLINE"
    else
        echo "  $server: OFFLINE" >&2
    fi
done

# Process log file line by line
while IFS= read -r line; do
    if [[ "$line" == *"ERROR"* ]]; then
        echo "Found error: $line"
    fi
done < /var/log/app/app.log

# until loop — wait for a service to respond
port=5432
until nc -z localhost "$port" 2>/dev/null; do
    echo "Waiting for port $port to open..."
    sleep 3
done
echo "Service on port $port is ready."

Building a Disk Monitoring Script

Putting the pieces together: a complete disk monitoring script that checks disk usage across specified mount points, logs warnings when thresholds are exceeded, and sends an email alert when a critical threshold is hit. Schedule it with cron to run every 15 minutes: */15 * * * * /usr/local/bin/disk-monitor.sh.

#!/usr/bin/env bash
# disk-monitor.sh — Check disk usage and alert on threshold breach
set -euo pipefail

# Configuration
WARN_THRESHOLD=80
CRIT_THRESHOLD=90
ALERT_EMAIL="admin@example.com"
LOG_FILE="/var/log/disk-monitor.log"
MOUNT_POINTS=("/" "/var" "/home" "/data")
HOSTNAME="$(hostname -f)"
TIMESTAMP="$(date '+%Y-%m-%d %H:%M:%S')"

# Function: log a message
log() { echo "[$TIMESTAMP] $*" | tee -a "$LOG_FILE"; }

# Function: send alert email
send_alert() {
    local mount="$1" used="$2"
    echo "CRITICAL: Disk usage on $HOSTNAME:$mount is at ${used}%" | \
        mail -s "[DISK ALERT] $HOSTNAME $mount at ${used}%" "$ALERT_EMAIL"
    log "ALERT sent for $mount at ${used}%"
}

alert_needed=false

for mp in "${MOUNT_POINTS[@]}"; do
    [[ -d "$mp" ]] || { log "SKIP: $mp not found"; continue; }
    used=$(df "$mp" | awk 'NR==2 {print $5}' | tr -d '%')

    if (( used >= CRIT_THRESHOLD )); then
        log "CRITICAL: $mp is at ${used}%"
        send_alert "$mp" "$used"
        alert_needed=true
    elif (( used >= WARN_THRESHOLD )); then
        log "WARNING: $mp is at ${used}%"
    else
        log "OK: $mp is at ${used}%"
    fi
done

$alert_needed && exit 2
exit 0