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.
  • $? 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

  • [[ansible-getting-started]]
  • [[managing-linux-services-systemd-ansible]]