Skip to content

Bash Scripting Patterns for Sysadmins

These are the patterns I reach for when writing bash scripts for server automation and maintenance tasks. Not a tutorial from scratch — this assumes you know basic bash syntax and want to write scripts that don't embarrass you later.

The Short Answer

#!/usr/bin/env bash
set -euo pipefail

Start every script with these two lines. set -e exits on error. set -u treats unset variables as errors. set -o pipefail catches errors in pipes. Together they prevent a lot of silent failures.

Script Header

#!/usr/bin/env bash
set -euo pipefail

# ── Config ─────────────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
LOG_FILE="/var/log/myscript.log"
# ───────────────────────────────────────────────────────────────────────────────

Logging

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

log "Script started"
log "Processing $1"

Error Handling

# Exit with a message
die() {
    echo "ERROR: $*" >&2
    exit 1
}

# Check a condition
[ -f "$CONFIG_FILE" ] || die "Config file not found: $CONFIG_FILE"

# Trap to run cleanup on exit
cleanup() {
    log "Cleaning up temp files"
    rm -f "$TMPFILE"
}
trap cleanup EXIT

Checking Dependencies

check_deps() {
    local deps=("curl" "jq" "rsync")
    for dep in "${deps[@]}"; do
        command -v "$dep" &>/dev/null || die "Required dependency not found: $dep"
    done
}

check_deps

Argument Parsing

usage() {
    cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] <target>

Options:
  -v, --verbose    Enable verbose output
  -n, --dry-run    Show what would be done without doing it
  -h, --help       Show this help
EOF
    exit 0
}

VERBOSE=false
DRY_RUN=false

while [[ $# -gt 0 ]]; do
    case $1 in
        -v|--verbose) VERBOSE=true; shift ;;
        -n|--dry-run) DRY_RUN=true; shift ;;
        -h|--help)    usage ;;
        --)           shift; break ;;
        -*)           die "Unknown option: $1" ;;
        *)            TARGET="$1"; shift ;;
    esac
done

[[ -z "${TARGET:-}" ]] && die "Target is required"

Running Commands

# Dry-run aware command execution
run() {
    if $DRY_RUN; then
        echo "DRY RUN: $*"
    else
        "$@"
    fi
}

run rsync -av /source/ /dest/
run systemctl restart myservice

Working with Files and Directories

# Check existence before use
[[ -d "$DIR" ]] || mkdir -p "$DIR"
[[ -f "$FILE" ]] || die "Expected file not found: $FILE"

# Safe temp files
TMPFILE="$(mktemp)"
trap 'rm -f "$TMPFILE"' EXIT

# Loop over files
find /path/to/files -name "*.log" -mtime +30 | while read -r file; do
    log "Processing: $file"
    run gzip "$file"
done

String Operations

# Extract filename without extension
filename="${filepath##*/}"         # basename
stem="${filename%.*}"              # strip extension

# Check if string contains substring
if [[ "$output" == *"error"* ]]; then
    die "Error detected in output"
fi

# Convert to lowercase
lower="${str,,}"

# Trim whitespace
trimmed="${str#"${str%%[![:space:]]*}"}"

Common Patterns

Backup with timestamp:

backup() {
    local source="$1"
    local dest="${2:-/backup}"
    local timestamp
    timestamp="$(date '+%Y%m%d_%H%M%S')"
    local backup_path="${dest}/$(basename "$source")_${timestamp}.tar.gz"

    log "Backing up $source to $backup_path"
    run tar -czf "$backup_path" -C "$(dirname "$source")" "$(basename "$source")"
}

Retry on failure:

retry() {
    local max_attempts="${1:-3}"
    local delay="${2:-5}"
    shift 2
    local attempt=1

    until "$@"; do
        if ((attempt >= max_attempts)); then
            die "Command failed after $max_attempts attempts: $*"
        fi
        log "Attempt $attempt failed, retrying in ${delay}s..."
        sleep "$delay"
        ((attempt++))
    done
}

retry 3 10 curl -f https://example.com/health

Gotchas & Notes

  • Always quote variables. "$var" not $var. Unquoted variables break on spaces and glob characters.
  • Use [[ not [ for conditionals. [[ is a bash built-in with fewer edge cases.
  • set -e exits on the first error — including in pipes. Add set -o pipefail or you'll miss failures in cmd1 | cmd2.
  • set -e also aborts on a false && list. [ "$n" -eq 0 ] && exit 0 exits the script with status 1 when $n is non-zero — the list evaluates false and errexit fires. In an alert script that is the run that had something to report, so it fails exactly when it matters. Use an explicit if block, or append || true. See Alert Script Never Emails.
  • $? after if is almost always wrong. Use if command; then not command; if [[ $? -eq 0 ]]; then.
  • Bash isn't great for complex data. If your script needs real data structures or error handling beyond strings, consider Python.

See Also