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-eexits when a command returns non-zero.-uexits on an unset variable, which catches$pthfor$path.-o pipefailmakes a pipeline fail when any stage fails; without itcat missing.txt | grep fooreports 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/*.shUse [[ ]] 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 EXITKnow 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>&1Cron 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.