Skip to main content

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:

LinePurpose
#!/bin/bashUse bash as interpreter
set -eExit on any error
set -uExit on unset variable
set -o pipefailExit 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

OperatorMeaning
-f fileFile exists and is regular file
-d pathDirectory exists
-e pathPath exists
-x fileFile is executable
-z stringString is empty
-n stringString is not empty
-eqEqual (numbers)
-neNot equal (numbers)
-gtGreater than
-ltLess 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

Test your knowledge

→ Take the Bash Scripting Quiz