Shell Scripting for SRE and DevOps Automation¶
What You'll Learn¶
- How to start every script safely with a shebang and strict mode
- The Bash building blocks: variables, arguments, conditionals, loops, functions, and traps
- Six practical operations scripts you can adapt
- How to debug, lint, and decide when to switch to another language
Shell scripts are one of the most practical tools in infrastructure work. They let you combine operating system commands, glue together tools, automate repetitive tasks, and turn manual runbooks into repeatable workflows.
For SRE and DevOps work, shell scripting is often the fastest way to automate:
- Health checks
- Service operations
- Backup tasks
- Deployment helpers
- Log analysis
- CI/CD build steps
- Cron-based maintenance
Why Shell Scripting Still Matters¶
Even with tools like Python, Go, Ansible, and Terraform, shell scripts remain important because they are:
- Available by default on most Linux and Unix systems
- Excellent for orchestration and system-level automation
- Easy to integrate with commands like
systemctl,docker,kubectl,curl, andrsync - Fast to write for operational tasks and prototypes
Use shell where it fits
Shell is great for command orchestration and glue logic. If the logic becomes very complex, data-heavy, or hard to read, it is often better to switch to Python or Go.
Common Shells¶
Popular Unix shells include:
sh: Basic POSIX shellbash: Most common shell for Linux scriptingzsh: Powerful interactive shell, often used on macOSksh: Korn shell, common in some enterprise Unix systems
For most Linux automation work, bash is the most common choice.
Start Every Script Correctly¶
Shebang¶
The first line tells the OS which interpreter should run the script.
This is a portable way to locate bash.
Safe mode¶
A strong default for production scripts is:
What it does:
-e: Exit when a command fails-u: Fail on unset variables-o pipefail: Fail a pipeline if any command inside it fails
You can also add:
to print commands during debugging.
Where strict mode doesn't protect you¶
set -e is a safety net with holes. The four that bite most often:
# 1. Failures inside conditions are ignored — by design
if grep -q ready /tmp/status; then ... # grep failing here is fine and expected
# 2. `local` (and `export`) hide the exit code of the command substitution
local out=$(curl -fsS "$url") # curl fails, script carries on
local out; out=$(curl -fsS "$url") # fixed: declare, then assign
# 3. Arithmetic that evaluates to 0 counts as a failure
count=0; ((count++)) # exits the script! (the expression's value was 0)
count=$((count + 1)) # safe
# 4. Functions called from a condition or `||` lose set -e inside them
deploy || rollback # errors inside deploy no longer stop it
Check important commands explicitly (cmd || die "...") rather than relying on set -e alone, and let ShellCheck flag the rest.
Core Building Blocks¶
Variables¶
Arguments¶
echo "Script name: $0"
echo "First argument: $1"
echo "Argument count: $#"
echo "All arguments: $*" # "$@" when passing them on: cmd "$@"
echo "Previous command exit code: $?"
Conditionals¶
Loops¶
Functions¶
Case statements¶
case "${1:-}" in
start) echo "Starting service" ;;
stop) echo "Stopping service" ;;
status) echo "Checking status" ;;
*) echo "Usage: $0 {start|stop|status}" ;;
esac
Useful Bash Patterns¶
Default values¶
Command substitution¶
Arrays¶
Reading a file line by line¶
Trap for cleanup¶
How Bash Turns a Line Into Arguments¶
Most shell bugs come from one misunderstanding: a program never sees your command line. Bash rewrites it first, in a fixed order, and passes the program a list of strings (its argv):
- Brace expansion:
file{,.bak}becomesfile file.bak. - Tilde expansion:
~/xbecomes/home/you/x(not inside quotes). - Parameter, arithmetic, and command expansion:
$VAR,$((n + 1)),$(cmd). - Word splitting: the results of unquoted expansions are split on spaces, tabs, and newlines.
- Pathname expansion: unquoted
*,?, and[...]are replaced by matching file names. - Quote removal: the quotes themselves disappear.
Steps 4 and 5 apply only to unquoted expansions. That's the whole reason to quote: "$FILE" stays one argument whatever it contains, and an empty "$DIR" stays an (empty) argument instead of vanishing. Type a command below and watch each argument the program will receive:
Bash argument viewer (needs JavaScript). Without it, check what a command receives with printf '<%s>\n' your args here, which prints one argument per line between angle brackets.
Real incidents: Steam (2015) and Bumblebee (2011)
Steam for Linux's launcher ran rm -rf "$STEAMROOT/"*. When STEAMROOT came out empty, the argument became /*, which the glob expanded to every top-level directory, and users lost every file they owned (issue #3671). Four years earlier, Bumblebee's installer ran rm -rf /usr /lib/nvidia-current/xorg/xorg: one stray space turned a path into two arguments, the first being /usr. Both are presets in the viewer above. Guard every destructive path with ${VAR:?}, which stops the script when the variable is empty or unset; set -u alone misses empty values.
What Most Engineers Miss¶
- An empty unquoted variable doesn't become an empty argument; it disappears.
[ $ENV = prod ]with an emptyENVruns[ = prod ], and fails withunary operator expected. Inside[[ ]]Bash doesn't split or glob, so[[ $ENV == prod ]]is safe. "$@"and$*are different."$@"passes each argument intact,"$*"joins them into one string, and unquoted$@or$*re-splits every argument on spaces. Wrapper scripts should always use"$@".$10is${1}0. Only one digit follows a bare$. Use${10}for the tenth argument.${x:-1}and${x: -1}mean different things. The first is "default to 1", the second "the last character". The space matters.- Prefix assignments apply after expansion. In
LANG=C echo $LANG,$LANGis expanded before the assignment takes effect, becauseVAR=x cmdonly putsVARincmd's environment. - Redirection order matters.
cmd 2>&1 > logsends stderr to the terminal, because2>&1copies stderr to where stdout points at that moment. Writecmd > log 2>&1. - A glob that matches nothing is passed literally.
rm *.bakin a directory with no backups runsrm '*.bak'.shopt -s nullglobmakes it vanish instead;failglobmakes the command fail. - Bash 5.3 adds command substitution without a subshell.
${ cmd; }runscmdin the current shell, so variables it sets survive, and${| cmd; }returns the value ofREPLY. Both need Bash 5.3 (July 2025), so check$BASH_VERSIONbefore using them in scripts that run elsewhere.
Script Structure Template¶
This is a clean starting point for operational scripts:
#!/usr/bin/env bash
set -euo pipefail
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$1"
}
usage() {
echo "Usage: $0 <environment>"
exit 1
}
main() {
local environment="${1:-}"
[[ -n "$environment" ]] || usage
log "Running in environment: $environment"
}
main "$@"
Example Script 1: System Health Check¶
This script is useful for daily operations checks or cron jobs.
#!/usr/bin/env bash
set -euo pipefail
WARNING_DISK_THRESHOLD=80
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$1"
}
check_disk() {
local usage
usage="$(df -h / | awk 'NR==2 {gsub("%","",$5); print $5}')"
if (( usage >= WARNING_DISK_THRESHOLD )); then
log "WARNING: Root disk usage is ${usage}%"
else
log "OK: Root disk usage is ${usage}%"
fi
}
check_memory() {
free -h
}
check_load() {
uptime
}
main() {
log "Starting health check"
hostnamectl || true
check_disk
check_memory
check_load
log "Health check completed"
}
main "$@"
Use cases:
- Cron-based health reports
- Basic server validation after provisioning
- Quick support triage
Example Script 2: Log Error Scanner¶
This script scans a log file for common error patterns and prints a short summary.
#!/usr/bin/env bash
set -euo pipefail
LOG_FILE="${1:-/var/log/syslog}"
if [[ ! -f "$LOG_FILE" ]]; then
echo "Log file not found: $LOG_FILE"
exit 1
fi
echo "Scanning: $LOG_FILE"
echo
echo "Top error counts:"
grep -Ei 'error|failed|fatal|panic|critical' "$LOG_FILE" | \
sed 's/[[:space:]]\+/ /g' | \
sort | uniq -c | sort -nr | head -10
Use cases:
- Rapid incident triage
- Scheduled log reviews
- Pre-check before escalating to application teams
Example Script 3: Backup Script with Retention¶
This script creates a compressed backup and deletes old backups based on retention days.
#!/usr/bin/env bash
set -euo pipefail
SOURCE_DIR="${1:-/etc}"
BACKUP_DIR="${2:-/var/backups/custom}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
TIMESTAMP="$(date +%F-%H%M%S)"
ARCHIVE_NAME="backup-${TIMESTAMP}.tar.gz"
mkdir -p "$BACKUP_DIR"
tar -czf "${BACKUP_DIR}/${ARCHIVE_NAME}" "$SOURCE_DIR"
echo "Created backup: ${BACKUP_DIR}/${ARCHIVE_NAME}"
find "$BACKUP_DIR" -type f -name 'backup-*.tar.gz' -mtime +"$RETENTION_DAYS" -delete
echo "Old backups older than ${RETENTION_DAYS} days removed"
Use cases:
- Configuration backups
- Pre-change safety snapshots
- Simple server maintenance automation
Production backup note
For critical systems, backups should also include verification, secure storage, encryption, restore testing, and off-host copies.
Example Script 4: Deploy a Release With Automatic Rollback¶
git pull followed by a restart is how many small deployments start, and it has no answer for "the new version doesn't start". This version unpacks each release into its own directory, switches a current symlink, checks health, and switches back if the check fails:
#!/usr/bin/env bash
set -euo pipefail
APP="myapp"
BASE="/opt/${APP}"
VERSION="${1:?usage: deploy.sh VERSION (e.g. 2.14.0)}"
ARTIFACT="https://artifacts.example.com/${APP}/${APP}-${VERSION}.tar.gz"
HEALTH_URL="http://127.0.0.1:8080/healthz"
log() { printf '[%s] %s\n' "$(date -u '+%FT%TZ')" "$*" >&2; }
healthy() {
for _ in {1..20}; do
curl -fsS --max-time 2 "$HEALTH_URL" >/dev/null && return 0
sleep 3
done
return 1
}
release_dir="${BASE}/releases/${VERSION}"
previous="$(readlink -f "${BASE}/current" || true)"
if [[ ! -d "$release_dir" ]]; then
log "Downloading ${VERSION}"
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
curl -fsSL "$ARTIFACT" -o "${tmp}/release.tar.gz"
mkdir -p "$release_dir"
tar -xzf "${tmp}/release.tar.gz" -C "$release_dir"
fi
log "Switching current -> ${VERSION}"
ln -sfn "$release_dir" "${BASE}/current"
sudo systemctl restart "$APP"
if healthy; then
log "Deployed ${VERSION}"
# keep the 5 newest releases
ls -1dt "${BASE}"/releases/*/ | tail -n +6 | xargs -r rm -rf --
else
log "Health check failed; rolling back to ${previous:-nothing}"
if [[ -n "$previous" ]]; then
ln -sfn "$previous" "${BASE}/current"
sudo systemctl restart "$APP"
fi
exit 1
fi
Why this shape works in production:
- Each release lives in its own directory, so rolling back is one symlink change, with no rebuild or re-download.
- Health is checked from the outside (the HTTP endpoint), not by
systemctl status, which only says the process started. - It exits non-zero on failure, so the CI job or person running it sees the deploy failed, even after a successful rollback.
- The same pattern is what Ansible's rolling deployment and Kubernetes Deployments do for you at fleet scale.
Example Script 5: Kubernetes Rollout Checker¶
This script validates that a Kubernetes deployment rollout finishes successfully.
#!/usr/bin/env bash
set -euo pipefail
NAMESPACE="${1:-default}"
DEPLOYMENT_NAME="${2:-}"
if [[ -z "$DEPLOYMENT_NAME" ]]; then
echo "Usage: $0 <namespace> <deployment-name>"
exit 1
fi
echo "Checking rollout for deployment/${DEPLOYMENT_NAME} in namespace ${NAMESPACE}"
kubectl rollout status "deployment/${DEPLOYMENT_NAME}" -n "$NAMESPACE" --timeout=120s
kubectl get pods -n "$NAMESPACE"
Use cases:
- Post-deployment checks
- CD validation steps
- Quick operational validation during incidents
Example Script 6: Service Monitor with Exit Codes¶
This is useful when a script needs to integrate with monitoring systems or CI jobs.
#!/usr/bin/env bash
set -euo pipefail
SERVICE_NAME="${1:-nginx}"
if systemctl is-active --quiet "$SERVICE_NAME"; then
echo "OK: ${SERVICE_NAME} is running"
exit 0
else
echo "CRITICAL: ${SERVICE_NAME} is not running"
exit 2
fi
Use cases:
- Nagios-style checks
- Cron alerts
- Jenkins or monitoring integrations
Running a Script¶
Save the file, then make it executable:
Run it:
Or pass arguments:
You can also run it directly with an interpreter:
Debugging Shell Scripts¶
Useful debugging techniques:
Enable command tracing¶
Print line numbers on errors¶
Check syntax without running¶
Lint with ShellCheck¶
ShellCheck is worth using
ShellCheck catches quoting mistakes, unsafe expansions, and common Bash pitfalls that are easy to miss in review.
Best Practices¶
- Use
#!/usr/bin/env bashwhen you are writing Bash-specific scripts - Start with
set -euo pipefailfor safer behavior - Quote variables like
"$VAR"unless you intentionally want word splitting - Prefer functions for readability and reuse
- Validate input arguments early
- Return meaningful exit codes
- Log clearly so operators can understand what happened
- Avoid hardcoding secrets in scripts
- Use
mktempfor temporary files - Test scripts in a safe environment before production use
Common Mistakes¶
- Unquoted variables:
Safer:
- Ignoring command failures
- Using shell for overly complex business logic
- Assuming tools like
jq,kubectl, orawsare installed without checking - Deleting files without validating paths
- Mixing POSIX
shsyntax and Bash-specific syntax accidentally
Integration with DevOps Workflows¶
Shell scripts are often used inside:
- Jenkins stages with
sh - GitHub Actions
runsteps - Cron jobs for recurring tasks
- Systemd services and timers
- Docker entrypoint scripts
- Kubernetes init containers and operational jobs
Example Jenkins stage:
Example GitHub Actions step:
When to Use Another Language¶
Shell is not always the right tool. Consider Python or Go when you need:
- Complex parsing and data structures
- Heavy JSON or YAML processing
- Strong error modeling
- Cross-platform application logic
- Larger testable codebases
Use shell scripts for what they do best: command orchestration, operational automation, and fast system-level workflows.
Check Yourself¶
The vanishing argument
DIR is set but empty. How many arguments does ls -l $DIR pass to ls, not counting ls itself?
- Two:
-land an empty string - One:
-l, solslists the current directory - None: Bash reports an error
An unquoted expansion that produces nothing is removed entirely. ls -l "$DIR" would pass an empty argument, and ls would report that it can't access ''.
Guarding a delete
Which line refuses to run when TARGET is empty?
-
set -u; rm -rf "$TARGET"/* -
rm -rf "${TARGET:?}"/* -
rm -rf --preserve-root "$TARGET"/*
set -u only catches unset variables, and --preserve-root only protects / itself, not /*. ${TARGET:?} stops the script for both unset and empty values.
Logging stderr
You want both stdout and stderr of ./deploy.sh in deploy.log. Which works?
-
./deploy.sh 2>&1 > deploy.log -
./deploy.sh > deploy.log 2>&1 - Both are equivalent
Redirections apply left to right. 2>&1 copies stderr to wherever stdout points at that moment, so it must come after > deploy.log.
Interview Questions¶
- What does
set -euo pipefaildo, and what are its pitfalls? (Hint: conditions,local x=$(...), and((count++)).) - Why quote variables like
"$file"? - How do you make sure a script cleans up temporary files even when it fails?
- When would you rewrite a shell script in Python or move it to Ansible?
Next¶
Continue to Text Processing to slice logs, config files, and JSON with grep, sed, awk, and jq. When scripts need to run idempotently across many hosts, move to Ansible.