Linux Basic — Debug Mode

3 minute read

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 trace
  • LOG_LEVEL=2 — your own debug messages
  • DRY_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

Updated: