Linux Basic — Debug Mode
Debug Mode
set -x is great for the developer; it’s noisy for the end user. The right pattern is to make tracing opt-in via an environment variable or CLI flag. That way, production runs stay clean and any user can ask “run this in debug mode” without editing the script.
Pattern 1 — DEBUG=1 Env Var
#!/usr/bin/env bash
set -euo pipefail
[[ "${DEBUG:-0}" == "1" ]] && set -x
main() {
cp src.txt dst.txt
chmod 600 dst.txt
}
main "$@"
Default run:
$ ./script.sh
# (silent — just the script's normal output)
Debug run:
$ DEBUG=1 ./script.sh
+ main
+ cp src.txt dst.txt
+ chmod 600 dst.txt
Zero cost when DEBUG is unset — the set -x line is just one boolean check.
Pattern 2 — Verbose Logging
Make your own debug helper that prints only when enabled:
debug() {
[[ "${DEBUG:-0}" == "1" ]] || return 0
printf 'DEBUG: %s\n' "$*" >&2
}
debug "Loaded config: $config"
debug "Processing ${#files[@]} files"
Add debug calls liberally — they cost almost nothing when off. Users who hit a problem can re-run with DEBUG=1 and email you the output.
Pattern 3 — Trace Just One Function
trace_fn() {
local fn=$1; shift
set -x
"$fn" "$@"
set +x
}
# Use:
trace_fn deploy_to_staging
Now you can debug one function without flooding the trace with setup noise.
Pattern 4 — CLI -v / --verbose Flag
verbose=0
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose) verbose=1; shift ;;
--) shift; break ;;
-*) die "unknown flag: $1" ;;
*) break ;;
esac
done
debug() { (( verbose )) || return 0; printf 'DEBUG: %s\n' "$*" >&2; }
Now ./script.sh -v args… enables verbose; without it, silent.
Pattern 5 — Log Levels
For more nuanced output:
# log levels: 0=quiet 1=info 2=debug 3=trace
LOG_LEVEL=${LOG_LEVEL:-1}
_log() {
local lvl=$1; shift
(( lvl <= LOG_LEVEL )) || return 0
local name
case "$lvl" in
0) name=QUIET ;;
1) name=INFO ;;
2) name=DEBUG ;;
3) name=TRACE ;;
esac
printf '%-5s %s\n' "$name" "$*" >&2
}
info() { _log 1 "$@"; }
debug() { _log 2 "$@"; }
trace() { _log 3 "$@"; }
Set the level on the command line:
LOG_LEVEL=2 ./script.sh # info + debug
LOG_LEVEL=3 ./script.sh # everything including trace
LOG_LEVEL=0 ./script.sh # silent
Pattern 6 — DRY_RUN Mode
Show what would happen, without actually doing it:
DRY=${DRY_RUN:-0}
run() {
if (( DRY )); then
echo "DRY: $*"
else
"$@"
fi
}
run rm -rf /tmp/staging
run cp -r build/ /var/www/myapp/
Run DRY_RUN=1 ./deploy.sh to preview every command. Combine with DEBUG for “preview + trace”:
DRY_RUN=1 DEBUG=1 ./deploy.sh
Pattern 7 — Step-Through (Interactive Debug)
PAUSE=${PAUSE:-0}
step() {
(( PAUSE )) && read -rp "Continue? [Enter to step, Ctrl-C to abort] "
"$@"
}
step cp src.txt dst.txt
step chmod 600 dst.txt
step rm /tmp/old
Run PAUSE=1 ./script.sh and the shell waits for Enter between each step. Rough but effective for one-off debugging of a destructive script.
Combine Them All
#!/usr/bin/env bash
set -euo pipefail
DEBUG=${DEBUG:-0}
DRY_RUN=${DRY_RUN:-0}
LOG_LEVEL=${LOG_LEVEL:-1}
(( DEBUG )) && set -x
debug() { (( LOG_LEVEL >= 2 )) || return 0; printf 'DEBUG: %s\n' "$*" >&2; }
info() { (( LOG_LEVEL >= 1 )) || return 0; printf 'INFO: %s\n' "$*" >&2; }
run() {
info "+ $*"
if (( DRY_RUN )); then return 0; fi
"$@"
}
main() {
debug "Starting with DEBUG=$DEBUG DRY_RUN=$DRY_RUN LOG_LEVEL=$LOG_LEVEL"
run cp src.txt dst.txt
run chmod 600 dst.txt
info "Done"
}
main "$@"
This four-knob design (DEBUG, DRY_RUN, LOG_LEVEL, PAUSE) covers 95% of real debugging needs:
DEBUG=1— Bash traceLOG_LEVEL=2— your own debug messagesDRY_RUN=1— show without doing- All combinable
Document them in the script’s --help.
Don’t Forget: Logging To a File
For long-running scripts, redirect everything to a file:
exec > >(tee -a /var/log/myapp.log) 2>&1
echo "Started at $(date)"
exec > >(tee … ) re-routes the current shell’s stdout through tee, so everything from this line onward appears on the console and in the log. Combined with strict mode + traps from the last chapter, you have a complete observability story.
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
set -x always on |
toggle via DEBUG=1 |
trace pollutes normal output |
debug prints to stdout |
always >&2 |
mixes with data |
| Hardcoded debug paths | env-var toggles | rebuild needed for every change |
| Trace inside hot loops without throttling | log every N iterations | trace itself slows the script |
| Storing secrets in DEBUG traces | sanitize or redact before logging | secrets leak to debug logs |
Next → Common Scripting Bugs