Key Takeaways
set -euo pipefailis non-negotiable:-eexits on error,-ucatches undefined variables (typos like$HOMRinstead of$HOME),-o pipefailensuresfalse | truefails rather than silently succeeding becausetrueexits 0.[[ ]]over[ ]: Double brackets are bash-specific and safe. They handle empty strings and spaces without extra quoting. Use[[ -f "$file" ]], not[ -f $file ].trapfor guaranteed cleanup:trap 'cleanup' EXIT ERRruns your cleanup function on any exit path — success, failure, or Ctrl-C. Prevents orphaned temp files and partial state.shellcheckbefore every deploy:sudo apt-get install shellcheck && shellcheck script.sh. It explains every warning in plain English. No script should go to production without a clean ShellCheck pass.
Introduction
Direct Answer: How do I write bash scripts on Ubuntu 24.04 LTS in 2026?
Create a file ending in .sh, open it with #!/bin/bash on line 1 and set -euo pipefail on line 2, write your commands, then run chmod +x script.sh to make it executable and ./script.sh to run it. Variables are assigned with VAR=value (no spaces) and referenced with $VAR or ${VAR}. Test conditions with if [[ condition ]]; then ... fi. Iterate with for item in "${array[@]}"; do ... done. Functions are defined with name() { commands; }. The set -euo pipefail safety line is the most important line in any script: -e exits immediately when any command fails, -u errors on undefined variables (catching typos before they cause damage), and -o pipefail makes pipe failures visible instead of hidden. Ubuntu 24.04 ships with bash 5.2 — no installation required. Install ShellCheck with sudo apt-get install shellcheck to lint your scripts before running them.
Setup
mkdir -p ~/scripts && cd ~/scripts
sudo apt-get install -y shellcheck
shellcheck --version | head -1
Expected output:
ShellCheck - shell script analysis tool
Part 1: The Essential Script Template
cat > ~/scripts/deploy.sh << 'EOF'
#!/bin/bash
# ─────────────────────────────────────────────────────────────
# Script: deploy.sh
# Usage: ./deploy.sh [--dry-run] <app-name>
# ─────────────────────────────────────────────────────────────
set -euo pipefail
IFS=$'\n\t' # Split on newline+tab only, not spaces
# Constants
readonly SCRIPT="$(basename "$0")"
readonly LOG="/var/log/deploy.log"
# Colour helpers (disable if not a TTY)
if [[ -t 1 ]]; then
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; NC=''
fi
log() { echo -e "${GREEN}[INFO]${NC} $*" | tee -a "$LOG"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*" | tee -a "$LOG" >&2; }
die() { echo -e "${RED}[ERROR]${NC} $*" | tee -a "$LOG" >&2; exit 1; }
# Cleanup runs on any exit (success, failure, or Ctrl-C)
TMPDIR_WORK=""
cleanup() {
[[ -n "$TMPDIR_WORK" && -d "$TMPDIR_WORK" ]] && rm -rf "$TMPDIR_WORK"
}
trap cleanup EXIT
# Argument parsing
DRY_RUN=false
APP=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--help|-h) echo "Usage: $SCRIPT [--dry-run] <app>"; exit 0 ;;
-*) die "Unknown flag: $1" ;;
*) APP="$1"; shift ;;
esac
done
[[ -z "$APP" ]] && die "Missing app name. Usage: $SCRIPT [--dry-run] <app>"
# Dependency check
for cmd in docker git curl; do
command -v "$cmd" &>/dev/null || die "Required command not found: $cmd"
done
main() {
TMPDIR_WORK="$(mktemp -d)"
log "Deploying: $APP (dry-run: $DRY_RUN)"
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — no changes will be made"
return
fi
log "Build complete."
}
main "$@"
EOF
chmod +x ~/scripts/deploy.sh
shellcheck ~/scripts/deploy.sh && echo "ShellCheck: clean"
./scripts/deploy.sh --dry-run myapp
Expected output:
ShellCheck: clean
[INFO] Deploying: myapp (dry-run: true)
[WARN] DRY RUN — no changes will be made
Part 2: Variables and String Operations
cat > ~/scripts/variables.sh << 'EOF'
#!/bin/bash
set -euo pipefail
# Scalars
NAME="Ubuntu"
VERSION=24
readonly MAX=3 # Constant — write-protected
# Default values
LOG_LEVEL="${LOG_LEVEL:-info}" # Use env var if set, else "info"
TARGET="${1:-/var}" # First arg, defaulting to /var
echo "Name: $NAME"
echo "Version: $VERSION"
echo "LogLevel: $LOG_LEVEL"
echo "Target: $TARGET"
# String operations
FILE="/var/log/nginx/access.log"
echo "Dir: ${FILE%/*}" # Remove last /component
echo "Basename: ${FILE##*/}" # Everything after last /
echo "No ext: ${FILE%.*}" # Remove extension
echo "Upper: ${NAME^^}" # Uppercase (bash 4+)
echo "Length: ${#FILE}"
echo "Replace: ${FILE/nginx/apache}"
# Indexed array
SERVERS=("web01" "web02" "db01")
echo "All: ${SERVERS[*]}"
echo "First: ${SERVERS[0]}"
echo "Last: ${SERVERS[-1]}"
echo "Count: ${#SERVERS[@]}"
for server in "${SERVERS[@]}"; do
echo " Server: $server"
done
# Associative array (bash 4+)
declare -A PORTS=([nginx]=80 [postgres]=5432 [redis]=6379)
for svc in "${!PORTS[@]}"; do
echo " $svc → port ${PORTS[$svc]}"
done
EOF
bash ~/scripts/variables.sh /tmp
Expected output:
Name: Ubuntu
Version: 24
LogLevel: info
Target: /tmp
Dir: /var/log/nginx
Basename: access.log
No ext: /var/log/nginx/access
Upper: UBUNTU
Length: 26
Replace: /var/log/apache/access.log
All: web01 web02 db01
First: web01
Last: db01
Count: 3
Server: web01
Server: web02
Server: db01
nginx → port 80
postgres → port 5432
redis → port 6379
Part 3: Conditionals and Comparisons
cat > ~/scripts/conditionals.sh << 'EOF'
#!/bin/bash
set -euo pipefail
FILE="/etc/hostname"
DIR="/var/log"
SERVICE="nginx"
# ── File tests ────────────────────────────────────────────────────────────
[[ -f "$FILE" ]] && echo "File exists: $FILE"
[[ -d "$DIR" ]] && echo "Dir exists: $DIR"
[[ -r "$FILE" ]] && echo "File is readable"
[[ -s "$FILE" ]] && echo "File is non-empty"
[[ -x "/usr/sbin/nginx" ]] && echo "nginx binary is executable" || echo "nginx not found"
# ── String comparisons ────────────────────────────────────────────────────
OS="$(uname -s)"
[[ "$OS" == "Linux" ]] && echo "Running on Linux"
[[ "$OS" != "Darwin" ]] && echo "Not macOS"
[[ -z "" ]] && echo "Empty string is true"
[[ -n "hello" ]] && echo "Non-empty string is true"
[[ "abc" =~ ^[a-z]+$ ]] && echo "String matches regex [a-z]+"
# ── Integer comparisons ───────────────────────────────────────────────────
COUNT=5
(( COUNT > 3 )) && echo "$COUNT > 3"
(( COUNT <= 10 )) && echo "$COUNT <= 10"
[[ $COUNT -eq 5 ]] && echo "$COUNT equals 5" # Alternative syntax
# ── Compound conditions ───────────────────────────────────────────────────
USER_ID="$(id -u)"
if [[ "$USER_ID" -eq 0 ]]; then
echo "Running as root"
elif [[ "$USER_ID" -gt 1000 ]]; then
echo "Running as regular user (UID $USER_ID)"
else
echo "Running as system user (UID $USER_ID)"
fi
# ── Case statement ────────────────────────────────────────────────────────
ENV="${APP_ENV:-development}"
case "$ENV" in
production) echo "Production: strict settings" ;;
staging) echo "Staging: moderate settings" ;;
development) echo "Development: relaxed settings" ;;
*) echo "Unknown environment: $ENV" ;;
esac
EOF
bash ~/scripts/conditionals.sh
Expected output:
File exists: /etc/hostname
Dir exists: /var/log
File is readable
File is non-empty
Running on Linux
Not macOS
Empty string is true
Non-empty string is true
String matches regex [a-z]+
5 > 3
5 <= 10
5 equals 5
Running as regular user (UID 1000)
Development: relaxed settings
Part 4: Loops
cat > ~/scripts/loops.sh << 'EOF'
#!/bin/bash
set -euo pipefail
# ── For loop over list ────────────────────────────────────────────────────
PACKAGES=(curl wget jq git)
echo "Checking packages:"
for pkg in "${PACKAGES[@]}"; do
if command -v "$pkg" &>/dev/null; then
echo " ✓ $pkg $(command -v "$pkg")"
else
echo " ✗ $pkg — not installed"
fi
done
# ── C-style for loop ─────────────────────────────────────────────────────
echo ""
echo "Countdown:"
for (( i=5; i>=1; i-- )); do
echo -n " $i "
done
echo "Go!"
# ── While loop with counter ───────────────────────────────────────────────
echo ""
echo "Retry pattern:"
MAX_ATTEMPTS=3
attempt=1
while (( attempt <= MAX_ATTEMPTS )); do
echo " Attempt $attempt/$MAX_ATTEMPTS"
# Simulate success on attempt 2
if (( attempt == 2 )); then
echo " ✓ Success on attempt $attempt"
break
fi
(( attempt++ ))
done
# ── Read lines from file ──────────────────────────────────────────────────
echo ""
echo "Reading /etc/os-release key lines:"
while IFS='=' read -r key value; do
case "$key" in
NAME|VERSION_ID|ID) echo " $key = ${value//\"/}" ;;
esac
done < /etc/os-release
# ── Loop over command output ──────────────────────────────────────────────
echo ""
echo "Large files in /var/log:"
while IFS= read -r line; do
echo " $line"
done < <(find /var/log -type f -size +100k -exec ls -lh {} \; 2>/dev/null | \
awk '{print $5, $NF}' | sort -hr | head -5)
EOF
bash ~/scripts/loops.sh
Expected output:
Checking packages:
✓ curl /usr/bin/curl
✓ wget /usr/bin/wget
✓ jq /usr/bin/jq
✓ git /usr/bin/git
Countdown:
5 4 3 2 1 Go!
Retry pattern:
Attempt 1/3
Attempt 2/3
✓ Success on attempt 2
Reading /etc/os-release key lines:
NAME = Ubuntu
VERSION_ID = 24.04
ID = ubuntu
Large files in /var/log:
1.2M /var/log/syslog
847K /var/log/nginx/access.log
Part 5: Functions
cat > ~/scripts/functions.sh << 'EOF'
#!/bin/bash
set -euo pipefail
# ── Basic function ─────────────────────────────────────────────────────────
greet() {
local name="${1:-World}" # local = function-scoped variable
echo "Hello, $name!"
}
greet "Ubuntu"
greet
# ── Function with return value via echo ───────────────────────────────────
get_os_version() {
local version
version="$(lsb_release -rs 2>/dev/null)" || version="unknown"
echo "$version" # Return value via stdout, captured with $()
}
OS_VER="$(get_os_version)"
echo "OS version: $OS_VER"
# ── Function with exit code ───────────────────────────────────────────────
service_running() {
local service="$1"
systemctl is-active --quiet "$service" 2>/dev/null
# Implicitly returns exit code of systemctl (0 = active, non-zero = inactive)
}
for svc in nginx postgresql nonexistent-service; do
if service_running "$svc"; then
echo " ✓ $svc is running"
else
echo " ✗ $svc is not running"
fi
done
# ── Function that modifies a nameref variable (bash 4.3+) ─────────────────
parse_url() {
local url="$1"
local -n _result="$2" # nameref — alias for caller's variable
if [[ "$url" =~ ^(https?)://([^/:]+)(:([0-9]+))?(/.*)? ]]; then
_result[scheme]="${BASH_REMATCH[1]}"
_result[host]="${BASH_REMATCH[2]}"
_result[port]="${BASH_REMATCH[4]:-443}"
_result[path]="${BASH_REMATCH[5]:-/}"
else
return 1
fi
}
declare -A url_parts
parse_url "https://api.example.com:8443/v1/users" url_parts
echo "Scheme: ${url_parts[scheme]}"
echo "Host: ${url_parts[host]}"
echo "Port: ${url_parts[port]}"
echo "Path: ${url_parts[path]}"
EOF
bash ~/scripts/functions.sh
Expected output:
Hello, Ubuntu!
Hello, World!
OS version: 24.04
✗ nginx is not running
✗ postgresql is not running
✗ nonexistent-service is not running
Scheme: https
Host: api.example.com
Port: 8443
Path: /v1/users
Part 6: Error Handling and Trap
cat > ~/scripts/error-handling.sh << 'EOF'
#!/bin/bash
set -euo pipefail
# Global state for cleanup
LOCK_FILE="/tmp/myscript.lock"
TEMP_FILES=()
cleanup() {
local exit_code=$?
# Remove temp files
for f in "${TEMP_FILES[@]}"; do
[[ -f "$f" ]] && rm -f "$f" && echo "[cleanup] Removed: $f"
done
# Release lock
[[ -f "$LOCK_FILE" ]] && rm -f "$LOCK_FILE" && echo "[cleanup] Lock released"
(( exit_code != 0 )) && echo "[cleanup] Script exited with code $exit_code" >&2
}
trap cleanup EXIT
# Prevent concurrent runs
if [[ -f "$LOCK_FILE" ]]; then
echo "Another instance is running (lock: $LOCK_FILE)" >&2
exit 1
fi
touch "$LOCK_FILE"
echo "Lock acquired"
# Register temp files for cleanup
TMP1="$(mktemp)"
TMP2="$(mktemp)"
TEMP_FILES+=("$TMP1" "$TMP2")
echo "Created temp files: $TMP1, $TMP2"
# Safe command execution with custom error message
run_or_die() {
local desc="$1"; shift
echo "Running: $desc"
if ! "$@"; then
echo "FAILED: $desc" >&2
exit 1
fi
}
run_or_die "Write to temp file" bash -c "echo 'data' > $TMP1"
run_or_die "Verify temp file" test -s "$TMP1"
echo "All operations succeeded"
# trap cleanup runs here on normal exit
EOF
bash ~/scripts/error-handling.sh
Expected output:
Lock acquired
Created temp files: /tmp/tmp.XXXXXX, /tmp/tmp.YYYYYY
Running: Write to temp file
Running: Verify temp file
All operations succeeded
[cleanup] Removed: /tmp/tmp.XXXXXX
[cleanup] Removed: /tmp/tmp.YYYYYY
[cleanup] Lock released
Part 7: Real-World Script — Server Health Check
cat > ~/scripts/health-check.sh << 'EOF'
#!/bin/bash
# health-check.sh — check server health and print a report
# Usage: ./health-check.sh [--warn-threshold 80] [--crit-threshold 90]
set -euo pipefail
WARN=80
CRIT=90
while [[ $# -gt 0 ]]; do
case "$1" in
--warn-threshold) WARN="$2"; shift 2 ;;
--crit-threshold) CRIT="$2"; shift 2 ;;
*) shift ;;
esac
done
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m'
status_icon() {
local pct="$1"
if (( pct >= CRIT )); then echo -e "${RED}CRIT${NC}"
elif (( pct >= WARN )); then echo -e "${YELLOW}WARN${NC}"
else echo -e "${GREEN}OK ${NC}"
fi
}
echo "====== Server Health: $(hostname) @ $(date '+%Y-%m-%d %H:%M') ======"
# CPU load
LOAD1="$(awk '{print $1}' /proc/loadavg)"
NCPU="$(nproc)"
LOAD_PCT="$(echo "$LOAD1 $NCPU" | awk '{printf "%d", ($1/$2)*100}')"
printf "CPU Load: [%s] %s%% (load %.2f, %d CPUs)\n" \
"$(status_icon "$LOAD_PCT")" "$LOAD_PCT" "$LOAD1" "$NCPU"
# Memory
read -r _ total used _ < <(free -m | grep "^Mem:")
MEM_PCT=$(( used * 100 / total ))
printf "Memory: [%s] %d%% (%dMB used of %dMB)\n" \
"$(status_icon "$MEM_PCT")" "$MEM_PCT" "$used" "$total"
# Disk
while IFS= read -r line; do
read -r _ _ _ _ pct_raw mount <<< "$line"
pct="${pct_raw//%/}"
printf "Disk %-6s [%s] %s used\n" "$mount" "$(status_icon "$pct")" "$pct_raw"
done < <(df -h --output=source,size,used,avail,pcent,target | \
grep -E '^/dev/' | sort -k6)
# Key services
echo "Services:"
for svc in nginx postgresql docker ollama; do
if systemctl is-active --quiet "$svc" 2>/dev/null; then
echo -e " ${GREEN}✓${NC} $svc"
else
echo -e " ${RED}✗${NC} $svc (inactive)"
fi
done
echo "======================================"
EOF
chmod +x ~/scripts/health-check.sh
bash ~/scripts/health-check.sh
Expected output:
====== Server Health: hetzner-cx22 @ 2026-04-22 10:30 ======
CPU Load: [OK ] 12% (load 0.23, 2 CPUs)
Memory: [OK ] 47% (1847MB used of 3907MB)
Disk / [OK ] 21% used
Services:
✓ nginx
✓ postgresql
✓ docker
✗ ollama (inactive)
======================================
Quick Reference
# ── Safety ────────────────────────────────────────────────────────────────
set -euo pipefail # Always first
IFS=$'\n\t' # Safe word splitting
# ── Variables ─────────────────────────────────────────────────────────────
VAR="value" # Assign (no spaces around =)
echo "$VAR" # Reference
echo "${VAR:-default}" # Default if unset/empty
readonly CONST="fixed" # Constant
local LOCAL="scoped" # Function-local variable
# ── Tests ─────────────────────────────────────────────────────────────────
[[ -f file ]] # File exists and is regular file
[[ -d dir ]] # Directory exists
[[ -z "$str" ]] # String is empty
[[ -n "$str" ]] # String is non-empty
[[ "$a" == "$b" ]] # String equality
[[ $n -gt 10 ]] # Integer comparison
[[ "$s" =~ ^[0-9]+$ ]] # Regex match
# ── Arrays ────────────────────────────────────────────────────────────────
ARR=("a" "b" "c") # Create
ARR+=("d") # Append
"${ARR[@]}" # All elements
"${#ARR[@]}" # Length
"${ARR[0]}" # First element
"${ARR[-1]}" # Last element
# ── Loops ─────────────────────────────────────────────────────────────────
for item in "${ARR[@]}"; do ... done
for (( i=0; i<10; i++ )); do ... done
while IFS= read -r line; do ... done < file
# ── Functions ─────────────────────────────────────────────────────────────
my_func() { local arg="$1"; echo "$arg"; }
result="$(my_func "value")" # Capture output
# ── Error handling ────────────────────────────────────────────────────────
trap cleanup EXIT
cmd || { echo "cmd failed" >&2; exit 1; }
Troubleshooting
Script runs fine manually but fails in cron
Cause: Cron has a minimal PATH — commands like docker, node, python3 aren’t found.
Fix: Add PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin at the top of the script, or use full paths for all commands.
unbound variable error with set -u
Cause: Referencing a variable before it’s set.
Fix: Use default syntax: "${VAR:-}" (empty default) or "${VAR:-default_value}" to make unset safe.
bad substitution error
Cause: Running a bash-specific construct (${VAR^^}, [[ ]], (( ))) under /bin/sh.
Fix: Ensure the shebang is #!/bin/bash, not #!/bin/sh.
Conclusion
Bash scripting on Ubuntu 24.04 is a fundamental DevOps skill: set -euo pipefail for safety, [[ ]] for conditionals, trap for cleanup, functions for reuse, and ShellCheck before every deployment. The health-check script in Part 7 is a practical starting point — extend it with email alerting, Slack webhooks, or Prometheus metric output.
Connect scripts to automated scheduling with Cron Jobs and systemd Timers on Ubuntu 24.04, or run them across multiple servers with Python for DevOps Automation and Fabric SSH.
People Also Ask
What is the difference between $() and backticks for command substitution?
Both $(command) and `command` capture command output, but $() is preferred in 2026. $() nests cleanly — $(outer $(inner)) — while backticks require escaping: `outer \`inner\ “. $() is readable and recommended by ShellCheck. Backticks are POSIX-compatible for scripts targeting /bin/sh, but for bash scripts there is no reason to use them.
Should I use #!/bin/bash or #!/usr/bin/env bash?
#!/usr/bin/env bash finds bash in the PATH, making the script portable across systems where bash may not be at /bin/bash (macOS with Homebrew, NixOS). On Ubuntu 24.04, bash is always at /bin/bash, so both work. For scripts you’ll only run on Ubuntu servers, #!/bin/bash is fine and slightly faster. For scripts distributed to multiple platforms or shared in open-source repositories, #!/usr/bin/env bash is the better choice.
How do I debug a bash script?
Add set -x to enable execution tracing — bash prints every command before running it, with + prefixes. Add set +x to stop tracing. For a specific section: wrap it in set -x; ...; set +x. Run with bash -x script.sh for whole-script tracing without editing the file. For complex bugs, add echo "DEBUG: var=$VAR line=$LINENO" statements. ShellCheck catches static bugs; set -x reveals runtime behaviour.
Further Reading
- Cron Jobs and systemd Timers on Ubuntu 24.04 — schedule the scripts you write here
- Linux File Permissions Guide — understand
chmod +xand file permission bits - Python for DevOps Automation — when bash isn’t enough, Python takes over
- Ubuntu 24.04 LTS Server Setup Checklist — uses bash scripts throughout
Tested on: Ubuntu 24.04 LTS. Bash 5.2.21, ShellCheck 0.9.0. Last verified: April 22, 2026.