Overview

A Bash script that works in a terminal fails silently in cron when a variable is empty, a path is relative, or a pipe swallows an exit code. These defaults make scripts predictable. Read general-principles first.

Start every script in strict mode

#!/usr/bin/env bash
set -euo pipefail
  • -e exits when a command returns non-zero.
  • -u exits on an unset variable, which catches $pth for $path.
  • -o pipefail makes a pipeline fail when any stage fails; without it cat missing.txt | grep foo reports the exit code of the last stage only.

set -e is not a complete safety net. It is ignored inside if, while, &&/|| lists, and commands whose status you test, so a failing function called as if my_func does not abort. Check critical steps explicitly with || exit 1 or || { echo "failed" >&2; exit 1; }.

Quote every expansion

"$var" is the default and $var is the exception. Unquoted expansions split on whitespace and glob-expand, which breaks filenames with spaces and turns a filename starting with - into a flag.

rm -- "$tmpfile"

Use "${prefix}_log.txt" when the next character would join the variable name, and -- before path arguments.

Run shellcheck in CI

shellcheck finds bugs strict mode cannot, such as unquoted expansions and misused [ ]. Fail the build on warnings, and silence a false positive per line with a reason:

# shellcheck disable=SC2086  # flag list must word-split
ssh "$host" $flags uptime
- run: shellcheck --severity=warning scripts/*.sh

Use [[ ]] in Bash, [ ] only for POSIX sh

[[ ]] does not word-split, supports glob ([[ $a == abc* ]]) and regex ([[ $a =~ ^[0-9]+$ ]]) matching, and allows && and || inside. Use [ ] only when the shebang is #!/bin/sh and POSIX compatibility is a real requirement (Alpine BusyBox, system rc scripts).

Clean up with one EXIT trap

An EXIT trap runs on success, failure, and most signals. A second trap ... EXIT replaces the first, so keep one handler.

tmpdir="$(mktemp -d)"
cleanup() { rm -rf -- "$tmpdir"; }
trap cleanup EXIT

Know your Bash version before using Bash 4 features

Arrays, [[ ]], and ${var/foo/bar} are safe on any Bash 3.2 or newer. mapfile, associative arrays, and ${var,,} need Bash 4. macOS has shipped Bash 3.2 as /bin/bash through macOS 26 (check /bin/bash --version on newer releases), so a script using those features can fail on a stock Mac; #!/usr/bin/env bash finds a newer Bash only if one (for example from Homebrew) is earlier on PATH. Avoid the Bash 4 features in scripts that must run there. When a script must run across operating systems or grows past one purpose, write it in Python or Go.

Use absolute paths and an explicit environment in cron, systemd, and launchd

Scheduled jobs do not inherit your interactive PATH, working directory, or environment. Set them, and log to a known file.

#!/usr/bin/env bash
set -euo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin
cd "$(dirname "${BASH_SOURCE[0]}")"
/usr/local/bin/myapp run >> /var/log/myapp/job.log 2>&1

Cron mails any unhandled output, so redirect stdout and stderr deliberately. Cron expression syntax is in cron-syntax; shared scripts across machines are covered in dotfiles, and common idioms in bash-one-liners.