Shell Scripting: Silent Backup Failure — Exit Codes Matter
Zero-byte backups from missing pipefail? Catch pipeline failures and verify outputs — production trick GFG skips.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓Solid grasp of DevOps fundamentals
- ✓Comfortable with command-line tools
- ✓Basic Linux administration knowledge
- Shell scripting automates repetitive tasks by combining commands into a single executable file
- Always double-quote variables to prevent word splitting on spaces or special chars
- Add
set -euo pipefailto every script — catches failures before they cascade - Use functions with
localvariables to avoid polluting global scope - Biggest production mistake: ignoring exit codes leads to silent corruption and incomplete operations
Shell scripting is the practice of writing sequences of commands for a Unix shell (like Bash, Zsh, or Dash) to automate system administration, deployment, and data processing tasks. At its core, a shell script is a plain-text file containing commands that would otherwise be typed interactively, but with the addition of control flow, variables, and logic.
The fundamental contract between a shell script and its caller is the exit code — a small integer (0 for success, 1–255 for failure) that determines whether subsequent commands run. Ignoring exit codes is the single most common cause of silent failures in production scripts, where a backup command fails but the script continues as if nothing happened, overwriting good data with nothing.
Shell scripting occupies a specific niche: it excels at orchestrating other programs, manipulating files, and running system commands, but it's a poor choice for complex data structures, performance-critical work, or cross-platform GUI applications. Alternatives include Python (for richer logic and libraries), Go (for compiled performance), or Ansible (for declarative infrastructure).
You should reach for shell scripts when you need to glue together Unix tools like find, grep, awk, and rsync in a way that's portable across Linux and macOS systems — but avoid them when you need error handling beyond simple exit code checks, or when your script exceeds ~200 lines without strict discipline.
Real-world shell scripts at companies like Stripe and Netflix handle millions of dollars worth of infrastructure decisions daily, using patterns like set -euo pipefail to fail fast, trap handlers for cleanup, and careful quoting to handle filenames with spaces or newlines. The difference between a junior and senior shell scripter is visible in how they handle edge cases: quoting variables with "$@" instead of $@, checking $? explicitly after critical commands, and using || and && for conditional execution rather than relying on implicit behavior.
When done right, shell scripts are the most efficient way to automate; when done wrong, they silently corrupt data at 3 AM.
Imagine you have a morning routine: wake up, brew coffee, check your phone, pack your bag. You do the exact same steps every single day. Now imagine you could write those steps on a sticky note and hand it to a robot that does them all for you while you sleep. That sticky note is a shell script — a list of commands you'd normally type one by one in a terminal, saved in a file so the computer can run them automatically, repeatedly, and without you sitting there.
Every time a deployment pipeline fires at 2am, a log file gets rotated before it fills a disk, or a server backs itself up without anyone lifting a finger — there's almost certainly a shell script doing the heavy lifting. Shell scripts are the connective tissue of DevOps. They glue together tools that weren't designed to talk to each other, automate the boring-but-critical tasks that keep systems healthy, and turn a ten-step manual process into a single command anyone on the team can run safely.
The problem isn't learning to write a shell script — it's writing one that doesn't blow up three months later when a directory name has a space in it, or silently does the wrong thing because an error wasn't caught. Most tutorials show you the syntax and stop there. That leaves you with scripts that work on your laptop but fail in CI, or scripts that overwrite production data because nobody handled the edge cases.
By the end of this article you'll understand not just how to write shell scripts, but why certain patterns exist, when to use functions vs inline commands, how to make your scripts fail loudly instead of silently, and how to structure a real-world deployment script that a teammate could pick up and trust. The code examples here are taken from patterns used in actual production environments — not toy demos.
Shell Scripting: The Art of Automating with Exit Codes
Shell scripting is the practice of writing sequences of shell commands in a file to automate repetitive tasks. At its core, it's a way to execute a series of commands with control flow — conditionals, loops, and variable substitution — all within the shell's own language. The fundamental mechanic is that every command returns an exit code (0 for success, non-zero for failure), and the script's logic depends on these codes. Without checking exit codes, a script is just a fragile list of commands that may silently fail.
In practice, a shell script is a plain text file with a shebang (#!/bin/bash) that tells the kernel which interpreter to use. Variables are untyped and expanded at runtime; arrays and arithmetic are supported but have quirks. The key properties that matter: commands run in a subshell by default, so variable changes in a pipeline don't persist; set -e can abort on error, but it's not a silver bullet because it doesn't trap all failures (e.g., in conditionals). Exit codes are the only reliable signal of success or failure.
Use shell scripting for glue logic: orchestrating other programs, file manipulation, environment setup, and simple data processing. It's ideal when you need to run a series of tools that already exist — not for heavy computation or complex data structures. In production, shell scripts often run as cron jobs, deployment hooks, or CI steps. A single unhandled exit code can silently corrupt backups, leave stale locks, or deploy broken code. That's why exit codes matter.
Variables, Quoting, and Why Your Script Breaks on File Names with Spaces
Variables in shell scripts look simple until they aren't. You assign with no spaces around = and read back with $. But the real skill is knowing when to quote them — and the answer is almost always.
When you write $filename unquoted, the shell performs word splitting on it. So if filename holds my report.txt, the shell sees two arguments: my and report.txt. Your script quietly operates on the wrong thing. Wrapping in double quotes — "$filename" — tells the shell to treat the whole value as one unit regardless of spaces.
Single quotes are different: they freeze everything literally. '$HOME' prints the string $HOME, not your home directory. Use double quotes when you want variables expanded, single quotes when you want a completely literal string.
There's also the distinction between local and exported variables. A variable set in your script only exists in that script's process. If you need a child process or a called program to see it, you must export it — or it simply won't be there.
#!/usr/bin/env bash # ALWAYS start with a shebang — it tells the OS which interpreter to use. # Using /usr/bin/env bash is more portable than /bin/bash directly. # --------------------------------------------------------------------------- # BAD PRACTICE: unquoted variable — dangerous when values contain spaces # --------------------------------------------------------------------------- unsafe_filename="quarterly report.txt" # This would break: ls $unsafe_filename (shell sees 'quarterly' and 'report.txt') # --------------------------------------------------------------------------- # GOOD PRACTICE: always double-quote variables when using them # --------------------------------------------------------------------------- safe_filename="quarterly report.txt" echo "Processing file: \"$safe_filename\"" # Output: Processing file: "quarterly report.txt" # --------------------------------------------------------------------------- # READONLY variables — use these for values that must never change # --------------------------------------------------------------------------- readonly MAX_RETRIES=3 readonly LOG_DIR="/var/log/myapp" echo "Will retry up to $MAX_RETRIES times." echo "Logs go to: $LOG_DIR" # --------------------------------------------------------------------------- # EXPORTED variables — child processes (like scripts you call) can see these # --------------------------------------------------------------------------- DATABASE_HOST="db.internal.example.com" export DATABASE_HOST # Now if this script calls another script, that script can read $DATABASE_HOST # --------------------------------------------------------------------------- # DEFAULT VALUES with parameter expansion — saves you from empty-variable bugs # --------------------------------------------------------------------------- # If $DEPLOY_ENV is unset or empty, fall back to 'staging' DEPLOY_ENV="${DEPLOY_ENV:-staging}" echo "Deploying to environment: $DEPLOY_ENV" # --------------------------------------------------------------------------- # ARITHMETIC — use $(( )) for integer math, not expr # --------------------------------------------------------------------------- current_version=4 next_version=$(( current_version + 1 )) echo "Bumping version from $current_version to $next_version"
$variable, ask yourself 'could this ever contain a space or a glob character?' If yes, it must be "$variable". Tools like ShellCheck (shellcheck.net) will catch these automatically — add it to your CI pipeline.Conditionals and Exit Codes: Making Your Script Fail Loudly, Not Silently
Exit codes are the heartbeat of shell scripting. Every command returns a number when it finishes — 0 means success, anything else means something went wrong. The shell stores the last exit code in $?. Most beginners ignore this completely, which leads to scripts that soldier on after a critical failure and leave systems in a broken half-state.
The two most important options you'll ever add to a script are set -e and set -u. set -e (errexit) makes the script stop immediately when any command fails. set -u (nounset) treats any unset variable as an error rather than silently substituting an empty string. Combine them with set -o pipefail and your pipelines stop if any command in the middle fails — not just the last one.
For conditionals, the double-bracket [[ ]] syntax is almost always preferable to the old single-bracket [ ] in bash. It handles edge cases more gracefully, supports regex matching with =~, and doesn't choke on empty variables the same way.
The real power comes when you chain conditionals with your exit codes to build scripts that are self-diagnosing — they tell you exactly what went wrong and clean up after themselves.
#!/usr/bin/env bash # These three lines should be at the top of EVERY non-trivial script. # -e : exit immediately if any command fails # -u : treat unset variables as errors (no silent empty-string substitution) # -o pipefail : if any command in a pipe fails, the whole pipe fails set -euo pipefail # --------------------------------------------------------------------------- # A pre-deployment health check script — realistic DevOps pattern # --------------------------------------------------------------------------- REQUIRED_DISK_SPACE_MB=500 DEPLOY_DIR="/opt/myapp" HEALTH_CHECK_URL="http://localhost:8080/health" # --------------------------------------------------------------------------- # FUNCTION: check available disk space before deploying # --------------------------------------------------------------------------- check_disk_space() { local available_mb # df gives disk usage; awk extracts the available column for our directory available_mb=$(df -m "$DEPLOY_DIR" | awk 'NR==2 {print $4}') if [[ "$available_mb" -lt "$REQUIRED_DISK_SPACE_MB" ]]; then echo "ERROR: Only ${available_mb}MB free in $DEPLOY_DIR. Need at least ${REQUIRED_DISK_SPACE_MB}MB." return 1 # non-zero = failure; caller can act on this fi echo "Disk check passed: ${available_mb}MB available." return 0 } # --------------------------------------------------------------------------- # FUNCTION: verify the app is responding before we call the deploy complete # --------------------------------------------------------------------------- wait_for_healthy() { local max_attempts=10 local attempt=1 local sleep_seconds=3 echo "Waiting for app to become healthy at $HEALTH_CHECK_URL ..." while [[ "$attempt" -le "$max_attempts" ]]; do # curl -sf: -s = silent, -f = fail on HTTP error codes (4xx/5xx) # We redirect output to /dev/null — we only care about the exit code if curl -sf "$HEALTH_CHECK_URL" > /dev/null 2>&1; then echo "App is healthy after $attempt attempt(s)." return 0 fi echo " Attempt $attempt/$max_attempts failed. Retrying in ${sleep_seconds}s..." sleep "$sleep_seconds" (( attempt++ )) done echo "ERROR: App did not become healthy after $max_attempts attempts." return 1 } # --------------------------------------------------------------------------- # MAIN — using && chains: only proceed if each step succeeds # --------------------------------------------------------------------------- echo "=== Pre-deployment checks ===" # if check_disk_space fails, set -e will stop the script here check_disk_space echo "=== Deploying application ===" # Simulate deployment step (replace with your real deploy command) echo " rsync / docker pull / helm upgrade would run here..." echo "=== Post-deployment health check ===" wait_for_healthy echo "=== Deployment complete ==="
pipefail, a command like cat missing_file.txt | grep pattern returns exit code 0 because grep succeeded — even though cat failed. This means your script thinks everything is fine when it isn't. Add set -euo pipefail as lines 3-5 of every script you write. It catches the silent failures that cause 3am incidents.set -euo pipefail is the single most impactful line you can add to any script.Functions, Loops, and Building Scripts You Can Actually Maintain
The moment a script grows past about 30 lines, you need functions. Not because the script stops working without them, but because without them nobody — including you six months later — can figure out what it does. A function is a named chunk of logic you can call by name, test in isolation, and reuse across scripts.
The critical habit with functions is declaring variables inside them as local. Without local, every variable in a function pollutes the global script scope. This causes the maddening bug where a function in one part of your script silently overwrites a variable used somewhere else entirely.
Loops in shell come in three main shapes: for (iterate over a known list), while (keep going until a condition changes), and until (keep going until a condition becomes true). The most practical distinction for real DevOps work is iterating over files vs iterating over command output — and these need different patterns to handle edge cases safely.
The for loop over a glob (for file in /path/*.log) is safer than parsing ls, because it handles spaces in filenames correctly and is built into the shell. Parsing ls output is one of the classic shell script antipatterns.
#!/usr/bin/env bash set -euo pipefail # --------------------------------------------------------------------------- # A realistic log rotation and archiving script # Shows: functions with local vars, for loops, while loops, arrays # --------------------------------------------------------------------------- readonly LOG_SOURCE_DIR="/var/log/myapp" readonly ARCHIVE_DIR="/mnt/archives/myapp" readonly MAX_LOG_AGE_DAYS=30 readonly COMPRESSION_LEVEL=9 # 1=fast/large to 9=slow/small # --------------------------------------------------------------------------- # FUNCTION: compress a single log file and move it to archive # 'local' keeps these variables from leaking into the global scope # --------------------------------------------------------------------------- archive_log_file() { local source_file="$1" # first argument passed to the function local dest_dir="$2" # second argument local filename local archived_path # basename strips the directory path, leaving just the filename filename=$(basename "$source_file") archived_path="${dest_dir}/${filename}.gz" echo " Archiving: $filename" # gzip -${COMPRESSION_LEVEL} compresses; we keep the original with -c and redirect gzip -c -"${COMPRESSION_LEVEL}" "$source_file" > "$archived_path" # Only remove the original AFTER we verified the compressed file exists if [[ -f "$archived_path" ]]; then rm "$source_file" echo " Done: $filename -> $(basename "$archived_path")" else echo "ERROR: Compression failed for $filename. Original NOT deleted." return 1 fi } # --------------------------------------------------------------------------- # FUNCTION: find and archive logs older than MAX_LOG_AGE_DAYS # --------------------------------------------------------------------------- rotate_old_logs() { local log_dir="$1" local archive_dir="$2" local file_count=0 echo "Scanning $log_dir for logs older than $MAX_LOG_AGE_DAYS days..." # We use an array to safely collect results from find # This handles filenames with spaces correctly local old_log_files=() while IFS= read -r -d '' log_file; do old_log_files+=("$log_file") done < <(find "$log_dir" -maxdepth 1 -name "*.log" -mtime +"$MAX_LOG_AGE_DAYS" -print0) # -print0 and read -d '' use null bytes as delimiters — safe for ANY filename if [[ "${#old_log_files[@]}" -eq 0 ]]; then echo "No logs older than $MAX_LOG_AGE_DAYS days found. Nothing to archive." return 0 fi echo "Found ${#old_log_files[@]} file(s) to archive." mkdir -p "$archive_dir" # -p: create parent dirs too, no error if already exists # Iterate over the array — safe even with spaces in filenames for log_file in "${old_log_files[@]}"; do archive_log_file "$log_file" "$archive_dir" (( file_count++ )) done echo "Archived $file_count file(s) successfully." } # --------------------------------------------------------------------------- # FUNCTION: report total size of archived files — shows while loop usage # --------------------------------------------------------------------------- report_archive_size() { local archive_dir="$1" local total_size_kb=0 local file_size # while loop reading line-by-line from du output while IFS= read -r archive_file; do # du -k gives size in kilobytes file_size=$(du -k "$archive_file" | awk '{print $1}') (( total_size_kb += file_size )) done < <(find "$archive_dir" -name "*.gz" -type f) echo "Total archive size: ${total_size_kb}KB" } # --------------------------------------------------------------------------- # MAIN EXECUTION # --------------------------------------------------------------------------- echo "=== Log Rotation Started: $(date '+%Y-%m-%d %H:%M:%S') ===" rotate_old_logs "$LOG_SOURCE_DIR" "$ARCHIVE_DIR" report_archive_size "$ARCHIVE_DIR" echo "=== Log Rotation Complete ==="
ls output?' The answer: ls formats output for humans, not machines. Filenames with spaces, newlines, or special characters get mangled. Use globs (for file in /path/*.log) or find with -print0 + read -d '' instead. This demonstrates you understand shell safety at a production level.ls -t to find old files, but a cron job rotated the file mid-execution.ls output; use find with null-delimited output.local become landmines.ls as a display tool, not a data source.Script Structure, Argument Handling, and the Trap Command for Cleanup
A script that works correctly once isn't enough. It needs to work correctly when someone passes unexpected arguments, when it gets interrupted halfway through, and when the system is under load. These three things separate a 'it works on my machine' script from a production-grade one.
Argument handling with getopts is the built-in way to add named flags to your script (like -e staging -v). It's more robust than just reading $1, $2 directly because it handles combined flags, gives you clear error messages for unknown options, and follows Unix conventions your teammates expect.
The trap command is what makes your scripts trustworthy. It lets you register a function to run when the script exits — whether it finishes normally, hits an error, or gets killed with Ctrl+C. This is how you clean up temp files, release locks, send a failure notification, or restore a backup if something goes wrong mid-deployment. Without trap, interrupted scripts leave messes behind.
Combining good argument handling with a cleanup trap is what turns a one-off script into a tool you'd actually put in a shared repository and hand to a colleague.
#!/usr/bin/env bash set -euo pipefail # --------------------------------------------------------------------------- # A production-pattern database backup script # Demonstrates: getopts, trap for cleanup, structured argument validation # Usage: ./database_backup.sh -e production -d myapp_db -o /backups # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # CLEANUP FUNCTION — registered with trap, runs no matter how script exits # --------------------------------------------------------------------------- cleanup() { local exit_code=$? # capture exit code BEFORE we do anything else if [[ -f "${temp_backup_file:-}" ]]; then echo "Cleaning up temporary file: $temp_backup_file" rm -f "$temp_backup_file" fi if [[ $exit_code -ne 0 ]]; then echo "BACKUP FAILED (exit code: $exit_code). Check logs above." >&2 # In real life: send a Slack/PagerDuty alert here fi } # Register cleanup to run on: normal exit (EXIT), Ctrl+C (INT), kill signal (TERM) trap cleanup EXIT INT TERM # --------------------------------------------------------------------------- # ARGUMENT PARSING with getopts # --------------------------------------------------------------------------- usage() { echo "Usage: $0 -e <environment> -d <database_name> -o <output_dir>" echo " -e Environment (staging|production)" echo " -d Database name to back up" echo " -o Output directory for backup files" exit 1 } # Variables that flags will populate — give them empty defaults for set -u safety environment="" database_name="" output_dir="" # getopts loop: colon after a letter means it expects an argument (e.g. -e staging) while getopts ":e:d:o:" option; do case "$option" in e) environment="$OPTARG" ;; # OPTARG holds the value after the flag d) database_name="$OPTARG" ;; o) output_dir="$OPTARG" ;; :) echo "ERROR: -$OPTARG requires an argument." >&2; usage ;; ?) echo "ERROR: Unknown flag -$OPTARG." >&2; usage ;; esac done # Validate that all required arguments were provided if [[ -z "$environment" || -z "$database_name" || -z "$output_dir" ]]; then echo "ERROR: All flags are required." >&2 usage fi # Validate environment is one of the allowed values if [[ "$environment" != "staging" && "$environment" != "production" ]]; then echo "ERROR: Environment must be 'staging' or 'production'." >&2 exit 1 fi # --------------------------------------------------------------------------- # MAIN BACKUP LOGIC # --------------------------------------------------------------------------- timestamp=$(date '+%Y%m%d_%H%M%S') backup_filename="${database_name}_${environment}_${timestamp}.sql" temp_backup_file="${output_dir}/.tmp_${backup_filename}" # hidden temp file final_backup_file="${output_dir}/${backup_filename}.gz" mkdir -p "$output_dir" echo "=== Database Backup ===" echo " Environment : $environment" echo " Database : $database_name" echo " Output : $final_backup_file" echo "Starting backup at $(date '+%H:%M:%S')..." # Write to a temp file first — if this fails or is interrupted, cleanup() removes it # Replace this with your real pg_dump / mysqldump command: # pg_dump -h "$DB_HOST" -U "$DB_USER" "$database_name" > "$temp_backup_file" echo "-- Simulated SQL dump for $database_name" > "$temp_backup_file" echo "-- Environment: $environment" >> "$temp_backup_file" # Only compress and finalise AFTER the dump succeeded gzip -c "$temp_backup_file" > "$final_backup_file" rm "$temp_backup_file" # Remove temp — it's safe now, we have the compressed final backup_size=$(du -sh "$final_backup_file" | awk '{print $1}') echo "Backup complete. Size: $backup_size" echo "Saved to: $final_backup_file"
.tmp_ file, then move or compress it to the final name once complete. If the script is interrupted mid-write, your cleanup trap removes the partial temp file and you never have a corrupt half-written backup masquerading as a real one. This pattern is used in every serious backup and deployment tool.trap left stale lock files on the server after a timeout killed the process.trap that cleans it up.trap.Working with Command Output: Pipes, Subshells, and Process Substitution
Shell scripts live on piping output from one command to another. But not all pipe patterns are safe. The basic pipe cmd1 | cmd2 passes stdout of cmd1 to stdin of cmd2. That's fine for simple filters, but when you need to capture output into a variable or feed multiple commands from one source, you need more.
Command substitution $(command) captures stdout into a string. It's the modern replacement for backticks ` command — avoid backticks because they're harder to nest and read. However, $(...)` strips trailing newlines, which can surprise you when you're preserving file contents.
Process substitution <(command) feeds the output of a command as if it were a file. This is invaluable for diffing two command outputs: diff <(cmd1) <(cmd2). It avoids writing intermediate temp files.
Reading command output line by line requires care. The safe pattern is while IFS= read -r line; do ... done < <(command). The -r prevents backslash escaping, and IFS= prevents trimming whitespace. This works correctly for all inputs.
Pipes create subshells — variables set inside a pipe are not visible outside unless you use process substitution or adjust shopt -s lastpipe (bash 4.2+). This catches many beginners off guard.
#!/usr/bin/env bash set -euo pipefail # --------------------------------------------------------------------------- # Demonstrates safe patterns for capturing and processing command output # --------------------------------------------------------------------------- readonly LOG_DIR="/var/log/app" readonly PATTERN="ERROR" # --------------------------------------------------------------------------- # SAFE: process substitution with while read (variables survive outside pipe) # --------------------------------------------------------------------------- echo "=== Counting ERROR lines per file using process substitution ===" # This counts occurances for each file matching pattern total_error_count=0 while IFS= read -r file; do count_in_file=$(grep -c "$PATTERN" "$file" 2>/dev/null || echo 0) echo " $file: $count_in_file" (( total_error_count += count_in_file )) done < <(find "$LOG_DIR" -name "*.log" -type f) echo "Total error count across all logs: $total_error_count" # --------------------------------------------------------------------------- # UNSAFE: pipe with while read (variables set inside pipe are lost) # --------------------------------------------------------------------------- echo "" echo "=== Unsafe pipe pattern (variable not visible outside) ===" total_via_pipe=0 find "$LOG_DIR" -name "*.log" -type f | while IFS= read -r file; do count=$(grep -c "$PATTERN" "$file" 2>/dev/null || echo 0) (( total_via_pipe += count )) done echo "Total via pipe (likely 0 due to subshell): $total_via_pipe" # --------------------------------------------------------------------------- # PROCESS SUBSTITUTION for diffing outputs # --------------------------------------------------------------------------- echo "" echo "=== Diffing two command outputs ===" echo "Listing files before and after archiving:" diff <(find "$LOG_DIR" -name "*.log" -type f | sort) \ <(find "$LOG_DIR" -name "*.log" -type f | sort) \ || true # --------------------------------------------------------------------------- # CAPTURING MULTILINE OUTPUT into an array # --------------------------------------------------------------------------- echo "" echo "=== Capturing lines into an array ===" old_log_files=() while IFS= read -r -d '' file; do old_log_files+=("$file") done < <(find "$LOG_DIR" -name "*.log" -mtime +30 -print0) echo "Found ${#old_log_files[@]} old log files."
- Use process substitution
<(cmd)instead of pipes when you need the variable to persist. - The
while readpattern withdone < <(cmd)keeps the loop in the current shell. IFS=preserves leading/trailing whitespace;-rprevents backslash interpretations.- Null-delimited reading (
read -d '') withfind -print0is safe for all filenames.
while ... done < <(cmd) keeps everything in the parent shell.Robust Logging: The Only Debugging You'll Ever Need
Your scripts fail in production. Always. The question is whether you'll know why before the pager wakes you at 3 AM.
Most shell scripts treat logging like an afterthought — a few echo statements scattered like breadcrumbs. That's amateur hour. Production logging means structured output: timestamps, severity levels, correlation IDs. You need to know not just that something failed, but exactly when and where.
Here's the pattern: wrap every important operation in a function that logs to stderr with a timestamp and exit code. Redirect all debug output to a file with log rotation. Never let log noise hide real errors — use different file descriptors for debug vs error output.
The real win? When your deployment pipeline catches a script failure, you grep the logs for 'FATAL' and trace the exact command that broke. No guessing. No replaying the scenario. Your logging IS your debugging.
// io.thecodeforge — devops tutorial #!/bin/bash set -euo pipefail LOG_FILE="/var/log/deployment/processor.log" info() { echo "[INFO] $(date '+%F %T') $*" >> "$LOG_FILE"; } error() { echo "[ERROR] $(date '+%F %T') $*" >&2; } fatal() { echo "[FATAL] $(date '+%F %T') $*" >&2; exit 1; } disk_check() { local usage=$(df /data | tail -1 | awk '{print $5}' | sed 's/%//') if (( usage > 85 )); then fatal "disk usage ${usage}% exceeds threshold" fi info "disk usage at ${usage}% — safe" } disk_check echo "Script completed successfully"
Idempotency: Make Your Script Safe to Run Twice
Production scripts crash mid-execution. Power outages happen. Network drops. Your deployment script that copies files, restarts services, and updates configs needs to be safe to run again — without corrupting anything.
Idempotency means running the same script twice produces the same result as running it once. No duplicate entries. No errors because a file already exists. No services restarted unnecessarily.
The trick: check before you act. Use 'if [ -f ... ]' or 'grep -q' to test state before modifying it. Wrap destructive operations in idempotent patterns. Use 'mkdir -p' instead of 'mkdir'. Use 'cp -n' to avoid overwriting. Test if a user exists before creating them.
Here's what breaks: a script that assumes clean state. When that assumption fails — and it will — your deployment halts mid-step. Then you're running manual fix commands on a server under load.
Idempotent scripts aren't just safer — they're faster. You can rerun them during incident response without fear. And that's the whole point of automation.
// io.thecodeforge — devops tutorial #!/bin/bash set -euo pipefail APP_DIR="/opt/app/v2.1" CONFIG_FILE="${APP_DIR}/app.conf" # idempotent user creation if ! id "appuser" &>/dev/null; then useradd --system --shell /sbin/nologin appuser fi # idempotent directory creation mkdir -p "$APP_DIR" # copy only if target doesn't exist if [ ! -f "$CONFIG_FILE" ]; then cp /tmp/configs/app.conf "$CONFIG_FILE" chown appuser:appuser "$CONFIG_FILE" fi # start service only if not running if ! systemctl is-active --quiet app-service; then systemctl start app-service fi
Fail Securely: Handling Secrets Without Exposing Them
Your script needs an API key. Where do you put it? If your answer involves hardcoding, environment variables in the script, or passing it on the command line — stop. You're one 'ps aux' leak away from a P0 security incident.
Production scripts handle secrets differently. Use a secrets manager — Vault, AWS Secrets Manager, or at minimum a secured file with restricted permissions. Read the secret at runtime, use it, and never echo it. Never log it. Never pass it to a subprocess that someone can spy on with 'ps'.
The pattern: read the secret into a local variable, use it for the one API call, then unset it immediately. Redirect your curl command's output carefully — stderr can leak secrets in error messages. Use 'set +x' around secret handling so your debug logs don't betray you.
Here's what I've seen go wrong: a deploy script that echoed the DB password when the connection failed. That log got shipped to a central aggregator. Three months later, auditors found it. The fallout was worse than the original outage.
Secrets aren't configuration. They're liabilities. Treat them like radioactive material — minimize exposure, contain it, dispose of it fast.
// io.thecodeforge — devops tutorial #!/bin/bash set -euo pipefail # read secret from vault at runtime, never hardcode readonly SECRET_FILE="/etc/secrets/api_key" if [ ! -r "$SECRET_FILE" ]; then echo "FATAL: cannot read secret file" >&2 exit 1 fi # use 'read' to avoid leaking into process table read -r API_KEY < "$SECRET_FILE" # disable tracing for this block set +x response=$(curl -s -H "Authorization: Bearer $API_KEY" \ https://api.production.example.com/v2/health) set -x # immediately destroy the secret from memory unset API_KEY # handle response without echoing secrets echo "API health check returned: $(echo "$response" | jq -r '.status')"
Make Your Scripts Bleed Before They Break: Signal Handling & Strace
Your script is a black box until it hits a production edge case. Then it silently dies, leaving zero clues. That's on you. Real debugging starts before the crash -- by hooking into every way the OS can kill your process. Traps catch SIGINT, SIGTERM, and EXIT. strace catches every syscall. Together they turn a silent coffin into a screaming autopsied corpse.
Write a trap handler that dumps the last N lines of your log and the current working directory. Pair that with a DEBUG trap that logs every command. On failure, you get a timeline instead of a mystery. In production, run strace -p on the PID before the script dies. You'll see the exact open() fail or the missing file descriptor. No guesswork. No 'it worked on my machine.'
Senior shortcut: always set -Eeuo pipefail and trap ERR. That combo catches undefined vars, pipe breaks, and command failures -- and your handler runs before the script vanishes. Your next move: teach your CI pipeline to capture strace output on non-zero exit. Then sleep better.
// io.thecodeforge — devops tutorial // Catch every failure before it becomes a 2 AM call script: - set -Eeuo pipefail - trap 'echo "[FATAL] at line $LINENO"; kill -0 $PID 2>/dev/null && strace -p $PID -o /tmp/strace.log &' ERR - trap 'echo "[CLEANUP] removing temp files"; rm -rf /tmp/deploy_*' EXIT - | deploy_app() { local DEPLOY_ID="deploy_$$_$(date +%s)" mkdir -p "/tmp/$DEPLOY_ID" rsync -avz ./bin/ user@server:/app/ || exit 127 # Intentional fail below /usr/bin/no_such_binary } - deploy_app - echo "Deploy succeeded"
Idempotency Through State: The Only Way to Run Scripts Twice Without Pain
Running the same script twice should produce the same result -- or better, no result at all. That's idempotency. If your deploy script blindly copies files, creates users, or appends config lines, the second run corrupts state. Stop writing scripts that assume the first run was clean. Write scripts that check what exists, then act.
The pattern is brutal and simple: check state, compare to desired state, act only if they differ. Use a lockfile with PID, a state file with a checksum of the last successful run, or a database row. If state matches, your script does nothing and exits 0. If not, it mutates and updates state. No force, no repaint, no 'rm -rf /deploy' because someone ran it twice.
Senior trick: use mkdir as an atomic lock. mkdir is atomic over NFS. If mkdir succeeds, you own the lock. If it fails, another instance is running -- log the conflict and die. That prevents two deploys from stomping each other. Combine with a state file that records the git SHA of the last deployed version. Then your script becomes a simple: 'if current SHA == desired SHA, exit. Else, deploy and write SHA.' You can run it every minute. Zero damage.
// io.thecodeforge — devops tutorial // Atomic lock + state check = safe replay vars: lock_dir: "/tmp/deploy.lock" state_file: "/var/deploy/state" desired_sha: "a1b2c3d4" tasks: - name: Aquire atomic lock shell: | if ! mkdir "{{ lock_dir }}" 2>/dev/null; then echo "Lock held by PID $(cat {{ lock_dir }}/pid)" exit 1 fi echo $$ > "{{ lock_dir }}/pid" - name: Check if already deployed shell: | if [[ -f "{{ state_file }}" && "$(cat {{ state_file }})" == "{{ desired_sha }}" ]]; then echo "Already at {{ desired_sha }}, skipping" exit 0 fi - name: Deploy new version shell: | rsync -a --delete ./dist/ user@server:/app/ echo "{{ desired_sha }}" > "{{ state_file }}" echo "Deployed {{ desired_sha }}" - name: Release lock shell: rm -rf "{{ lock_dir }}"
What’s Happening Here: Understanding Shell Execution Flow
When you run a shell script, the system doesn't just execute commands line by line—it spawns a new process, parses the script, and evaluates each token through expansion phases: brace expansion, parameter expansion, command substitution, arithmetic expansion, word splitting, and pathname expansion. Failing to understand this pipeline causes cryptic bugs. For example, a variable containing spaces is split into multiple words unless quoted, because the shell interprets the unquoted variable as multiple arguments after expansion. Debugging requires stepping back: use set -x to print each command before execution, or trace with strace -f to see syscalls. The shell's execution model is both powerful and unforgiving; knowing what occurs under the hood—like subshells inheriting file descriptors but not the parent's variable scope—lets you predict behavior rather than guess. Master this mental model, and your scripts become deterministic.
// io.thecodeforge — devops tutorial // 25 lines max script: | #!/bin/bash set -x # prints expanded commands mydir="/path with spaces" ls $mydir # becomes: ls '/path' 'with' 'spaces' ls "$mydir" # becomes: ls '/path with spaces' output: | + mydir='/path with spaces' + ls /path with spaces # error: two args ls: cannot access '/path': No such file or directory ls: cannot access 'with': No such file or directory
What’s Happening Here: Process Substitution vs. Subshells
A subshell runs commands in a child process, isolating environment changes but preserving inherited file descriptors. Process substitution <() or >() creates a named pipe or /dev/fd entry, allowing you to treat command output as a file. For example, diff <(ls dir1) <(ls dir2) compares two directory listings without temporary files. Under the hood, the shell opens a pipe, forks a child to execute the command, and presents the read end as a file path. Unlike pipes, which create a linear chain, process substitution fits into command arguments where a filename is expected. However, process substitution does not spawn a subshell—the parent shell opens the file descriptor and the child runs inside its own subshell. This distinction matters for performance: using $(...) captures output into a string (which spawns a subshell), while <(...) passes a file descriptor, avoiding memory overhead. On macOS, process substitution may fail without /bin/bash—use bash -c or avoid it in portable scripts.
// io.thecodeforge — devops tutorial // 25 lines max script: | #!/bin/bash diff <(echo "hello") <(echo "hello world") # expands to: diff /dev/fd/3 /dev/fd/4 output: | 1c1 < hello --- > hello world
#!/bin/sh scripts. Use temp files for portability.The Silent Backup Failure — Why Exit Codes Matter
set -o pipefail — the mysqldump command failed silently (e.g., due to a connection timeout), but gzip still ran on empty input, producing a valid empty gz file. The script reported success.set -euo pipefail at the top. Also add a post-backup size check: if backup file size is less than 1KB, treat as failure.- Always verify the output of critical operations — don't assume success from exit code 0.
- Use
set -o pipefailto catch mid-pipeline failures. - Add post-condition checks for file sizes and content integrity.
set -e is causing early exit. Run with set +e temporarily or add || true to expected failures.echo "DEBUG: var=$var" or enable set -x to trace execution. Watch for spaces and special characters.set -o pipefail — ensures the whole pipeline fails if any component fails.echo "$var"ShellCheck: shellcheck myscript.shecho "Variable is: ${var:-default}"bash -x myscript.sh to see line causing error:-"" or initialize variable before useecho "Exit code: $?" (but note $? changes after any command)Run pipeline step by step to identify failing componentset -o pipefail and check intermediate resultsenv | sort > /tmp/env_manual.txt / ci environmentecho "PATH=$PATH" in both environmentsAdd a counter with max iterations: `while [[ $count -lt 10 ]]; do`Use `timeout` command to wrap the scriptif [[ $count -gt 100 ]]; then exit 1; fi| Aspect | [ ] Single Bracket | [[ ]] Double Bracket |
|---|---|---|
| Shell compatibility | POSIX sh compatible (portable) | Bash/Zsh only (not POSIX) |
| Empty variable handling | Breaks: [ $var = 'x' ] errors if $var is empty | Safe: [[ $var == 'x' ]] handles empty variables |
| Regex matching | Not supported | Supported via =~ operator |
| String comparison | Needs quoting: [ "$a" = "$b" ] | Safe unquoted: [[ $a == $b ]] |
| Logical operators | Use -a and -o (error-prone) | Use && and || (clear and safe) |
| Pattern matching | Not supported | Supported: [[ $file == *.log ]] |
| Word splitting risk | High — unquoted vars split on spaces | None — immune to word splitting |
| Recommendation | Use only when writing /bin/sh scripts | Use this in all bash scripts |
| File | Command / Code | Purpose |
|---|---|---|
| variable_safety_demo.sh | unsafe_filename="quarterly report.txt" | Variables, Quoting, and Why Your Script Breaks on File Names |
| safe_deploy_check.sh | set -euo pipefail | Conditionals and Exit Codes |
| log_rotation_manager.sh | set -euo pipefail | Functions, Loops, and Building Scripts You Can Actually Main |
| database_backup.sh | set -euo pipefail | Script Structure, Argument Handling, and the Trap Command fo |
| pipe_safety_demo.sh | set -euo pipefail | Working with Command Output |
| LoggingPattern.yml | set -euo pipefail | Robust Logging |
| IdempotentDeploy.yml | set -euo pipefail | Idempotency |
| SecureSecretFetch.yml | set -euo pipefail | Fail Securely |
| debug_trap_example.yml | script: | Make Your Scripts Bleed Before They Break |
| idempotent_deploy.yml | vars: | Idempotency Through State |
| execution_trace.yml | script: | | What’s Happening Here |
| proc_substitution.yml | script: | | What’s Happening Here |
Key takeaways
"$variable"set -euo pipefail should be in every non-trivial bash scripttrap cleanup EXIT INT TERM with a cleanup function to guarantee temp files are deleted and resources are released regardless of how your script exits.while ... done < <(cmd) instead of piping into whileCommon mistakes to avoid
3 patternsNot quoting variables that may contain spaces
cp "$source" "$dest". Run ShellCheck on scripts to catch these automatically.Missing `set -euo pipefail`
set -euo pipefail as the third line of every script (after shebang and a comment line).Using global variables inside functions
local keyword: local count=0.Interview Questions on This Topic
What's the difference between `$*` and `$@` when passing arguments to a script, and which one should you use and why?
How would you make a shell script that creates a temporary file guarantee that file gets deleted even if the script is killed with Ctrl+C?
trap cleanup EXIT INT TERM and define a cleanup function that removes the temp file. The trap runs regardless of how the script exits (normal, error, signal). Inside the function, check if the temp file exists and rm -f it.If you have a pipeline like `command_a | command_b | command_c` and command_b fails, what happens by default — and how do you change that behaviour?
set -o pipefail — then the pipeline exits with the last non-zero exit code from any command in the pipeline.Frequently Asked Questions
Both run your script with bash, but /usr/bin/env bash searches your PATH for bash rather than hardcoding its location. This matters because on some systems (notably macOS) bash lives in a different place, or you might want to use a newer bash installed via Homebrew. Using /usr/bin/env bash makes your script more portable across different Linux distributions and macOS.
Shell scripts are the right tool when you're primarily gluing together existing command-line tools — file operations, calling other programs, CI/CD steps, system administration tasks. Reach for Python (or Go, etc.) when you need data structures more complex than arrays, you're doing string parsing beyond simple pattern matching, you need HTTP requests, JSON parsing, or error handling that's too complex to express cleanly in shell. A good rule of thumb: if your script needs more than two levels of nested logic, consider a real programming language.
The ls command formats its output for human reading — it can reorder files, strip or alter characters, and wrap lines depending on terminal width. If a filename contains a newline, a space, or certain special characters, parsing ls output will silently give you wrong results or fail unpredictably. Use glob patterns (for file in /path/*.log) for simple iteration, and find with -print0 combined with read -d '' when you need more complex file selection — both handle all legal filenames correctly.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Linux. Mark it forged?
10 min read · try the examples if you haven't