Skip to main content

Functions and Script Patterns

Writing a few commands in a script is straightforward. Writing a script that is reliable, maintainable, and safe under failure conditions requires deliberate structure. This chapter covers how to define and use functions, handle errors robustly, and apply the patterns that separate a professional script from a fragile one-liner.


Defining Functions

Functions let you name a block of commands and call it multiple times. This is how you avoid repeating yourself in scripts.

Syntax

# Style 1: function keyword
function greet {
echo "Hello, $1!"
}

# Style 2: parentheses (more portable, preferred)
greet() {
echo "Hello, $1!"
}

# Call it
greet "World" # Hello, World!
greet "Ubuntu" # Hello, Ubuntu!

Functions must be defined before they are called. Put all function definitions at the top of the script (or in a separate sourced file), and put the main logic at the bottom.


Arguments Inside Functions

Inside a function, $1, $2, $@ refer to the function's arguments, not the script's arguments.

#!/bin/bash

deploy_version() {
local APP="$1"
local VERSION="$2"
local ENV="${3:-production}" # default to "production" if not provided

echo "Deploying $APP version $VERSION to $ENV"
}

# Call with arguments
deploy_version "myapp" "1.2.3"
deploy_version "myapp" "1.2.3" "staging"

The script's own positional arguments are still accessible via $BASH_ARGV inside functions, but it is cleaner to pass them explicitly.


Local Variables

By default, variables defined inside a function are global and bleed into the rest of the script. Use local to scope them to the function.

#!/bin/bash

# Without local — BAD
calculate_sum() {
RESULT=$(( $1 + $2 )) # RESULT is global — visible outside the function
}

# With local — GOOD
calculate_sum() {
local a="$1"
local b="$2"
local result=$(( a + b ))
echo "$result"
}

SUM=$(calculate_sum 10 20)
echo "Sum: $SUM"
# "result" variable does not exist here — it was local to the function

Always declare function-internal variables as local. This prevents accidental name collisions with the rest of the script.


Return Values

Bash functions can "return" values in two ways:

Exit Code (true/false)

return N sets the function's exit code (0 = success, non-zero = failure):

is_service_running() {
systemctl is-active --quiet "$1"
# is-active returns 0 if active, non-zero if not
}

if is_service_running nginx; then
echo "nginx is running"
else
echo "nginx is NOT running"
fi

Output Capture

Use echo (or printf) inside the function and capture it with $():

get_latest_version() {
local APP_DIR="$1"
ls -1 "$APP_DIR" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1
}

LATEST=$(get_latest_version "/opt/myapp")
echo "Latest version: $LATEST"

Combining Both

get_db_url() {
local ENV="$1"
local URL

case "$ENV" in
prod) URL="postgres://prod-db.example.com:5432/mydb" ;;
staging) URL="postgres://staging-db.example.com:5432/mydb" ;;
dev) URL="postgres://localhost:5432/mydb" ;;
*)
echo "Unknown environment: $ENV" >&2
return 1 # signal failure
;;
esac

echo "$URL"
return 0
}

DB_URL=$(get_db_url "$ENVIRONMENT") || {
echo "Failed to get DB URL" >&2
exit 1
}

Sourcing Scripts — source and .

source (or its alias .) executes a script in the current shell rather than a subshell. This means variables and functions defined in the sourced file become available in your current session or script.

# lib/common.sh — shared library
log_info() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"
}

log_error() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}

die() {
log_error "$@"
exit 1
}
#!/bin/bash
# main.sh

# Source the library (both forms are equivalent)
source "$(dirname "$0")/lib/common.sh"
# . "$(dirname "$0")/lib/common.sh"

log_info "Starting deployment"
# ... rest of script ...

Sourcing is also how shell configuration files work. When you add something to ~/.bashrc, new shells source that file and pick up the changes.


Error Handling

The default behaviour of bash is to continue executing after a command fails. This is dangerous in scripts — a failed mkdir might lead to writing files in the wrong place, a failed cd might cause rm -rf * to delete the wrong directory.

set -e — Exit on Error

#!/bin/bash
set -e # Exit immediately if any command returns non-zero

echo "Step 1"
cp /nonexistent/file /tmp/ # This fails
echo "Step 2" # This is NEVER reached — script exits after the cp fails

set -u — Error on Undefined Variables

#!/bin/bash
set -u # Treat unset variables as errors

echo "$UNDEFINED_VAR" # Script exits with error instead of using empty string

This catches a huge class of bugs: a typo in a variable name no longer silently evaluates to an empty string.

set -o pipefail — Propagate Pipe Errors

By default, a pipe's exit code is the exit code of the last command. This means errors early in a pipeline are silently ignored:

# Without pipefail:
cat /nonexistent | grep "something"
echo $? # 1 (grep's exit code — nothing found)
# cat's failure was silently swallowed

# With pipefail, the pipe fails if ANY command in it fails
set -o pipefail
cat /nonexistent | grep "something"
echo $? # 1 (from cat)

The Standard Preamble

Start every non-trivial script with:

#!/bin/bash
set -euo pipefail

This combination catches:

  • e: Command failures
  • u: Undefined variables
  • o pipefail: Pipe failures

trap — Cleanup on Exit

trap registers commands to run when the script exits or receives a signal. This is how you ensure cleanup always happens, even if the script fails.

Basic trap

#!/bin/bash
set -euo pipefail

TEMP_DIR=$(mktemp -d)

# Register cleanup to run when the script exits for any reason
cleanup() {
echo "Cleaning up temp files..."
rm -rf "$TEMP_DIR"
}
trap cleanup EXIT

# Now do your work — even if this fails, cleanup() will run
cd "$TEMP_DIR"
git clone https://github.com/example/repo.git .
./build.sh
cp output.jar /opt/myapp/releases/

Trap on Multiple Signals

# Clean up on exit, interrupt (Ctrl+C), or termination
trap cleanup EXIT INT TERM

# Also catch errors explicitly
trap 'echo "Error on line $LINENO" >&2; cleanup; exit 1' ERR

Lock File Pattern with Trap

#!/bin/bash
set -euo pipefail

LOCK_FILE="/tmp/deploy.lock"

if [ -f "$LOCK_FILE" ]; then
echo "Another deployment is running (lock file: $LOCK_FILE)" >&2
exit 1
fi

touch "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT

echo "Running deployment..."
# ... deploy commands ...
echo "Deployment complete"
# Lock file is removed automatically when the script exits

Rollback Pattern with Trap

#!/bin/bash
set -euo pipefail

PREVIOUS_VERSION=$(readlink /opt/myapp/current | xargs basename)
NEW_VERSION="$1"

rollback() {
echo "Deployment failed — rolling back to $PREVIOUS_VERSION" >&2
ln -sfn "/opt/myapp/$PREVIOUS_VERSION" /opt/myapp/current
sudo systemctl restart myapp
}

trap rollback ERR

# Update symlink
ln -sfn "/opt/myapp/$NEW_VERSION" /opt/myapp/current
sudo systemctl restart myapp

# Health check — if this fails, ERR trap triggers rollback
curl --fail http://localhost:3000/health

# Deactivate the rollback trap on clean exit
trap - ERR
echo "Deployment of $NEW_VERSION successful"

Heredocs

A heredoc (here document) lets you write multi-line strings directly in a script without needing a separate file.

Basic Heredoc

cat << 'EOF'
This is a multi-line string.
Variables like $HOME are NOT expanded (single-quoted delimiter).
Special characters are literal.
EOF
NAME="World"
cat << EOF
Hello, $NAME!
Today is $(date).
Variables ARE expanded (unquoted delimiter).
EOF

Write a Config File with heredoc

#!/bin/bash
set -euo pipefail

APP_NAME="myapp"
DB_HOST="prod-db.example.com"
DB_PORT="5432"

sudo tee /etc/myapp/config.conf > /dev/null << EOF
# Generated by deploy.sh on $(date)
app.name=${APP_NAME}
db.host=${DB_HOST}
db.port=${DB_PORT}
db.name=${APP_NAME}
log.level=info
EOF

echo "Config written to /etc/myapp/config.conf"

Create a systemd Unit with heredoc

create_service_unit() {
local APP="$1"
local USER="$2"
local WORK_DIR="$3"

sudo tee "/etc/systemd/system/${APP}.service" > /dev/null << EOF
[Unit]
Description=${APP} application
After=network.target

[Service]
Type=simple
User=${USER}
WorkingDirectory=${WORK_DIR}
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
echo "Service unit created for ${APP}"
}

create_service_unit "myapp" "deploy" "/opt/myapp/current"

Send Multi-line Commands Over SSH

ssh deploy@myserver << 'EOF'
set -e
cd /opt/myapp
git pull origin main
npm install --production
sudo systemctl restart myapp
EOF

Complete Production-Grade Script Template

This template incorporates all the patterns from this chapter:

#!/bin/bash
# =============================================================================
# Script: deploy.sh
# Description: Deploy application to a server environment
# Usage: ./deploy.sh <environment> <version>
# =============================================================================
set -euo pipefail

# ---------- Configuration ----------------------------------------------------
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/deploy.log"

# ---------- Logging ----------------------------------------------------------
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"; }
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [WARN] $*" | tee -a "$LOG_FILE" >&2; }
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE" >&2; }
die() { log_error "$*"; exit 1; }

# ---------- Argument Validation ----------------------------------------------
usage() {
cat << EOF
Usage: $SCRIPT_NAME <environment> <version>

Arguments:
environment Target environment: dev, staging, or prod
version Version to deploy (e.g. 1.2.3)

Examples:
$SCRIPT_NAME staging 1.2.3
$SCRIPT_NAME prod 1.2.3
EOF
exit 1
}

[ $# -eq 2 ] || usage

readonly ENVIRONMENT="$1"
readonly VERSION="$2"

# ---------- Validate Arguments -----------------------------------------------
case "$ENVIRONMENT" in
dev|staging|prod) ;;
*) die "Invalid environment: $ENVIRONMENT. Must be dev, staging, or prod." ;;
esac

if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
die "Invalid version format: $VERSION. Expected format: X.Y.Z"
fi

# ---------- Setup & Cleanup --------------------------------------------------
LOCK_FILE="/tmp/deploy-${ENVIRONMENT}.lock"
TEMP_DIR=""

cleanup() {
local exit_code=$?
[ -n "$TEMP_DIR" ] && rm -rf "$TEMP_DIR"
rm -f "$LOCK_FILE"
if [ $exit_code -ne 0 ]; then
log_error "Deployment failed (exit code: $exit_code)"
fi
}
trap cleanup EXIT

# Prevent concurrent deployments
if [ -f "$LOCK_FILE" ]; then
die "Another deployment is already running for $ENVIRONMENT (lock: $LOCK_FILE)"
fi
touch "$LOCK_FILE"

# ---------- Main Logic -------------------------------------------------------
TEMP_DIR=$(mktemp -d)
APP_DIR="/opt/myapp"
RELEASE_DIR="${APP_DIR}/${VERSION}"

log "Starting deployment: version=$VERSION environment=$ENVIRONMENT"

# Verify release directory exists
[ -d "$RELEASE_DIR" ] || die "Release directory not found: $RELEASE_DIR"

# Update symlink
log "Updating symlink: current -> $VERSION"
ln -sfn "$RELEASE_DIR" "${APP_DIR}/current"

# Restart service
log "Restarting myapp service"
sudo systemctl restart myapp

# Health check
log "Running health check"
RETRIES=0
until curl -sf http://localhost:3000/health > /dev/null; do
RETRIES=$((RETRIES + 1))
[ $RETRIES -ge 15 ] && die "Health check failed after 15 attempts"
log_warn "Health check attempt $RETRIES/15..."
sleep 3
done

log "Deployment complete: $ENVIRONMENT is running version $VERSION"