Bash Scripting Cheatsheet
Last updated 11th May 2026 Reviewed May 2026

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/$SHA failing leaves you cd-ing into the previous release and overwriting it.
  • Without -u, a typo like $RELASE_DIR (missing E) silently expands to empty string. rm -rf "${RELASE_DIR}/old" then becomes rm -rf /old.
  • Without pipefail, tar czf - releases/ | ssh prod "tar xzf - -C /tmp" exits 0 even when the local tar segfaults — the pipeline value is the exit code of ssh, 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 -n is 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 300 waits up to 5 minutes if you'd rather queue.
  • The file descriptor 200 is 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.



Need help? Email support@deployhq.com or follow @deployhq on X.