← Back to Blog

Bash Scripting Cheat Sheet: Write Shell Scripts Like a Pro (2026)

Bash is the default shell on most Linux systems and macOS. Whether you are automating deployments, processing log files, or scheduling tasks with cron, this cheat sheet gives you copy-paste examples for every common pattern - from variables and loops to error handling and string manipulation.

Why Bash Scripting Still Matters

With the rise of Python, Go, and other scripting-friendly languages, developers sometimes overlook bash. But bash has irreplaceable advantages: it is available on every Unix system without installation, it runs directly in the shell prompt, it composes perfectly with standard Unix tools like grep, awk, sed, and find, and it is the lingua franca of CI/CD pipelines and DevOps automation.

If you are writing a deployment script, a cron job, a Docker entrypoint, or a git hook - bash is almost always the right tool. The key is knowing its quirks well enough to avoid the subtle bugs that catch developers off guard.

Script Header: Always Start Here

Every bash script should start with a shebang line and a set of safety options:

#!/usr/bin/env bash
set -euo pipefail

# -e  : exit immediately if any command fails
# -u  : treat unset variables as errors
# -o pipefail : catch failures in pipelines (e.g. false | true now fails)

Without set -euo pipefail, a script can silently continue past errors. This is a common source of partially-executed deployments and data corruption. Always include it.

Variables

# Assign a variable (no spaces around =)
name="World"
echo "Hello, $name"

# Command substitution
today=$(date +%Y-%m-%d)
file_count=$(ls -1 /var/log | wc -l)

# Read-only variable
readonly MAX_RETRIES=3

# Default value if variable is unset or empty
LOG_DIR="${LOG_DIR:-/var/log/myapp}"

# Required variable - exits with error if unset
: "${DATABASE_URL:?DATABASE_URL must be set}"

# Integer arithmetic
count=5
count=$((count + 1))
echo "Count: $count"   # Count: 6

Special Variables

$0    # Script name
$1    # First argument
$@    # All arguments (as separate words)
$#    # Number of arguments
$?    # Exit code of the last command
$$    # Current process ID
$!    # PID of the last background process
$LINENO  # Current line number (useful for debugging)

Conditionals

# File tests
if [ -f "/etc/passwd" ]; then
  echo "File exists"
fi

if [ -d "/tmp" ]; then
  echo "Directory exists"
fi

if [ ! -f "config.yml" ]; then
  echo "ERROR: config.yml not found" >&2
  exit 1
fi

# String comparisons (use [[ ]] for bash-specific features)
env="production"
if [[ "$env" == "production" ]]; then
  echo "Running in production"
elif [[ "$env" == "staging" ]]; then
  echo "Running in staging"
else
  echo "Unknown environment: $env"
fi

# Numeric comparisons
retries=3
if (( retries > 5 )); then
  echo "Too many retries"
fi

# Check if a command exists
if command -v jq &>/dev/null; then
  echo "jq is installed"
else
  echo "jq not found - install it with: apt-get install jq"
fi

Common Test Operators

  • -f file - file exists and is a regular file
  • -d dir - directory exists
  • -z "$var" - string is empty
  • -n "$var" - string is not empty
  • -r file - file is readable
  • -w file - file is writable
  • -x file - file is executable
  • $a -eq $b - integers are equal
  • $a -lt $b - a is less than b
  • $a -gt $b - a is greater than b

Loops

# For loop over a list
for env in dev staging production; do
  echo "Deploying to: $env"
done

# For loop over files
for file in /var/log/*.log; do
  echo "Processing: $file"
  gzip "$file"
done

# C-style for loop
for (( i=1; i<=5; i++ )); do
  echo "Step $i"
done

# While loop
count=0
while (( count < 3 )); do
  echo "Attempt $count"
  count=$((count + 1))
done

# Read file line by line
while IFS= read -r line; do
  echo "Line: $line"
done < /etc/hosts

# Process command output line by line
while IFS= read -r container; do
  echo "Stopping: $container"
  docker stop "$container"
done < <(docker ps -q)

Functions

# Define a function
log() {
  local level="$1"
  local message="$2"
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message"
}

log "INFO" "Script started"
log "ERROR" "Something went wrong"

# Function with return value (via exit code)
is_running() {
  local service="$1"
  systemctl is-active --quiet "$service"
}

if is_running nginx; then
  echo "Nginx is running"
fi

# Function with output (capture with $())
get_timestamp() {
  date '+%Y%m%d_%H%M%S'
}

backup_name="backup_$(get_timestamp).tar.gz"

Arrays

# Declare and populate an array
servers=("web01" "web02" "web03")

# Access elements
echo "${servers[0]}"         # web01
echo "${servers[@]}"         # all elements
echo "${#servers[@]}"        # number of elements (3)

# Append to array
servers+=("web04")

# Loop over array
for server in "${servers[@]}"; do
  echo "Deploying to: $server"
  ssh "$server" "sudo systemctl restart app"
done

# Associative array (bash 4+)
declare -A config
config["host"]="localhost"
config["port"]="5432"
config["db"]="myapp"

echo "Connecting to ${config[host]}:${config[port]}/${config[db]}"

String Manipulation

filename="backup-2026-03-26.tar.gz"

# Length
echo "${#filename}"           # 24

# Substring (offset, length)
echo "${filename:7:10}"       # 2026-03-26

# Remove prefix (shortest match)
echo "${filename#backup-}"    # 2026-03-26.tar.gz

# Remove suffix (shortest match)
echo "${filename%.tar.gz}"    # backup-2026-03-26

# Replace first occurrence
echo "${filename/-/_}"        # backup_2026-03-26.tar.gz

# Replace all occurrences
echo "${filename//-/_}"       # backup_2026_03_26.tar.gz

# Uppercase / lowercase (bash 4+)
env="production"
echo "${env^^}"               # PRODUCTION
echo "${env,,}"               # production (already lowercase)

Error Handling and Cleanup

#!/usr/bin/env bash
set -euo pipefail

TMPDIR=$(mktemp -d)

# Cleanup on exit (runs even if script fails)
cleanup() {
  echo "Cleaning up temporary files..."
  rm -rf "$TMPDIR"
}
trap cleanup EXIT

# Handle specific signals
handle_interrupt() {
  echo "Script interrupted by user"
  exit 130
}
trap handle_interrupt INT TERM

# Run a command and check exit code manually
if ! wget -q "https://example.com/file.tar.gz" -O "$TMPDIR/file.tar.gz"; then
  echo "ERROR: Download failed" >&2
  exit 1
fi

echo "Download successful: $TMPDIR/file.tar.gz"

Input and Output

# Read user input
read -p "Enter environment (dev/prod): " env
echo "You chose: $env"

# Read secret input (no echo)
read -s -p "Enter password: " password
echo

# Redirect stdout and stderr
command > output.log 2>&1           # both to file
command >> output.log 2>&1          # append both to file
command 2>/dev/null                 # discard errors
command > /dev/null 2>&1            # discard all output

# Here document (multiline string)
cat > /etc/app.conf <&1 1>&3 | grep -v "^$"; } 3>&1

A Real-World Deployment Script

Putting it all together, here is a production-quality deployment script that uses all the patterns above:

#!/usr/bin/env bash
set -euo pipefail

# Configuration
APP_NAME="${APP_NAME:?APP_NAME must be set}"
DEPLOY_DIR="/var/www/${APP_NAME}"
RELEASES_DIR="${DEPLOY_DIR}/releases"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
RELEASE_DIR="${RELEASES_DIR}/${TIMESTAMP}"

log() { echo "[$(date '+%H:%M:%S')] $*"; }
error() { echo "[ERROR] $*" >&2; exit 1; }

# Validate prerequisites
for cmd in git node npm; do
  command -v "$cmd" &>/dev/null || error "$cmd is required but not installed"
done

log "Creating release directory: $RELEASE_DIR"
mkdir -p "$RELEASE_DIR"

log "Cloning repository..."
git clone --depth=1 "https://github.com/myorg/${APP_NAME}.git" "$RELEASE_DIR"

log "Installing dependencies..."
cd "$RELEASE_DIR"
npm ci --production

log "Running database migrations..."
NODE_ENV=production npm run migrate

log "Switching symlink..."
ln -sfn "$RELEASE_DIR" "${DEPLOY_DIR}/current"

log "Restarting application..."
systemctl restart "${APP_NAME}"

log "Deployment complete: ${TIMESTAMP}"

# Keep only last 5 releases
ls -dt "${RELEASES_DIR}/"*/ | tail -n +6 | xargs rm -rf --

Scan Your Site for Free

Our Exposure Checker runs 19 parallel security checks - SSL, headers, exposed paths, DNS, open ports, and more.

Run Free Security Scan

Scheduling with Cron

Most bash automation scripts run on a schedule via cron. The crontab format is: minute hour day-of-month month day-of-week command.

# Edit current user's crontab
crontab -e

# Run backup script every day at 2:30 AM
30 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# Run every 15 minutes
*/15 * * * * /opt/scripts/health-check.sh

# Run every Monday at 9 AM
0 9 * * 1 /opt/scripts/weekly-report.sh

# Run on the 1st of every month at midnight
0 0 1 * * /opt/scripts/monthly-cleanup.sh

Always redirect cron output to a log file with >> /var/log/script.log 2>&1. Cron jobs that produce output will attempt to email you, which silently fails on most systems.

FAQ

What is the difference between single quotes and double quotes in bash?

Single quotes preserve the literal value of every character. Variables and command substitutions are NOT expanded inside single quotes: echo '$HOME' prints $HOME. Double quotes allow variable expansion and command substitution: echo "$HOME" prints your home directory path. Use double quotes around variables to prevent word splitting: "$filename" handles filenames with spaces correctly.

Why does my script fail with "command not found" but works interactively?

Cron and some script execution environments have a minimal PATH that does not include /usr/local/bin or user-specific paths. Fix this by setting PATH explicitly at the top of your script: export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin". Also check that the script has execute permissions: chmod +x script.sh.

What does set -e do and should I always use it?

set -e (errexit) makes the script exit immediately if any command returns a non-zero exit code. This prevents scripts from silently continuing past errors. You should almost always use it, but be aware that commands in if conditions, while conditions, and after || or && are exempt from the exit-on-error behavior. Pair it with set -u (nounset) and set -o pipefail for maximum safety.

How do I pass arguments to a bash script?

Arguments are accessed via $1, $2, etc. $@ expands to all arguments as separate words. $# is the count. Always validate required arguments at the top of your script:

[[ $# -lt 2 ]] && { echo "Usage: $0 <env> <version>" >&2; exit 1; }
ENV="$1"
VERSION="$2"

How do I debug a bash script?

Run the script with bash -x script.sh to enable trace mode, which prints each command before executing it. You can also add set -x inside the script to enable tracing for a specific section, and set +x to disable it. For verbose output without expansion, use set -v. Adding echo "DEBUG: var=$var" statements is the simplest approach for targeted debugging.

What is the safest way to handle filenames with spaces?

Always double-quote variable expansions: "$filename", "${array[@]}". When looping over files, use while IFS= read -r -d '' file; do ... done < <(find . -print0) to handle filenames with spaces, newlines, and special characters correctly. Avoid parsing ls output - use glob patterns or find instead.

Quick Reference: Bash Flags

  • set -e - exit on error
  • set -u - error on unset variables
  • set -o pipefail - catch pipeline failures
  • set -x - print commands as they execute (debug mode)
  • bash -n script.sh - syntax check without executing
  • shellcheck script.sh - static analysis (install separately)

The Bottom Line

Bash scripting rewards the developer who knows its idioms. Start every script with set -euo pipefail. Quote your variables. Use functions to avoid repetition. Trap EXIT for cleanup. And schedule recurring scripts with a well-tested cron expression.

Use our free tool here → Crontab Generator to build cron schedules without memorizing the syntax, and validate your expressions before deploying them to production.

UK
Written by Usman Khan
DevOps Engineer | MSc Cybersecurity | CEH | AWS Solutions Architect

Usman has 10+ years of experience securing enterprise infrastructure, managing high-traffic servers, and building zero-knowledge security tools. Read more about the author.