Most shell scripts start as three quick lines to save some typing, then quietly become the backbone of a deployment pipeline or cron job nobody wants to touch.…
Most shell scripts start as three quick lines to save some typing, then quietly become the backbone of a deployment pipeline or cron job nobody wants to touch. The problem is that Bash is forgiving by default — it happily continues after a failed command, silently treats unset variables as empty strings, and lets a bad cd send rm -rf somewhere catastrophic. This guide covers the practical habits that turn a "works on my machine" script into one you can trust unattended, at 3 AM, in production.
Prerequisites / What You'll Need
- A Linux or macOS system with Bash (version 4+ recommended)
- Basic familiarity with writing and running shell scripts
- A text editor
shellcheck installed (we'll get to why)
Step 1: Always Start with a Strict Mode Header
The single highest-leverage habit in shell scripting is adding this to the top of every script:
#!/usr/bin/env bash
set -euo pipefail
Here's what each flag actually does:
set -e — exit immediately if any command fails, instead of barreling ahead with a broken state
set -u — treat referencing an undefined variable as an error, instead of silently substituting an empty string
set -o pipefail — make a pipeline (cmd1 | cmd2) fail if any command in it fails, not just the last one
Without these three lines, a script can fail halfway through, keep running, and leave your system in a half-finished state without you ever knowing something went wrong.
#!/usr/bin/env bash
set -euo pipefail
echo "Starting deploy..."
cd /var/www/myapp
git pull
npm install
pm2 restart myapp
echo "Deploy complete."
If git pull fails here (network issue, merge conflict), the script stops immediately instead of restarting your app with stale or half-updated code.
Step 2: Quote Your Variables. Always.
Unquoted variables are a classic source of bugs, especially with filenames containing spaces or unexpected characters:
# Dangerous
rm -rf $BACKUP_DIR/*
# Safe
rm -rf "${BACKUP_DIR:?}/"*
Two things happening in the safe version:
- Quoting prevents word-splitting if
$BACKUP_DIR contains a space
${BACKUP_DIR:?} fails loudly with an error if BACKUP_DIR is unset or empty — critical protection against accidentally running rm -rf /* because a variable was never assigned
As a rule: quote every variable expansion unless you have a specific reason not to (like intentional word-splitting, which is rare).
Step 3: Validate Inputs and Preconditions Early
Don't let a script get halfway through a task before discovering it's missing something it needs. Fail fast, at the top:
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then
echo "Usage: $0 <environment>" >&2
exit 1
fi
ENVIRONMENT="$1"
if [ ! -f ".env.${ENVIRONMENT}" ]; then
echo "Error: .env.${ENVIRONMENT} not found" >&2
exit 1
fi
command -v docker >/dev/null 2>&1 || {
echo "Error: docker is not installed" >&2
exit 1
}
This pattern — check, fail with a clear message, exit — costs a few lines but saves you from debugging a script that failed obscurely on line 40 because of something that was wrong from the very start.
Step 4: Use trap for Cleanup
Scripts that create temporary files or lock files need to clean up after themselves — including when they fail or get interrupted:
#!/usr/bin/env bash
set -euo pipefail
TMPDIR=$(mktemp -d)
cleanup() {
rm -rf "$TMPDIR"
}
trap cleanup EXIT
# ... use $TMPDIR for scratch work ...
echo "Working in $TMPDIR"
trap cleanup EXIT guarantees the cleanup function runs no matter how the script exits — success, failure, or a Ctrl+C interrupt. This is far more reliable than manually adding rm -rf "$TMPDIR" at the end, which never runs if something earlier in the script fails.
Step 5: Log Meaningfully, Not Excessively
A script that runs unattended (via cron, CI, or a deploy pipeline) needs to leave a trail you can actually debug from:
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
log "Starting backup"
pg_dump mydb > backup.sql
log "Backup complete: $(du -h backup.sql | cut -f1)"
Pair this with output redirection when scheduling via cron:
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
The 2>&1 matters — without it, errors go nowhere and you'll only ever see successful runs in your log, which is worse than no log at all.
Step 6: Lint with ShellCheck
shellcheck catches an enormous range of subtle bugs — unquoted variables, unreachable code, incorrect test operators — before they bite you in production:
sudo apt install shellcheck # or: brew install shellcheck
shellcheck myscript.sh
Run it on every script before deploying it. It's genuinely one of the highest-value five-minute habits in shell scripting — most experienced sysadmins still get caught off guard by what it flags.
Step 7: Prefer [[ ]] Over [ ] for Conditionals
Bash's [[ ]] is safer and more capable than the POSIX [ ] test command:
# Fragile — breaks on unquoted empty variables
if [ $STATUS = "running" ]; then
# Safer, and supports pattern matching / regex
if [[ $STATUS == "running" ]]; then
[[ ]] doesn't word-split or perform pathname expansion on unquoted variables inside it, which removes a whole class of bugs that [ ] is prone to.
Common Pitfalls / Troubleshooting
- Assuming
set -e catches everything. It doesn't apply inside conditionals (if some_command; then) or to commands in a pipeline other than the last one — that's exactly why pipefail is a separate flag you need alongside it.
- Forgetting
$0 isn't always the script name you expect. If a script is sourced rather than executed, $0 may reflect the parent shell. Use it for usage messages, not for logic.
- Silent failures in cron jobs. A script that works perfectly when run manually can fail under cron because of a different (much smaller)
PATH. Always use absolute paths to binaries in cron-run scripts, or explicitly set PATH at the top.
- Testing destructive commands directly. Before running anything with
rm, dd, or similar, echo the command or run with a --dry-run/-n equivalent first to confirm exactly what it will do.
- Not testing failure paths. It's easy to test that a script works when everything goes right. Deliberately break something — a missing file, a bad variable — and confirm the script fails safely and says why.
Wrapping Up
None of these habits take long to adopt, but together they're the difference between a script you can hand off or forget about, and one that quietly breaks things the first time an assumption doesn't hold. Strict mode, quoting, input validation, and shellcheck cover the vast majority of real-world shell scripting bugs — build them in from the first line, not as an afterthought once something's already gone wrong.
From here, worthwhile next steps include wrapping recurring scripts in proper logging and alerting (a failed cron job should notify you, not fail silently), and considering a tool like shfmt to keep formatting consistent across a growing collection of scripts.
Further Reading