What a Bash Script Actually Is
A Bash script is a plain text file containing shell commands, executed in sequence by the Bash interpreter. There is no compilation step, no runtime to install, and no dependency manager. The file starts with a shebang line that tells the kernel which interpreter to use.
The shebang `#!/usr/bin/env bash` is preferable to `#!/bin/bash` on modern systems because it resolves the Bash binary from your PATH rather than hardcoding the path. On macOS, for example, `/bin/bash` is still Bash 3.2 due to GPL licensing constraints, while Homebrew installs Bash 5.x at a different path. Using `env bash` means your script picks up the right version automatically.
Once saved, you make the file executable with `chmod +x` and run it directly. The script runs in a subshell, which means variables set inside do not pollute your current session unless you explicitly source the file with `.` or `source`.
#!/usr/bin/env bash
# Save as: hello.sh
echo "Running on: $(uname -sr)"
echo "Bash version: ${BASH_VERSION}"
Variables, Quoting, and the Mistakes Everyone Makes
Variable assignment in Bash has no spaces around the equals sign. `NAME="webserver"` works. `NAME = "webserver"` fails with a command not found error because Bash treats `NAME` as a command being passed `=` as an argument.
Reference a variable with `$NAME` or the safer `${NAME}`. The curly braces matter when you are concatenating: `${NAME}_backup` correctly produces `webserver_backup`, while `$NAME_backup` looks up a variable named `NAME_backup` which is almost certainly empty.
Quoting is where most beginners introduce bugs. Double quotes expand variables. Single quotes do not. If a variable contains spaces - say a filename with spaces - an unquoted `$VAR` in a command will split into multiple words and break. Always double-quote variable expansions unless you explicitly want word splitting.
Command substitution uses `$(command)` syntax. The older backtick syntax `` `command` `` works but is harder to nest and read. Prefer `$(...)` universally.
#!/usr/bin/env bash
HOSTNAME_SHORT="$(hostname -s)"
BACKUP_DIR="/var/backups/${HOSTNAME_SHORT}"
# Wrong - breaks on paths with spaces
ls $BACKUP_DIR
# Correct
ls "${BACKUP_DIR}"
# Single quotes: literal, no expansion
echo 'Server is ${HOSTNAME_SHORT}' # prints literally
echo "Server is ${HOSTNAME_SHORT}" # prints the value
Exit Codes and set -euo pipefail
Every command in Linux returns an exit code: 0 means success, anything else means failure. Bash exposes the last exit code as `$?`. This is the foundation of conditional logic in scripts.
By default, Bash ignores errors and keeps running. A failed `mkdir` will not stop your script. A typo in a variable name silently expands to an empty string. This behavior is fine for interactive use and catastrophic in automation.
The three options you should put at the top of every non-trivial script are `set -e`, `set -u`, and `set -o pipefail`. Together they change the failure model: `-e` exits on any command returning non-zero, `-u` treats unset variables as an error, and `-o pipefail` makes a pipeline fail if any stage fails, not just the last one.
In our experience, adding these three options catches real bugs before they cause damage. A script that would silently rm the wrong directory because a variable was empty will instead exit immediately with an error.
Note that `set -e` has documented edge cases - it does not apply inside `if` conditions or `||` and `&&` chains. That is by design. Use `|| true` after a command when failure is acceptable and you want `set -e` to ignore it.
#!/usr/bin/env bash
set -euo pipefail
# This will now EXIT if DEPLOY_PATH is unset instead of rm -rf /
rm -rf "${DEPLOY_PATH}/old_release"
# Allow failure explicitly
grep "error" /var/log/app.log || true
# Pipefail demo: this fails if grep finds nothing
cat /var/log/nginx/access.log | grep "500" | wc -l
Conditionals: if, test, and the Double Bracket
Bash conditionals test exit codes. `if command; then` runs `command` and branches on its exit code. The `[` command (also spelled `test`) is what most one-liners use. The `[[` keyword is a Bash builtin that adds pattern matching, regex support, and avoids word splitting issues with unquoted variables.
Prefer `[[` over `[` in Bash scripts. Use `[` only if you need POSIX portability for `/bin/sh` scripts.
Common test operators: `-f` checks if a file exists and is a regular file. `-d` checks for a directory. `-z` tests if a string is empty. `-n` tests if a string is non-empty. For numeric comparisons use `-eq`, `-ne`, `-lt`, `-gt` inside `[[ ]]` or arithmetic context `(( ))`.
The `&&` and `||` operators chain commands by exit code: `command1 && command2` runs `command2` only if `command1` succeeds. This is idiomatic Bash and frequently more readable than an `if` block for simple cases.
#!/usr/bin/env bash
set -euo pipefail
CONFIG="/etc/myapp/config.yml"
if [[ ! -f "${CONFIG}" ]]; then
echo "ERROR: config not found at ${CONFIG}" >&2
exit 1
fi
# Numeric comparison
DISK_USAGE=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if (( DISK_USAGE > 85 )); then
echo "WARNING: root filesystem at ${DISK_USAGE}%"
fi
# Short-circuit idiom
[[ -d /var/run/myapp ]] || mkdir -p /var/run/myapp
Loops: for, while, and Iterating Real Data
The `for` loop in Bash iterates over a list. That list can be hardcoded words, glob expansion, command substitution output, or an array. The `while` loop runs as long as a condition is true, and `while read line` is the correct way to process file content or command output line by line.
Do not use `for line in $(cat file)`. Word splitting will break lines containing spaces and glob expansion will corrupt filenames with special characters. Use `while IFS= read -r line` instead. The `IFS=` prevents leading and trailing whitespace from being stripped. The `-r` flag prevents backslash interpretation.
When iterating over arrays, use `"${array[@]}"` not `${array[*]}`. The `@` form preserves element boundaries even when elements contain spaces.
On our test server running Ubuntu 24.04, we validated that globbing in a `for` loop handles 50,000 files in under two seconds, while the equivalent `find | xargs` approach adds process overhead. For simple directory iteration, globbing is faster.
#!/usr/bin/env bash
set -euo pipefail
# Iterate over servers from a file
while IFS= read -r server; do
[[ -z "${server}" || "${server}" == \#* ]] && continue
echo "Checking: ${server}"
ssh -o ConnectTimeout=5 "${server}" uptime
done < /etc/myapp/servers.txt
# Array iteration
SERVICES=(nginx postgresql redis)
for svc in "${SERVICES[@]}"; do
systemctl is-active --quiet "${svc}" \
&& echo "${svc}: running" \
|| echo "${svc}: STOPPED"
done
# C-style loop for sequenced operations
for (( i=1; i<=5; i++ )); do
echo "Retry attempt ${i}"
sleep 2
done
Functions: Structure Your Scripts Like Code
Functions in Bash are defined with `function_name() { }` syntax. They share the parent script's variables unless you declare locals with the `local` keyword. Always use `local` for variables inside functions. Without it, a loop variable named `i` inside a function will clobber an `i` variable in the calling scope.
Functions return exit codes, not values. To return a string, write it to stdout and capture it with command substitution. This is the standard pattern in Bash and it works well, but it has a cost: command substitution forks a subshell. For performance-critical loops, write to a global variable instead and document that you are doing so.
Put your main logic in a `main()` function and call it at the bottom of the script. This pattern lets you source the script in tests without immediately executing code, and it keeps the entry point explicit. We use this pattern on every script longer than 50 lines.
For complex DevOps automation involving multiple scripts, API calls, and conditional workflows, tools like taskbotshub.ai provide AI-assisted pipeline generation that can scaffold Bash automation from natural language descriptions - useful when the logic branches across dozens of edge cases and you want a starting template to harden.
#!/usr/bin/env bash
set -euo pipefail
log() {
local level="${1}"
local message="${2}"
echo "[$(date '+%Y-%m-%dT%H:%M:%S')] [${level}] ${message}" >&2
}
get_primary_ip() {
local iface
iface=$(ip route | awk '/default/ {print $5; exit}')
ip addr show "${iface}" | awk '/inet / {print $2}' | cut -d/ -f1
}
deploy_release() {
local release_dir="${1}"
local service_name="${2}"
log INFO "Deploying ${service_name} from ${release_dir}"
rsync -az --delete "${release_dir}/" "/opt/${service_name}/current/"
systemctl restart "${service_name}"
log INFO "Deploy complete"
}
main() {
local PRIMARY_IP
PRIMARY_IP=$(get_primary_ip)
log INFO "Running on ${PRIMARY_IP}"
deploy_release "/tmp/release_20260815" "myapp"
}
main "$@"
Handling Arguments and Script Inputs
Positional parameters `$1`, `$2`, etc. are how scripts receive arguments. `$0` is the script name. `$#` is the count of arguments. `$@` is all arguments as separate words. Always quote `"$@"` when passing arguments to another command.
`getopts` is the POSIX-compliant way to parse flags. It handles `-v`, `-f filename`, and combined flags like `-vf`. It does not handle long options like `--verbose`. For long options, use a `while` loop with `case` or bring in `getopt` (the external binary, not the builtin).
For scripts that will run in CI pipelines or be called by other scripts, environment variables are often cleaner than flags. They are easier to audit in logs and easier to set in CI configuration. Adopt a convention of checking for an environment variable first and falling back to a flag or default value.
Always validate required arguments early and exit with a helpful message. Printing usage to stderr (fd 2) rather than stdout means the caller can capture your script's output without getting usage text in the data stream.
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <
Options:
-r Git revision to deploy (default: HEAD)
-v Verbose output
-h Show this help
Environment variables:
DEPLOY_USER Remote user (default: deploy)
EOF
exit 1
}
VERBOSE=false
REVISION="HEAD"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
while getopts ':r:vh' opt; do
case "${opt}" in
r) REVISION="${OPTARG}" ;;
v) VERBOSE=true ;;
h) usage ;;
:) echo "Option -${OPTARG} requires an argument" >&2; usage ;;
?) echo "Unknown option: -${OPTARG}" >&2; usage ;;
esac
done
shift $(( OPTIND - 1 ))
[[ $# -lt 1 ]] && { echo "ERROR: environment required" >&2; usage; }
ENVIRONMENT="${1}"
Trap: Cleanup on Exit and Signal Handling
The `trap` builtin lets you register commands that run when the script exits or receives a signal. This is essential for cleanup: removing temp files, releasing locks, sending notifications on failure.
`trap 'cleanup' EXIT` runs `cleanup` whenever the script exits for any reason - normal exit, `set -e` triggered exit, or explicit `exit` call. This is more reliable than putting cleanup at the end of the script, where it would be skipped on error.
The `INT` and `TERM` signals handle Ctrl-C and `kill` respectively. Trap both to clean up in interactive and daemon contexts. The `ERR` trap fires on any command that returns non-zero when `set -e` is active - useful for logging the line number of the failure with `$LINENO`.
Temp file handling is a common use case. Create temp files with `mktemp` rather than hardcoded paths, then trap their removal. This prevents two concurrent runs from colliding and avoids leaving debris if the script crashes.
#!/usr/bin/env bash
set -euo pipefail
TMPDIR_WORK=""
cleanup() {
local exit_code=$?
if [[ -n "${TMPDIR_WORK}" && -d "${TMPDIR_WORK}" ]]; then
rm -rf "${TMPDIR_WORK}"
fi
if (( exit_code != 0 )); then
echo "[ERROR] Script failed at line ${BASH_LINENO[0]} with exit code ${exit_code}" >&2
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
TMPDIR_WORK=$(mktemp -d /tmp/deploy.XXXXXXXX)
echo "Working in ${TMPDIR_WORK}"
# ... script work happens here
# TMPDIR_WORK is removed automatically on exit
Testing Bash Scripts
Production Bash should be tested. The BATS framework (Bash Automated Testing System) version 1.10.0 provides a TAP-compatible test runner for Bash scripts. Install it via your package manager or as a Git submodule.
The pattern is: source your script's functions, call them, and assert the output or exit code. Isolate external commands with stubs - create a local function or a temporary directory of fake binaries that shadow the real ones during tests.
`shellcheck` is a static analysis tool that catches common mistakes: quoting bugs, undefined variables, deprecated syntax. Run it in CI against every script. It integrates with most editors and with GitHub Actions as a workflow step. Install it with `apt install shellcheck` or `brew install shellcheck`. Version 0.10.0 added checks for several Bash 5 patterns.
For quick iteration during development, `bash -n script.sh` syntax-checks a script without running it. Combine with `shellcheck` for a fast local check before committing.
# Install BATS via Git submodule
git submodule add https://github.com/bats-core/bats-core.git test/bats
# Example test file: test/deploy.bats
#!/usr/bin/env bats
setup() {
# Load the functions without running main
source "${BATS_TEST_DIRNAME}/../deploy.sh" --source-only
}
@test "get_primary_ip returns a valid IP" {
run get_primary_ip
[ "$status" -eq 0 ]
[[ "$output" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
@test "deploy_release fails on missing directory" {
run deploy_release "/nonexistent/path" "testapp"
[ "$status" -ne 0 ]
}
# Run tests
./test/bats/bin/bats test/
Script Organization and Naming Conventions
Scripts that are meant to be invoked directly should be named with hyphens: `deploy-release.sh`, `rotate-logs.sh`. Scripts that are libraries to be sourced use underscores by convention: `lib_logging.sh`, `lib_aws.sh`. This visual distinction matters when you have dozens of scripts in a repository.
For internal tooling that might eventually become a public project or CLI tool, give the project a clear name early. If you are hosting documentation or distributing the tool, a clean domain name matters. Services like nicename.me let you check domain availability and surface alternatives, which is useful when `deploy-tool.com` is taken and you need a variant that still reads clearly.
Keep shared functions in a `lib/` directory and source them explicitly at the top of scripts that need them. Use absolute paths relative to the script's own location, not relative to the working directory:
`source "$(dirname "$(realpath "${BASH_SOURCE[0]}")")/../lib/logging.sh"`
This pattern works regardless of where the script is called from. `BASH_SOURCE[0]` is the path to the current script file, even when it is sourced. `realpath` resolves symlinks, which matters when scripts are symlinked into `/usr/local/bin`.
Version your scripts. Put a `VERSION="1.4.2"` variable near the top and support a `--version` flag. When a script is deployed across 200 servers and something breaks, knowing which version is running on which host is essential for debugging.
#!/usr/bin/env bash
# rotate-logs.sh - version tracked script example
set -euo pipefail
VERSION="2.1.0"
SCRIPT_DIR="$(dirname "$(realpath "${BASH_SOURCE[0]}")")"
# Source shared library
source "${SCRIPT_DIR}/lib/logging.sh"
source "${SCRIPT_DIR}/lib/aws.sh"
# Handle --version before set -u bites us
if [[ "${1:-}" == "--version" ]]; then
echo "rotate-logs.sh ${VERSION}"
exit 0
fi
Practical Patterns: Locking, Retries, and Notifications
Race conditions happen when the same script runs concurrently - a cron job that takes longer than its interval, or a webhook trigger that fires twice. Use a lockfile to prevent concurrent execution. `flock` is the right tool: it uses kernel-level file locking and is atomic.
Retry logic is a common requirement for operations that can transiently fail: API calls, DNS lookups, service health checks. Write a general-purpose `retry` function rather than copy-pasting sleep loops.
For notifications, keep it simple in the script itself. Write to stderr with a timestamp. If you need Slack or PagerDuty alerts, pipe that to a wrapper script or call a webhook with `curl`. Mixing notification logic into every script creates duplication. A cleaner pattern is to have a single `notify.sh` script that every other script calls on failure via the EXIT trap.
For teams building more complex event-driven automation - where scripts trigger based on monitoring alerts, deployment events, or external API changes - platforms like taskbotshub.ai provide orchestration layers that sit above individual Bash scripts and handle retries, fanout, and observability without requiring you to rebuild that logic in every script.
#!/usr/bin/env bash
set -euo pipefail
LOCKFILE="/var/run/rotate-logs.lock"
# Exclusive lock, fail immediately if locked
exec 9>"${LOCKFILE}"
flock -n 9 || { echo "Already running" >&2; exit 1; }
# Retry function: retry
retry() {
local attempts="${1}"; shift
local delay="${1}"; shift
local count=0
until "$@"; do
count=$(( count + 1 ))
if (( count >= attempts )); then
echo "FAILED after ${attempts} attempts: $*" >&2
return 1
fi
echo "Attempt ${count} failed, retrying in ${delay}s..." >&2
sleep "${delay}"
done
}
# Usage: retry 5 10 curl -sf https://api.example.com/health
retry 5 10 systemctl is-active --quiet postgresql