- Fixed 4 broken markdown links (bad relative paths in See Also sections) - Corrected n8n port binding to 127.0.0.1:5678 (matches actual deployment) - Updated SnapRAID article with actual majorhome paths (/majorRAID, disk1-3) - Converted 67 Obsidian wikilinks to relative markdown links or plain text - Added YAML frontmatter to 35 articles missing it entirely - Completed frontmatter on 8 articles with missing fields Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
5.1 KiB
Markdown
216 lines
5.1 KiB
Markdown
---
|
|
title: "Bash Scripting Patterns for Sysadmins"
|
|
domain: linux
|
|
category: shell-scripting
|
|
tags: [bash, scripting, automation, linux, shell]
|
|
status: published
|
|
created: 2026-03-08
|
|
updated: 2026-03-08
|
|
---
|
|
|
|
# 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
|
|
|
|
```bash
|
|
#!/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
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
|
SCRIPT_NAME="$(basename "$0")"
|
|
LOG_FILE="/var/log/myscript.log"
|
|
# ───────────────────────────────────────────────────────────────────────────────
|
|
```
|
|
|
|
## Logging
|
|
|
|
```bash
|
|
log() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
|
|
}
|
|
|
|
log "Script started"
|
|
log "Processing $1"
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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:**
|
|
|
|
```bash
|
|
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:**
|
|
|
|
```bash
|
|
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](ansible-getting-started.md)
|
|
- [managing-linux-services-systemd-ansible](../process-management/managing-linux-services-systemd-ansible.md)
|