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
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.
$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.
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.
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.
.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.
- 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.
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.
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.
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.
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.
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.
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.
#!/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.sh| 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 whileInterview Questions on This Topic
What's the difference between `$*` and `$@` when passing arguments to a script, and which one should you use and why?
Frequently Asked Questions
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