Debugging Bash Scripts with set -x and trap: A Senior Dev's Go-To Technique
I’ve lost count of how many times I’ve stared at a failing Bash script in production, wishing I could see exactly what it was doing at the moment things went sideways. Echo statements help, but they’re noisy and brittle. Over the years, I’ve settled on a lightweight, non-intrusive debugging pattern using set -x and trap that gives me real-time visibility without cluttering the script or requiring a full rewrite.
The scenario is familiar: a deployment script runs via cron, copies files, adjusts permissions, and restarts a service. One morning, it fails silently — no error in the logs, just a service that didn’t come back up. Adding echo statements everywhere feels like overkill, and removing them later is tedious. Instead, I now use a conditional debug toggle that activates tracing only when needed.
Here’s how it works in practice:
#!/usr/bin/env bash
# Enable debugging only if DEBUG is set (e.g., DEBUG=1 ./deploy.sh)
if [[ -n "$DEBUG" ]]; then
set -o xtrace # Same as set -x: prints each command before execution
set -o verbose # Prints shell input lines as they’re read
fi
# Optional: restore normal behavior on exit or error using trap
cleanup() {
if [[ -n "$DEBUG" ]]; then
set +o xtrace
set +o verbose
echo "[DEBUG] Tracing disabled." >&2
fi
}
trap cleanup EXIT
# --- Main script logic ---
echo "Starting deployment..."
src_dir="/var/www/app/current"
dest_dir="/var/www/app/releases/$(date +%s)"
mkdir -p "$dest_dir" || {
echo "Failed to create release directory" >&2
exit 1
}
cp -r "$src_dir"/* "$dest_dir/"
chown -R www-data:www-data "$dest_dir"
ln -sfn "$dest_dir" "/var/www/app/current"
systemctl reload nginx || {
echo "nginx reload failed" >&2
exit 1
}
echo "Deployment completed successfully."
The real power here isn’t just in turning on tracing — it’s in making it controllable and clean. By wrapping set -x in a conditional, I leave the debugging capability in the script permanently, but inactive by default. When something goes wrong, I simply rerun with DEBUG=1 ./deploy.sh and get a detailed execution trace:
+ mkdir -p /var/www/app/releases/1717000000 + cp -r /var/www/app/current/* /var/www/app/releases/1717000000/ + chown -R www-data:www-data /var/www/app/releases/1717000000/ + ln -sfn /var/www/app/releases/1717000000 /var/www/app/current + systemctl reload nginx
This shows every command as it’s executed, with expanded variables and globs — invaluable when figuring out why a path didn’t expand correctly or a command failed due to a missing file.
I also like adding set -o verbose alongside xtrace because it shows the actual script lines as they’re read, which helps when debugging complex conditionals or loops where you want to see the source, not just the expanded result.
The trap ensures tracing is cleanly turned off on exit, preventing any leakage if the script is sourced or called from another context. It’s a small detail, but in shared environments or reusable utility scripts, it prevents confusion.
Why this over scattered echo statements? Three reasons:
- Zero maintenance: No need to add or remove debug lines. The tracing is always there, off by default.
- Complete visibility: You see everything — variable expansions, command substitutions, even subshell entries.
- Context-aware: The output includes line numbers and command structure, making it easier to trace back to the source.
Of course, this isn’t for production logging — the output goes to stderr and can be verbose. But for ad-hoc debugging? It’s unbeatable. I’ve used this pattern in everything from simple backup scripts to complex multi-service rollouts, and it’s saved me hours of guesswork.
One pro tip: if you’re working with set -e (exit on error), remember that set -x doesn’t interfere with it. The trace still runs, but the script will halt on the first failing command — giving you the exact point of failure.
Next time you’re debugging a Bash script that’s behaving strangely, try toggling DEBUG=1 before reaching for another echo. You might find, as I have, that the best debugging tool is often the one built into the shell itself.