Bash Scripting
Automation begins with a script
Every repetitive task you do manually is a bug waiting to happen. Bash scripting turns procedures into repeatable, auditable, version-controlled automation. As a DevOps engineer, bash is the language you reach for first — it is on every Linux system, requires no installation, and integrates directly with every command-line tool.
The anatomy of a bash script
deploy.sh
#!/bin/bash
set -euo pipefail
# Variables
APP_NAME="myapp"
DEPLOY_DIR="/opt/${APP_NAME}"
LOG_FILE="/var/log/${APP_NAME}-deploy.log"
# Functions
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
# Main logic
log "Starting deployment of ${APP_NAME}"
systemctl stop "${APP_NAME}" || true
cp -r ./build/* "${DEPLOY_DIR}/"
systemctl start "${APP_NAME}"
log "Deployment complete"
The first three lines are always the same in a production script:
| Line | Purpose |
|---|---|
#!/bin/bash | Use bash as interpreter |
set -e | Exit on any error |
set -u | Exit on unset variable |
set -o pipefail | Exit if any pipe command fails |
Variables
# Assignment, no spaces around =
name="devops"
port=8080
log_dir="/var/log/myapp"
# Reference
echo "$name"
echo "${name}_user" # braces needed when appending text
# Command substitution
hostname=$(hostname)
date_now=$(date '+%Y-%m-%d')
file_count=$(ls /etc/*.conf | wc -l)
# Readonly
readonly MAX_RETRIES=3
# Arrays
servers=("web-01" "web-02" "db-01")
echo "${servers[0]}" # web-01
echo "${#servers[@]}" # 3 (array length)
for s in "${servers[@]}"; do echo "$s"; done
Conditionals
# if/elif/else
if [ "$1" = "production" ]; then
echo "Deploying to production"
elif [ "$1" = "staging" ]; then
echo "Deploying to staging"
else
echo "Usage: $0 [production|staging]"
exit 1
fi
# File tests
if [ ! -f "/etc/nginx/nginx.conf" ]; then
echo "Config file missing"
exit 1
fi
if [ -d "/opt/myapp" ]; then
echo "App directory exists"
fi
# String tests
if [ -z "$API_KEY" ]; then
echo "API_KEY is not set"
exit 1
fi
if [ -n "$DEBUG" ]; then
set -x
fi
# Numeric comparisons
if [ "$count" -gt 10 ]; then
echo "Too many items"
fi
Test operators
| Operator | Meaning |
|---|---|
-f file | File exists and is regular file |
-d path | Directory exists |
-e path | Path exists |
-x file | File is executable |
-z string | String is empty |
-n string | String is not empty |
-eq | Equal (numbers) |
-ne | Not equal (numbers) |
-gt | Greater than |
-lt | Less than |
Loops
# for loop, list
for env in development staging production; do
echo "Deploying to $env"
done
# for loop, files
for file in /etc/*.conf; do
echo "Config: $file"
done
# for loop, array
servers=("web-01" "web-02" "web-03")
for server in "${servers[@]}"; do
ssh "$server" "systemctl status nginx"
done
# while loop
count=0
while [ "$count" -lt 5 ]; do
echo "Attempt $count"
count=$((count + 1))
done
# Read file line by line
while IFS= read -r line; do
echo "Processing: $line"
done < servers.txt
Functions
# Define
check_service() {
local service="$1" # local keeps variable inside function
if systemctl is-active --quiet "$service"; then
echo "$service is running"
return 0
else
echo "$service is not running"
return 1
fi
}
deploy() {
local env="$1"
local version="$2"
echo "Deploying version $version to $env"
}
# Call
check_service nginx
deploy production v1.2.3
Always use local for variables inside functions. Without it they are global
and can overwrite variables in the main script.
Error handling
#!/bin/bash
set -euo pipefail
# Trap, run on exit regardless of how script ends
cleanup() {
rm -f /tmp/deploy.lock
echo "Cleanup complete"
}
trap cleanup EXIT
# Trap Ctrl+C
trap 'echo "Interrupted"; exit 1' INT
# Check command exists
if ! command -v docker &>/dev/null; then
echo "Docker is not installed"
exit 1
fi
# Check argument provided
if [ $# -eq 0 ]; then
echo "Usage: $0 <environment>"
exit 1
fi
Input and output
# Redirect stdout
command > output.txt # overwrite
command >> output.txt # append
# Redirect stderr
command 2> errors.txt
# Redirect both
command > output.txt 2>&1
command &> output.txt # shorthand
# Discard output
command > /dev/null 2>&1
# Read user input
read -p "Enter environment: " env
read -sp "Enter password: " pass # -s hides input
Debugging
# Debug mode, prints every command before running it
bash -x script.sh
# Or inside the script
set -x # enable
set +x # disable
# Check syntax without running
bash -n script.sh
A complete deployment script
scripts/deploy.sh
#!/bin/bash
set -euo pipefail
# ── Configuration ──────────────────────────────────────
APP="devops-chronicles"
DEPLOY_DIR="/opt/${APP}"
SERVICE="${APP}"
LOG="/var/log/${APP}-deploy.log"
# ── Functions ──────────────────────────────────────────
log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG"; }
fail() { echo "ERROR: $*" >&2; exit 1; }
cleanup() { log "Deploy script exited"; }
trap cleanup EXIT
# ── Validation ─────────────────────────────────────────
[ $# -eq 1 ] || fail "Usage: $0 <version>"
VERSION="$1"
command -v systemctl &>/dev/null || fail "systemctl not found"
[ -d "$DEPLOY_DIR" ] || fail "Deploy directory missing: $DEPLOY_DIR"
# ── Deploy ─────────────────────────────────────────────
log "Deploying ${APP} version ${VERSION}"
log "Stopping service"
systemctl stop "$SERVICE"
log "Copying files"
cp -r ./build/* "${DEPLOY_DIR}/"
log "Starting service"
systemctl start "$SERVICE"
# ── Health check ───────────────────────────────────────
log "Running health check"
sleep 3
if curl -sf "http://localhost:3000/health" &>/dev/null; then
log "Health check passed, deploy complete"
else
fail "Health check failed, check logs at $LOG"
fi
Quick reference
#!/bin/bash
set -euo pipefail # always start with this
name="value" # variable
echo "$name" # reference variable
result=$(command) # capture output
if [ condition ]; then # conditional
commands
fi
for item in list; do # loop
commands
done
function_name() { # function
local var="$1"
}
trap cleanup EXIT # run on exit
[ -f file ] # file exists
[ -z "$var" ] # variable is empty
[ $# -eq 0 ] # no arguments passed
bash -x script.sh # debug mode
bash -n script.sh # syntax check only