## 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

```bash
#!/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`](https://www.shellcheck.net/) — rules `SC2086` (unquoted vars), `SC2046` (unquoted command substitution), and `SC2155` (declare-and-assign masking exit code) catch most real bugs.

### Variables and quoting

```bash
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

```bash
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

```bash
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

```bash
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

```bash
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

```bash
${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

```bash
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

```bash
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

```bash
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

```bash
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.

```bash
#!/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:

```bash
#!/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](https://www.deployhq.com/features/build-pipelines) — 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`](https://developer.hashicorp.com/consul/commands/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:

```bash
# 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](https://www.deployhq.com/features/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:

```bash
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:

```bash
#!/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.

```bash
#!/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](https://www.deployhq.com/blog/ai-agents-cicd-pipelines-github-issue-to-production-deploy). To wire a Bash deploy script into a real pipeline, the [deploy from GitHub guide](https://www.deployhq.com/deploy-from-github) shows where each hook lives, and DeployHQ's [one-click rollback](https://www.deployhq.com/features/one-click-rollback) takes over when one of those scripts exits non-zero.

[Start a free DeployHQ trial](https://www.deployhq.com/signup) to drop these scripts into a working pipeline in minutes.

---

## Related cheatsheets

- [SSH cheatsheet](https://www.deployhq.com/cheatsheets/ssh) — for the transport layer your Bash deploy hooks run over.
- [curl cheatsheet](https://www.deployhq.com/cheatsheets/curl) — for the webhook triggers and smoke tests inside your scripts.
- [Docker cheatsheet](https://www.deployhq.com/cheatsheets/docker) — for the container builds and `docker compose` calls Bash orchestrates.
- [rsync cheatsheet](https://www.deployhq.com/cheatsheets/rsync) — for the file-sync step most Bash deploy scripts wrap.
- [Cron and crontab cheatsheet](https://www.deployhq.com/cheatsheets/cron) — for scheduling the Bash scripts you just wrote.
- [Cheatsheets hub](https://www.deployhq.com/cheatsheets) — every DeployHQ cheatsheet in one place.

---

Need help? Email [support@deployhq.com](mailto:support@deployhq.com) or follow [@deployhq on X](https://x.com/deployhq).