Vucense

Bash Scripting Guide 2026: Automate Linux Tasks on Ubuntu 24.04

🟢Beginner

Complete bash scripting guide for Ubuntu 24.04 LTS. Variables, arrays, conditionals, loops, functions, error handling, argument parsing, and real-world automation scripts. Fully tested with expected output.

Noah Choi

Author

Noah Choi

Linux & Cloud Native Infrastructure Engineer

Published

Duration

Reading

20 min

Build

30 min

Bash Scripting Guide 2026: Automate Linux Tasks on Ubuntu 24.04
Article Roadmap

Key Takeaways

  • set -euo pipefail is non-negotiable: -e exits on error, -u catches undefined variables (typos like $HOMR instead of $HOME), -o pipefail ensures false | true fails rather than silently succeeding because true exits 0.
  • [[ ]] over [ ]: Double brackets are bash-specific and safe. They handle empty strings and spaces without extra quoting. Use [[ -f "$file" ]], not [ -f $file ].
  • trap for guaranteed cleanup: trap 'cleanup' EXIT ERR runs your cleanup function on any exit path — success, failure, or Ctrl-C. Prevents orphaned temp files and partial state.
  • shellcheck before 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


Tested on: Ubuntu 24.04 LTS. Bash 5.2.21, ShellCheck 0.9.0. Last verified: April 22, 2026.

Further Reading

All Dev Corner

Comments