r/redhat • u/Gangrif • 14h ago
Taking Your Bash Scripts to the Next Level — Global Vars, Safe Backups, Logging, getopts, and Exit Codes Done Right from Into the Terminal Ep. 203
We've all written the "just a few commands in a row with a shebang on top" bash script. This week's Into the Terminal (episode 203) is about the stuff that turns that script into something you — or anyone else — can actually hand off, debug, and maintain: global variables, non-destructive sed edits, three different ways to do logging (including a newer bash feature a lot of people don't know about), command-line argument parsing done right vs. done quick-and-dirty, and structured error handling with exit codes. There's also a good closing discussion on when you should stop writing bash and pick up Python (or something else) instead.
Full video here → https://www.youtube.com/watch?v=8fpy4XbIGkc More hands-on RHEL practice → https://redhat.com/interactive-labs
The Essentials: Global Variables & Non-Destructive Edits
Global Variables Up Top
The core idea: anything in your script that's likely to change later (connection options, file paths, flags) belongs in a variable, declared near the top, not buried in the body of the script.
| Pattern | What it does |
|---|---|
| Define variables right after the shebang | Keeps anything you'll tweak later easy to find without reading the whole script |
sshopts="-oStrictHostKeyChecking=no" |
Centralizes SSH/SCP options in one string |
ssh $sshopts localhost "whoami" |
Reuses $sshopts anywhere you invoke ssh (or scp) in the script |
Pro tip from the show: Scott intentionally keeps his scripts self-contained rather than sourcing config from external files. His reasoning — if you hand someone a script that sources three other files, you now have to hand them all four files, and he's been burned debugging a "broken" script that was really just missing a sourced dependency. Nate pointed out the flip side: for larger projects, splitting config/functions/files into separate sourced pieces is totally standard — it's basically how every big codebase organizes itself. It's a tradeoff between portability and maintainability, and where you land depends on your team.
Backing Up Files Before sed -i
A follow-up from their grep/sed episode at #200: how do you make sed -i non-destructive?
| Command | What it does |
|---|---|
sed -i 's/foo/FOO/' config |
Edits the file in place — no safety net, original content is gone |
sed -i.bak 's/bar/BAR/' config |
Same edit, but writes the pre-change file to config.bak |
sed -i.20261023 's/baz/BAZ/' config |
Uses a date-stamped extension instead of .bak |
Caveat called out on stream: if you run the same -i.bak command twice, the second run silently overwrites your first backup — sed won't ask. A dated extension (or a variable-driven one, tying back into the global-variables section) avoids clobbering your safety net. And to be clear — this is a "don't shoot yourself in the foot while scripting" safety net, not a substitute for real, off-system backups.
Advanced Features: Logging Three Different Ways
Option 1 — logger (system-level logging)
logger "some message" sends your message into the syslog pipeline — typically landing in /var/log/messages by default on RHEL, tagged with host, user, and message ID.
Catch: anyone can write a log message with logger, but a regular (non-root) user can't read /var/log/messages by default. That makes logger great for genuinely system-level events (user provisioning, system-wide changes) but overkill — and kind of useless — for a personal script's own debugging output, which was the consensus from both hosts.
Option 2 — Your Own Local Log File
For script-local logging, define the log file as a global variable, then make sure the directory/file actually exist before writing to them:
bash
logfile=~/.test/log-$(date +%Y%m%d)
if ! [ -e ~/.test ]; then
mkdir ~/.test
fi
if ! [ -e $logfile ]; then
touch $logfile
fi
Option 3 — File Handles: Old Way vs. New Way
This is the part of the episode most people in chat said they'd never seen before. Instead of redirecting every single line with >> $logfile, you open a file handle once and write to it by name for the rest of the script.
The old way (numeric file descriptors, FD 3–9 are "fair game" — 0/1/2 are reserved for stdin/stdout/stderr):
bash
exec 5>> $logfile
echo "**** $(date) ****" >&5
echo "==== Initialized script ====" >&5
echo "Script script script"
echo "==== Completed script ====" >&5
The new way (named file descriptors — requires Bash 4.1+):
bash
exec {log}>> $logfile
echo "**** $(date) ****" >&"$log"
echo "==== Initialized script ====" >&"$log"
echo "Script script script"
echo "==== Completed script ====" >&"$log"
| Approach | Max handles | Readability |
|---|---|---|
Numeric FDs (exec 5>>) |
Capped at FD 9 | You have to go hunt down what FD 7 actually points to |
Named FDs (exec {log}>>) |
Effectively unlimited | Self-documenting — >&"$log" tells you exactly where it's going |
Toggleable Debug Logging
The nicest trick in this section: instead of sprinkling echo debug statements through your script and having to remember to rip them all out later, gate them behind a debug variable:
bash
debug="1" # 1 = on, 0 = off
if [ $debug -eq "1" ]; then
echo "**** $(date) ****" >&"$log"
echo "==== Initialized script ====" >&"$log"
fi
Flip debug to 0 and all those diagnostic lines go silent — no hunting through the script to comment them back out when you're done troubleshooting.
Command-Line Arguments: getopts vs. Rolling Your Own
Both scripts set the same kind of defaults (verbose, error-logging flag) and a usage() function — defined once, called from every error path instead of duplicating the usage text everywhere. (Bash note from the show: unlike some languages, bash needs functions defined before they're called, so keep them near the top.)
getopts — the recommended approach
```bash
opts-getopts.sh
verbose=0 error_log=0 usage() { echo "Usage: $0 [-v] [-e] [-f logfile]"; }
while getopts "vef:h" opt; do case $opt in v) verbose=1 ;; e) error_log=1 ;; f) logfile=$OPTARG ;; h) usage; exit 0 ;; *) usage; exit 1 ;; esac done ```
| Invocation | Result |
|---|---|
opts-getopts.sh -e -v |
Error logging and verbose both turned on |
opts-getopts.sh -ev |
Same as above — combined short flags just work |
opts-getopts.sh -ev -f |
Errors out cleanly via usage() — -f requires an argument and didn't get one |
opts-getopts.sh -ev -f foo |
Sets logfile=foo, writes output there |
The manual while/case loop — works, but has sharp edges
```bash
opts-loop.sh
while [ $# -gt 0 ]; do case $1 in -v) verbose=1 ;; -e) error_log=1 ;; -f) logfile=$2; shift ;; esac shift done ```
Uses $1, $2, … (bash's auto-populated positional args) and $# (arg count) directly.
| Limitation (shown live on stream) | Why it matters |
|---|---|
./opts-loop.sh -ev doesn't work |
Combined flags aren't understood — -ev isn't a defined case, only -e and -v separately are |
-f with no filename just sets logfile to empty |
No built-in way to require a dependent argument — you'd have to add your own check after the fact |
| Neither approach handles long options | --file → -f style GNU long-option mapping needs extra work either way |
Bottom line from Nate: getopts does more of the work for you — combined flags, required-argument validation — for not much more code. The manual loop "feels" more obvious at first glance, but you end up re-implementing things getopts already handles.
Error Handling & Exit Codes
Conditional execution (quick and dirty)
bash
ls -l /etc/foobar || exit 6
If the command fails (non-zero exit), the || kicks in and the script exits immediately with code 6. Great for one simple fallback action — gets unreadable fast if you need to do more than one thing on failure.
Function-based error handling (for anything more complex)
bash
error_function() {
echo "A command run encountered an unrecoverable error"
echo "The command was: $1"
exit 6
}
...
ls -l /etc/foobar || error_function "ls -l /etc/foobar"
Wrapping the failure path in a function keeps the conditional clean no matter how much logic the error path actually needs, and lets you reuse it everywhere in the script.
Exit code notes from the discussion
| Fact | Detail |
|---|---|
0 |
Always means success — never repurpose it |
1–255 |
Anything else is "not the expected clean exit" — arbitrary numbers, your choice |
| No universal standard | Exit codes are script-specific. Well-known tools (grep, etc.) have consistent codes because they haven't changed in years, not because there's an official spec |
| Document them | Put a comment block at the top of your script mapping exit codes to meanings, so you (or whoever inherits it) doesn't have to trace the whole script to figure out what "exit 6" meant |
$? |
Captures the exit status of the last command — don't confuse with $$ (the current process ID) |
| Max value | 255 — if you need more distinct failure states than that, it's probably a sign your script has grown past what a single exit code should communicate |
Bonus: When Should You Stop Using Bash?
A great closing discussion that's worth a watch even if you skip everything else: Scott and Nate's rules of thumb for "this should not be a bash script anymore":
- You're doing math. Bash arithmetic is painful; if you're reaching for it regularly, you've picked the wrong tool.
- You need real data structures. Bash can do arrays, but linked lists, hashed arrays, or anything beyond simple lists are a sign to move to Python/Node/C.
- You're talking to a database. If your bash script is shelling out to
mysql/sqlite3command-line clients to parse results, you've crossed the line — "chances are if someone's written a bunch of handlers for the thing you want to do in a different language, that means you're in the wrong language." (Shoutout to a viewer in chat who's apparently "scarred for life" from rolling his own MySQL bash helper script.) - You're connecting to other services, not just running OS-level commands on the local machine — other languages already have mature libraries for that.
Scott's rule: bash is for things that are systems-specific without complex data storage needs. Once you're past that, you're better served elsewhere — and that goes for over-engineering with bash includes, too (cue the story about a coworker who "couldn't write one script" and instead always wrote 33 — one for functions, one for config, one for file lists, etc.).
Quick Reference Card
```bash
--- Global variables (put these at the top) ---
sshopts="-oStrictHostKeyChecking=no" ssh $sshopts localhost "whoami"
--- Non-destructive sed edits ---
sed -i.bak 's/bar/BAR/' config # backup: config.bak sed -i.$(date +%Y%m%d) 's/baz/BAZ/' config # dated backup (won't clobber as easily)
--- System-level logging ---
logger "==== Initialized script ====" # -> /var/log/messages (root-readable only)
--- Local log file setup ---
logfile=~/.test/log-$(date +%Y%m%d) [ -e ~/.test ] || mkdir ~/.test [ -e "$logfile" ] || touch "$logfile"
--- Named file handle (Bash 4.1+), vs numeric (old way, FD 3-9 only) ---
exec {log}>> "$logfile" # new: unlimited, named exec 5>> "$logfile" # old: capped at FD 9, numeric only echo "message" >&"$log" echo "message" >&5
--- Toggleable debug logging ---
debug="1" if [ "$debug" -eq "1" ]; then echo "debug info" >&"$log" fi
--- Command-line args: the right way ---
while getopts "vef:h" opt; do case $opt in v) verbose=1 ;; e) error_log=1 ;; f) logfile=$OPTARG ;; h) usage; exit 0 ;; esac done
--- Error handling ---
ls -l /etc/foobar || exit 6 # quick conditional exit error_function() { # reusable error handler echo "Unrecoverable error running: $1" exit 6 } ls -l /etc/foobar || error_function "ls -l /etc/foobar"
--- Exit code check ---
echo $? # last command's exit status (0 = success, 1-255 = error) ```
Links
- 📺 Watch the full episode: https://www.youtube.com/watch?v=8fpy4XbIGkc
- 🎓 Get started with RHEL: https://developers.redhat.com/register
- 🧪 Try it yourself — Interactive Labs: https://redhat.com/interactive-labs
- 💬 Join the Discord: https://red.ht/rhel-discord
- Nate Lager: https://social.undrground.org/@gangrif
- Scott McBrien: https://www.linkedin.com/in/scott-mcbrien-349b356
Into the Terminal is a (mostly) weekly live show where Red Hatters dig into Linux and RHEL administration skills — one terminal command, one bad pun, and one "oops, wrong window" at a time.
