Skip to main content
Bash is the glue of Linux systems work. I have written scripts ranging from one-liners to thousand-line deployment frameworks, and the patterns below represent hard-won lessons about what holds up at 3 AM when something breaks. Bash is not a general-purpose language — use Python for anything that needs data structures, HTTP calls, or complex logic — but for process orchestration, system automation, and operations tooling, a well-structured Bash script beats almost anything for portability and transparency.

Script Structure and Safety Flags

Every non-trivial script should open with this header. The few extra seconds it takes are worth avoiding hours of debugging half-completed state.
#!/usr/bin/env bash
# ============================================================
# Script:  deploy-app.sh
# Purpose: Deploy application artifacts to target host
# Author:  Valeriy Khashkovskiy
# Usage:   ./deploy-app.sh [-e ENV] [-v VERSION] [-h]
# ============================================================

set -euo pipefail
# -e  exit immediately on any non-zero command
# -u  treat unset variables as errors
# -o pipefail  pipeline fails if any command in it fails

IFS=$'\n\t'
# Safer default field separator — avoids word-splitting on spaces
set -e — Without this, scripts happily continue after a failed cp or curl. With it, they stop and let you investigate.set -u — Catches typos in variable names. rm -rf $TMPDI/ (note the typo) would become rm -rf / without this flag.set -o pipefail — Without this, cat missing_file.txt | grep foo returns exit code 0 because grep succeeded — even though cat failed.IFS=$'\n\t' — The default IFS includes space, which causes word-splitting on filenames with spaces. Changing it prevents most quoting bugs.

Variables, Arrays, and String Operations

# Always quote variable expansions
NAME="Valeriy"
echo "Hello, ${NAME}!"       # prefer ${} syntax for clarity

# Default values
LOG_DIR="${LOG_DIR:-/var/log/app}"    # use default if unset or empty
PORT="${PORT:=8080}"                   # assign default if unset

# Read-only constants
readonly MAX_RETRIES=3
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Command substitution
CURRENT_DATE=$(date +%Y%m%d)
HOSTNAME=$(hostname -f)
RUNNING_PROCS=$(ps aux | wc -l)

Conditionals

# File / path tests
if [[ -f /etc/app.conf ]]; then
    echo "Config exists"
elif [[ -d /etc/app ]]; then
    echo "Directory exists but no config file"
else
    echo "Neither found"
fi

# String comparisons
if [[ "${ENVIRONMENT}" == "production" ]]; then
    echo "Running in production — extra care!"
elif [[ -z "${ENVIRONMENT}" ]]; then
    echo "ENVIRONMENT is empty"
fi

# Numeric comparisons — use (( )) for arithmetic
FREE_MB=$(free -m | awk '/^Mem:/{print $7}')
if (( FREE_MB < 512 )); then
    echo "WARNING: Low memory: ${FREE_MB} MB available"
fi

# Combining conditions
if [[ -f "${LOCKFILE}" && -s "${LOCKFILE}" ]]; then
    echo "Lockfile exists and is non-empty"
fi

# Common test flags
# -f  regular file        -d  directory
# -e  exists              -r  readable
# -w  writable            -x  executable
# -s  non-empty file      -L  symlink
# -z  empty string        -n  non-empty string

Loops

# Iterate over a list
for env in dev staging production; do
    echo "Deploying to ${env}..."
done

# Iterate over array
for server in "${SERVERS[@]}"; do
    ssh "${server}" "sudo systemctl restart app"
done

# C-style numeric loop
for (( i=1; i<=10; i++ )); do
    echo "Attempt ${i}"
done

# Iterate over files
for conf in /etc/app/*.conf; do
    [[ -f "${conf}" ]] || continue    # skip if glob matched nothing
    echo "Processing ${conf}"
done

# Iterate over command output lines
while IFS= read -r line; do
    echo "Processing: ${line}"
done < <(find /var/data -name "*.json" -mtime -1)

Functions

# Define before calling
log() {
    local level="${1}"
    local message="${2}"
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [${level}] ${message}" | tee -a "${LOG_FILE:-/tmp/script.log}"
}

die() {
    log "ERROR" "${1}"
    exit "${2:-1}"
}

# Call
log "INFO" "Starting deployment"
die "Config file not found" 2

Error Handling and Exit Codes

# Always-run cleanup on exit
TMPDIR_WORK=$(mktemp -d)
cleanup() {
    rm -rf "${TMPDIR_WORK}"
    log "INFO" "Cleanup complete"
}
trap cleanup EXIT

# Handle interrupts gracefully
trap 'log "WARN" "Caught SIGINT — aborting"; exit 130' INT
trap 'log "WARN" "Caught SIGTERM — aborting"; exit 143' TERM

# Debug trap — print each command before executing (useful during dev)
# trap 'echo "DEBUG: line ${LINENO}: ${BASH_COMMAND}"' DEBUG

Common Patterns

usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Options:
  -e, --env ENV        Target environment (dev|staging|prod) [required]
  -v, --version VER    Application version to deploy [required]
  -n, --dry-run        Simulate without making changes
  -h, --help           Show this help

Examples:
  $(basename "$0") -e production -v 2.4.1
  $(basename "$0") --env staging --version 2.4.0 --dry-run
EOF
}

ENVIRONMENT=""
VERSION=""
DRY_RUN=false

while [[ $# -gt 0 ]]; do
    case "$1" in
        -e|--env)
            ENVIRONMENT="${2}"
            shift 2
            ;;
        -v|--version)
            VERSION="${2}"
            shift 2
            ;;
        -n|--dry-run)
            DRY_RUN=true
            shift
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            echo "Unknown option: ${1}" >&2
            usage >&2
            exit 2
            ;;
    esac
done

[[ -n "${ENVIRONMENT}" ]] || { echo "Error: -e/--env is required" >&2; usage >&2; exit 2; }
[[ -n "${VERSION}" ]]     || { echo "Error: -v/--version is required" >&2; usage >&2; exit 2; }

Useful One-Liners and Idioms

# Print script name and location
echo "Running: $(basename "$0") from ${SCRIPT_DIR}"

# Confirm before destructive action
read -r -p "Delete all logs in /var/log/app? [y/N] " confirm
[[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; }

# Here-doc for config file generation
cat > /etc/app/config.ini <<EOF
[database]
host = ${DB_HOST}
port = ${DB_PORT}
name = ${DB_NAME}
EOF

# Run in parallel with wait
for server in "${SERVERS[@]}"; do
    ssh "${server}" "sudo systemctl restart app" &
done
wait    # block until all background jobs finish
echo "All servers restarted"

# Idempotent directory + ownership setup
install -d -m 755 -o app -g app /opt/app/{bin,conf,logs,tmp}

# Check if running inside a container
is_container() {
    [[ -f /.dockerenv ]] || grep -q 'docker\|lxc' /proc/1/cgroup 2>/dev/null
}

# Extract version from file
VERSION=$(grep -oP '(?<=version = ")[^"]+' setup.cfg)

# Safe temp file that auto-cleans
TMPFILE=$(mktemp /tmp/report.XXXXXX)
trap 'rm -f "${TMPFILE}"' EXIT

Complete Deployment Script Template

1

Save and make executable

chmod +x deploy-app.sh
2

Review the full template

#!/usr/bin/env bash
# ============================================================
# deploy-app.sh — Application deployment script
# ============================================================
set -euo pipefail
IFS=$'\n\t'

# ── Constants ────────────────────────────────────────────────
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/app/deploy-$(date +%Y%m%d-%H%M%S).log"
readonly LOCKFILE="/var/run/${SCRIPT_NAME%.sh}.lock"
readonly DEPLOY_USER="app"
readonly DEPLOY_BASE="/opt/app"

# ── Logging ──────────────────────────────────────────────────
mkdir -p "$(dirname "${LOG_FILE}")"

log() {
    local level="${1}"; shift
    local ts; ts=$(date '+%Y-%m-%d %H:%M:%S')
    local line="[${ts}] [${level}] $*"
    echo "${line}" >> "${LOG_FILE}"
    case "${level}" in
        ERROR|WARN) echo "${line}" >&2 ;;
        *) echo "${line}" ;;
    esac
}

die() { log "ERROR" "$1"; exit "${2:-1}"; }

# ── Cleanup / Locking ────────────────────────────────────────
cleanup() {
    rm -rf "${LOCKFILE}"
    log "INFO" "Cleanup done. Full log: ${LOG_FILE}"
}
trap cleanup EXIT
trap 'die "Interrupted" 130' INT TERM

if ! mkdir "${LOCKFILE}" 2>/dev/null; then
    die "Another deployment is in progress. Aborting."
fi
echo $$ > "${LOCKFILE}/pid"

# ── Usage ────────────────────────────────────────────────────
usage() {
    cat <<EOF
Usage: ${SCRIPT_NAME} -e ENV -v VERSION [-n] [-h]
  -e ENV        Environment: dev | staging | prod
  -v VERSION    Version tag to deploy (e.g. 2.4.1)
  -n            Dry-run (no changes made)
  -h            Help
EOF
}

# ── Argument Parsing ─────────────────────────────────────────
ENVIRONMENT=""
VERSION=""
DRY_RUN=false

while [[ $# -gt 0 ]]; do
    case "$1" in
        -e) ENVIRONMENT="$2"; shift 2 ;;
        -v) VERSION="$2";     shift 2 ;;
        -n) DRY_RUN=true;     shift   ;;
        -h) usage; exit 0              ;;
        *)  usage >&2; die "Unknown option: $1" 2 ;;
    esac
done

[[ -n "${ENVIRONMENT}" ]] || { usage >&2; die "-e ENV is required" 2; }
[[ -n "${VERSION}" ]]     || { usage >&2; die "-v VERSION is required" 2; }

# ── Pre-flight Checks ────────────────────────────────────────
log "INFO" "=== Deployment starting: ${SCRIPT_NAME} v${VERSION} → ${ENVIRONMENT} ==="
log "INFO" "Dry-run: ${DRY_RUN}"

for cmd in rsync ssh systemctl; do
    command -v "${cmd}" &>/dev/null || die "Required command not found: ${cmd}" 127
done

case "${ENVIRONMENT}" in
    prod|production)
        SERVERS=("web01.prod" "web02.prod")
        ;;
    staging)
        SERVERS=("web01.staging")
        ;;
    dev|development)
        SERVERS=("web01.dev")
        ;;
    *)
        die "Unknown environment: ${ENVIRONMENT}" 2
        ;;
esac

# ── Deploy Function ──────────────────────────────────────────
deploy_to_server() {
    local server="${1}"
    log "INFO" "Deploying to ${server}..."

    if [[ "${DRY_RUN}" == "true" ]]; then
        log "INFO" "[DRY-RUN] Would rsync to ${server}"
        return 0
    fi

    rsync -az --delete \
        "/opt/releases/${VERSION}/" \
        "${DEPLOY_USER}@${server}:${DEPLOY_BASE}/current/" \
        || die "rsync to ${server} failed" 1

    ssh "${DEPLOY_USER}@${server}" \
        "sudo systemctl restart app && systemctl is-active --quiet app" \
        || die "Service restart failed on ${server}" 1

    log "INFO" "Successfully deployed to ${server}"
}

# ── Main ─────────────────────────────────────────────────────
for server in "${SERVERS[@]}"; do
    deploy_to_server "${server}"
done

log "INFO" "=== Deployment complete: ${VERSION} → ${ENVIRONMENT} ==="
exit 0
3

Test with dry-run first

./deploy-app.sh -e staging -v 2.4.1 -n
4

Run for real

./deploy-app.sh -e staging -v 2.4.1
The readonly SCRIPT_DIR pattern using BASH_SOURCE[0] is the correct way to get the script’s own directory even when the script is sourced or called via a symlink. Never use $0 alone for this purpose.

Linux Essentials

The core commands your scripts will call.

Troubleshooting

Debug failing scripts and the services they manage.

GitLab CI/CD

Integrate these scripts into automated pipelines.

Docker

Containerise the applications your scripts deploy.
Last modified on June 9, 2026