Bash Scripting Cheatsheet
What it is
Bash is the shell that runs almost every deploy hook, CI step, and post-release smoke test on Linux. For deployment workflows it's the lowest common denominator — it's already installed on the build runner, on the production host, and inside most container images — so the scripts that wire git, rsync, curl, ssh, and docker together are nearly always Bash.
This sheet covers the syntax and idioms you reach for when writing deploy scripts, build-pipeline steps, and post-deploy hooks — plus the safety patterns that turn a brittle one-liner into something you can trust to run unattended on production.
Quick reference
Shebang, options, and script preamble
#!/usr/bin/env bash
set -euo pipefail # fail fast — see Deployment workflows
IFS=$'\n\t' # safer word-splitting
shopt -s nullglob # empty globs expand to nothing, not literal *
Run with bash -n script.sh to syntax-check without executing. Lint with shellcheck — rules SC2086 (unquoted vars), SC2046 (unquoted command substitution), and SC2155 (declare-and-assign masking exit code) catch most real bugs.
Variables and quoting
NAME="release" # no spaces around =
readonly DEPLOY_DIR="/var/www/${NAME}" # constants
declare -i COUNT=0 # integer
declare -a HOSTS=(web-1 web-2 web-3) # array
declare -A ENV=([staging]=staging.example.com [prod]=example.com) # assoc array (Bash 4+)
echo "$NAME" # double-quote to preserve spaces/empties
echo '${NAME}' # single-quote = literal, no expansion
echo "${NAME:-default}" # default if unset/empty
echo "${REQUIRED:?missing REQUIRED}" # exit 1 with message if unset
Command substitution and arithmetic
SHA="$(git rev-parse --short HEAD)" # prefer $(...) over backticks
NOW=$(date -u +%Y%m%dT%H%M%SZ)
COUNT=$(( COUNT + 1 )) # arithmetic
(( COUNT > 0 )) && echo "non-zero" # arithmetic test, no $
Conditionals
if [[ "$ENV" == "production" ]]; then
echo "shipping to prod"
elif [[ "$ENV" =~ ^stag(ing|e)$ ]]; then # regex match
echo "staging"
else
echo "unknown: $ENV" >&2
exit 1
fi
[[ -f "$path" ]] # regular file exists
[[ -d "$path" ]] # directory exists
[[ -L "$path" ]] # symlink exists
[[ -r "$path" && -w "$path" ]] # readable AND writable
[[ -z "$VAR" ]] # empty / unset
[[ -n "$VAR" ]] # non-empty
[[ "$a" -eq "$b" ]] # numeric equal
[[ "$a" == "$b" ]] # string equal
Use [[ ... ]] (Bash builtin) for new scripts. The POSIX [ ... ] is fine but more error-prone with unquoted variables.
Loops
for host in web-1 web-2 web-3; do
ssh "$host" 'systemctl reload nginx'
done
for f in releases/*.tar.gz; do # glob loop — needs `shopt -s nullglob`
echo "found: $f"
done
while IFS= read -r line; do # line-by-line, preserves leading whitespace
echo "got: $line"
done < hosts.txt
until curl -fsS http://localhost:8080/health -o /dev/null; do
sleep 2
done
Functions
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }
deploy_host() {
local host="$1" # always declare local
local release="$2"
ssh "$host" "ln -sfn /var/www/releases/$release /var/www/current" \
|| die "symlink swap failed on $host"
}
deploy_host web-1 "release-$SHA"
local is mandatory inside functions. Without it, every variable is global and your loop counter clobbers something three calls deep.
Parameter expansion
${var} # value
${var:-fallback} # value or fallback
${var:=fallback} # assign fallback if unset
${var:?error} # exit with error if unset
${var:+alt} # alt if SET, empty otherwise
${#var} # length
${var#prefix} # strip shortest prefix
${var##prefix} # strip longest prefix
${var%suffix} # strip shortest suffix
${var%%suffix} # strip longest suffix
${var/old/new} # replace first
${var//old/new} # replace all
${var^^} # uppercase
${var,,} # lowercase
${var:2:5} # substring (offset 2, length 5)
# Practical: derive release name from git ref
REF="refs/tags/v1.4.2"
TAG="${REF##*/}" # → v1.4.2
VERSION="${TAG#v}" # → 1.4.2
Arrays
HOSTS=(web-1 web-2 web-3)
echo "${HOSTS[0]}" # first element
echo "${HOSTS[@]}" # all elements (word-split-safe in quotes)
echo "${#HOSTS[@]}" # length
HOSTS+=(web-4) # append
for h in "${HOSTS[@]}"; do # ALWAYS quote @ for iteration
ssh "$h" uptime
done
# Associative
declare -A ROLE=([web-1]=app [web-2]=app [db-1]=db)
echo "${ROLE[web-1]}" # → app
echo "${!ROLE[@]}" # all keys
Redirection and pipes
cmd > out.log # stdout → file (truncate)
cmd >> out.log # stdout → file (append)
cmd 2> err.log # stderr → file
cmd > all.log 2>&1 # stdout + stderr → same file (this order matters)
cmd &> all.log # Bash shorthand for the same
cmd < input.txt # stdin from file
cmd <<< "literal" # here-string
cmd <<EOF # here-doc
multi-line
input
EOF
cmd1 | cmd2 # pipe
cmd1 |& cmd2 # pipe stdout AND stderr
cmd1 | tee out.log | cmd2 # branch the stream
exec > >(tee -a deploy.log) 2>&1 # tee everything from here on
Process substitution
diff <(ssh web-1 cat /etc/nginx/nginx.conf) <(ssh web-2 cat /etc/nginx/nginx.conf)
comm -23 <(sort current.txt) <(sort previous.txt)
Signals and traps
trap 'echo "cleaning up"; rm -f "$LOCK"' EXIT # always runs on exit (any reason)
trap 'echo "interrupted" >&2; exit 130' INT TERM
trap 'echo "error at line $LINENO: $BASH_COMMAND" >&2' ERR
EXIT traps fire for normal exit, errors, and signals — the right place to delete tempfiles, release locks, and flush logs.
Deployment workflows (the moat)
1. The deploy-script preamble that prevents silent failures
Every deploy script you write should start with this block. Without it, a typo in a variable name or a failed curl mid-pipeline will pass through unnoticed and ship a half-broken release.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# -e exit on any error
# -u exit on undefined variable (catches typos)
# -o pipefail pipeline fails if ANY step fails (default = only last step)
# IFS word-splits only on newlines/tabs (filenames with spaces survive)
What each one actually buys you in production:
- Without
-e,mkdir /var/www/releases/$SHAfailing leaves youcd-ing into the previous release and overwriting it. - Without
-u, a typo like$RELASE_DIR(missingE) silently expands to empty string.rm -rf "${RELASE_DIR}/old"then becomesrm -rf /old. - Without
pipefail,tar czf - releases/ | ssh prod "tar xzf - -C /tmp"exits 0 even when the localtarsegfaults — the pipeline value is the exit code ofssh, which succeeded.
Add set -x temporarily when debugging a hook: every line is echoed with + before execution. Remove it before merge — it leaks secrets to CI logs.
2. Lockfiles to prevent overlapping deploys
Two CI jobs racing each other onto the same server is the classic cause of "deploy worked locally, broke in production." A flock-based lockfile serialises them:
#!/usr/bin/env bash
set -euo pipefail
LOCK="/var/lock/deploy-myapp.lock"
exec 200>"$LOCK"
flock -n 200 || { echo "another deploy is in flight" >&2; exit 1; }
# from here on, only one process holds the lock
trap 'flock -u 200; rm -f "$LOCK"' EXIT
deploy_release "$@"
Key points:
flock -nis non-blocking — fails immediately if the lock is held, so a second deploy gets a clear "wait your turn" rather than hanging the CI runner.flock -w 300waits up to 5 minutes if you'd rather queue.- The file descriptor
200is arbitrary — anything ≥10 avoids stepping on stdin/stdout/stderr. - This pattern works inside a DeployHQ SSH deploy hook — run it as the first line of your post-deploy command and concurrent deploys serialise correctly.
For multi-host fleets where the lock has to live somewhere central, push it to a small Redis instance or use consul-lock; the script shape stays the same.
3. Idempotent file ops on remote hosts
A deploy hook that runs twice should leave the host in the same state as a hook that runs once. The most common failures come from non-idempotent operations:
# WRONG — fails on second run because the symlink already exists
ln -s /var/www/releases/$SHA /var/www/current
# RIGHT — atomic, idempotent symlink swap
ln -sfn "/var/www/releases/$SHA" "/var/www/current"
# WRONG — fails if directory exists
mkdir /var/www/releases/$SHA
# RIGHT
mkdir -p "/var/www/releases/$SHA"
# WRONG — duplicates the line on every run
echo "deploy_user ALL=(ALL) NOPASSWD: /bin/systemctl" >> /etc/sudoers.d/deploy
# RIGHT — grep-then-append guard
grep -qxF 'deploy_user ALL=(ALL) NOPASSWD: /bin/systemctl' /etc/sudoers.d/deploy \
|| echo 'deploy_user ALL=(ALL) NOPASSWD: /bin/systemctl' >> /etc/sudoers.d/deploy
# WRONG — leaves a half-written file if the deploy aborts mid-write
curl -fsS "$CONFIG_URL" > /etc/app/config.yml
# RIGHT — atomic via mv (same filesystem)
TMP=$(mktemp -p /etc/app .config.yml.XXXXXX)
trap 'rm -f "$TMP"' EXIT
curl -fsS "$CONFIG_URL" > "$TMP"
mv "$TMP" /etc/app/config.yml
The atomic-rename pattern matters because mv within the same filesystem is a single kernel rename(2) syscall — readers either see the old file or the new file, never a truncated one. This is exactly the mechanism behind DeployHQ's atomic deployments: each release lands in its own directory, then the current symlink swaps with ln -sfn — one filesystem operation, zero half-deployed state.
4. Exit-code conventions for CI gates
CI providers and DeployHQ build hooks read your script's exit code as the deploy verdict. Random exit codes make pipelines fail-open or fail-confusingly. Conventions that hold up:
| Code | Meaning | Example |
|---|---|---|
| 0 | Success | Deploy completed, smoke test passed |
| 1 | Generic failure (don't retry) | Validation error, missing config |
| 2 | Misuse of command (don't retry) | Bad CLI flag, parse error |
| 64–78 | sysexits.h semantic codes |
64 = usage error, 73 = can't create file |
| 75 | Temporary failure — retry safe | Network blip, registry timeout |
| 130 | Killed by SIGINT | Ctrl-C |
| 143 | Killed by SIGTERM | CI runner kill, timeout |
The pattern in deploy scripts:
deploy() {
pre_flight_checks || exit 64 # bad config — fix and re-run
fetch_release || exit 75 # transient — CI can retry
swap_symlink || exit 1 # state corruption — alert humans
smoke_test || { rollback; exit 1; }
}
deploy "$@"
Then in your CI config (or DeployHQ pipeline step) configure retries only on exit 75. Build a Bash script that exits with 75 for "registry returned 502" and 1 for "the new release crashed on boot", and your pipeline retries the right things and stops fast on the wrong ones.
5. Safe temp files and cleanup with trap
Deploy scripts that leave detritus in /tmp are the gift that keeps giving — disk fills, the next deploy fails on No space left on device, and you spend an hour finding what wrote the 4GB file. Pattern:
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d -t deploy-XXXXXX)
trap 'rm -rf "$TMPDIR"' EXIT
# now every artifact goes under $TMPDIR
curl -fsS "$URL" -o "$TMPDIR/release.tar.gz"
tar -xzf "$TMPDIR/release.tar.gz" -C "$TMPDIR/unpacked"
# the trap fires no matter how we exit (success, error, Ctrl-C, kill)
mktemp -d is non-racy and creates a directory only readable by the current user (0700). Don't roll your own /tmp/deploy-$$ — the PID is predictable and an attacker on a shared host can pre-create the directory with looser permissions.
6. Configurable scripts via env + flags
Scripts that hard-code hosts or paths don't survive their first migration. The pragmatic pattern is: env vars for secrets and toggles, flags for per-run choices.
#!/usr/bin/env bash
set -euo pipefail
: "${DEPLOY_USER:=deploy}" # default if unset
: "${DEPLOY_HOST:?DEPLOY_HOST is required}" # die if unset
DRY_RUN=0
ENVIRONMENT="staging"
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
-e|--env) ENVIRONMENT="$2"; shift 2 ;;
-h|--help) grep '^#/' "$0" | cut -c4-; exit 0 ;;
*) echo "unknown arg: $1" >&2; exit 64 ;;
esac
done
if (( DRY_RUN )); then
echo "would deploy to ${DEPLOY_USER}@${DEPLOY_HOST} (${ENVIRONMENT})"
exit 0
fi
The #/ ... comment trick gives you a free --help flag — write the usage doc as comments starting #/ at the top of the script, and the grep line prints them on demand.
Common errors and fixes
| Error / symptom | Cause | Fix |
|---|---|---|
unbound variable after adding set -u |
Referencing a var that's never set | Use ${VAR:-} to default to empty, or ${VAR:?missing VAR} to die loudly |
command not found only in CI |
PATH differs between login and non-login shells; CI is non-login |
Set PATH explicitly at the top of the script, or use full paths (/usr/bin/curl) |
Pipeline "succeeds" but tar crashed |
Default Bash returns only the last command's exit code | Add set -o pipefail (or just use the full set -euo pipefail preamble) |
[: too many arguments |
$var expanded to multiple words inside [ ] |
Quote it: [[ -n "$var" ]] — and prefer [[ over [ |
| Loop body sees only the last record | Word-splitting on a default IFS |
Set IFS=$'\n\t' near the top, and use while IFS= read -r line for line loops |
for f in *.tar.gz runs once with literal *.tar.gz |
No matching files; default glob expands to itself | shopt -s nullglob so empty globs expand to nothing |
| Heredoc loses variable expansion | <<'EOF' (quoted) disables expansion |
Use unquoted <<EOF for expansion; quoted <<'EOF' for literal payloads (scripts, JSON) |
Permission denied running the script |
Missing exec bit | chmod +x deploy.sh — and ensure your VCS tracks the mode (git update-index --chmod=+x deploy.sh) |
$? is 0 after a function I expected to fail |
Function ran a fallible command, then a successful one; $? is only the last |
Capture early: if ! my_func; then ...; fi instead of running and checking later |
| Cron job runs locally, fails in cron | Cron's PATH is minimal; no interactive shell init |
Set PATH= and SHELL= in the crontab, or call /usr/bin/env bash -lc 'script.sh' |
bad substitution in ${var,,} |
Script ran under sh or dash, not Bash |
Shebang must be #!/usr/bin/env bash and the file must be invoked as bash script.sh, not sh script.sh |
shellcheck flags SC2155: declare and assign separately |
local x=$(cmd) masks the exit code of cmd |
Split into two lines: local x; x=$(cmd) so set -e sees the failure |
Companion: full DeployHQ deploy workflow
Bash is the language every other deploy tool speaks. Your pre-build hooks, post-deploy SSH commands, and CI-side webhook triggers all run inside a Bash process — which means the set -euo pipefail and lockfile patterns above are what stand between a working pipeline and a flaky one.
The end-to-end pattern — Git push → CI build → DeployHQ webhook → SSH deploy hooks → smoke test — is documented in the agent-driven CI/CD walkthrough. To wire a Bash deploy script into a real pipeline, the deploy from GitHub guide shows where each hook lives, and DeployHQ's one-click rollback takes over when one of those scripts exits non-zero.
Start a free DeployHQ trial to drop these scripts into a working pipeline in minutes.
Related cheatsheets
- SSH cheatsheet — for the transport layer your Bash deploy hooks run over.
- curl cheatsheet — for the webhook triggers and smoke tests inside your scripts.
- Docker cheatsheet — for the container builds and
docker composecalls Bash orchestrates. - rsync cheatsheet — for the file-sync step most Bash deploy scripts wrap.
- Cron and crontab cheatsheet — for scheduling the Bash scripts you just wrote.
- Cheatsheets hub — every DeployHQ cheatsheet in one place.
Need help? Email support@deployhq.com or follow @deployhq on X.