Bash scripts often fail silently, leaving you guessing where things went wrong.
Here’s a script that logs its execution to a file.
#!/bin/bash
LOG_FILE="/var/log/my_script.log"
# Redirect stdout and stderr to the log file, and also to the console
exec > >(tee -a "$LOG_FILE") 2>&1
echo "Script started at $(date)"
# --- Your script logic goes here ---
echo "Performing some operation..."
# Example: Simulate a command that might succeed or fail
ls /non_existent_directory || echo "Warning: Directory not found."
echo "Another step in the script."
# Example: A potentially long-running process
sleep 2
echo "Script finished at $(date)"
exit 0
When this script runs, every echo and every command’s output, including errors, is written to both the console and the specified LOG_FILE.
The magic happens with exec > >(tee -a "$LOG_FILE") 2>&1.
exec replaces the current shell process with the command that follows.
> redirects standard output (stdout).
>(tee -a "$LOG_FILE") is process substitution. It creates a named pipe that tee reads from. tee then writes its input to both standard output (which is then redirected to the console by 2>&1) and to the file specified with -a (append mode).
2>&1 redirects standard error (stderr) to the same place as stdout.
This setup ensures that you see everything in real-time on your terminal while also getting a persistent record in your log file.
You control the LOG_FILE variable to specify where logs are stored. You can also add more echo statements throughout your script to mark specific stages or variable values.
The most surprising thing about logging in Bash is how easily it’s overlooked, yet how fundamental it is to debugging. Many assume logging is a complex add-on, but a simple exec and tee command handles the core functionality. The real art is in deciding what to log and when.
Consider this: logging every single command executed can quickly overwhelm your log files, making it hard to find critical information. Instead, focus on logging key milestones, the results of important operations (especially failures), and any user-defined variables that influence the script’s behavior.
For instance, if your script processes a list of files, logging the start and end of processing for each file, along with any errors encountered, is far more useful than logging every cp or mv command.
#!/bin/bash
LOG_FILE="/var/log/file_processor.log"
INPUT_DIR="/data/input"
OUTPUT_DIR="/data/output"
exec > >(tee -a "$LOG_FILE") 2>&1
echo "Starting file processing script at $(date)"
echo "Input directory: $INPUT_DIR"
echo "Output directory: $OUTPUT_DIR"
if [ ! -d "$INPUT_DIR" ]; then
echo "Error: Input directory '$INPUT_DIR' not found."
exit 1
fi
mkdir -p "$OUTPUT_DIR" || { echo "Error: Could not create output directory '$OUTPUT_DIR'."; exit 1; }
find "$INPUT_DIR" -type f | while read -r file; do
echo "Processing file: $file"
# Simulate processing: copy file and log success/failure
if cp "$file" "$OUTPUT_DIR/"; then
echo "Successfully copied $file to $OUTPUT_DIR"
else
echo "Error: Failed to copy $file to $OUTPUT_DIR"
fi
done
echo "File processing finished at $(date)"
exit 0
The find ... | while read -r file; do ... done pattern is common for iterating over files. Inside the loop, we log the file being processed and the result of the cp command. This provides granular insight into the script’s execution.
The most subtle aspect of effective Bash logging isn’t just capturing output, but managing log levels and rotation. For simple scripts, a single log file is fine, but for long-running or critical services, you’ll eventually want to implement mechanisms to archive or delete old log entries to prevent disk space exhaustion. Tools like logrotate are essential for this, but configuring them requires understanding how your script writes to its log files.
The next logical step after implementing basic logging is to consider structured logging formats for easier parsing by log analysis tools.