
Photo by Fotis Fotopoulos on Unsplash
A pragmatic guide for sysadmins, dev‑ops engineers, and power users who want reliable, maintainable shell code.
Why scripting matters now
Every day, a server administrator spends hours chasing down a mis‑configured cron job, a stray file permission, or a forgotten environment variable. In a world where cloud infrastructure scales to thousands of nodes, those manual tweaks become bottlenecks and single points of failure. Writing robust shell scripts lets you:
- Automate repetitive tasks – backup, monitoring, deployment, user provisioning.
- Guarantee consistency – the same code runs on every host, eliminating “works‑on‑my‑machine” surprises.
- Reduce human error – clear logic and explicit error handling mean fewer outages.
Because most Linux distributions ship with a POSIX‑compatible shell (bash, zsh, dash), you can write scripts that run almost anywhere without installing extra binaries. That ubiquity is why learning “good practices” for shell scripting is an investment in the stability of your whole environment.
1. Start every script with a safety header
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
| Directive | What it does | Why you need it |
|---|---|---|
set -e |
Exit immediately if any command returns non‑zero. | Stops a script from silently continuing after a failure. |
set -u |
Treat unset variables as an error. | Prevents typos like $VARIABL from being silently expanded to empty string. |
set -o pipefail |
The exit status of a pipeline is the last non‑zero command. | If any part of cmd1 | cmd2 | cmd3 fails, the script aborts. |
IFS=$'\n\t' |
Restrict word splitting to newline and tab only. | Avoids unexpected splitting when a variable contains spaces (e.g., file names). |
Tip: Some distributions ship
/usr/bin/envpointing to an older bash that lacks certain features. If you need a specific version, use the absolute path (/bin/bash) instead.
2. Keep scripts modular with functions
A script should be a collection of small, well‑named functions rather than a monolithic block of commands. Functions:
- Encapsulate logic – easier to test and reuse.
- Make debugging easier – you can call individual functions from the shell prompt.
- Improve readability – a function name like
check_disk_space()says what it does without reading the code.
# Example: A reusable disk‑space checker
check_disk_space() {
local mount_point="$1"
local threshold="${2:-90}" # default to 90%
local usage
usage=$(df --output=pcent "$mount_point" | tail -n1 | tr -d '% ')
if (( usage >= threshold )); then
echo "CRITICAL: $usage% used on $mount_point"
return 2
fi
echo "OK: $usage% free on $mount_point"
return 0
}
Practice: Write a small helper that logs messages with timestamps and severity levels (
INFO,WARN,ERROR). Call it from every function.
3. Centralised logging and error handling
A script that writes to /var/log/ or sends alerts via email/Slack is more useful than one that just prints to stdout. Use a single log file per script, rotate it with logrotate, and include the PID for correlation:
LOGFILE="/var/log/${0##*/}.log"
log() {
local level="$1"
shift
printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$level" "$*" >>"$LOGFILE"
}
error_exit() {
log "ERROR" "$*"
exit 1
}
Error handling pattern
run_cmd() {
local cmd="$*"
if ! eval "$cmd"; then
error_exit "Command failed: $cmd"
fi
}
This wrapper guarantees that any non‑zero exit status triggers a clean shutdown and a meaningful log entry.
4. Use explicit paths and avoid implicit dependencies
Scripts should not rely on the current PATH. Explicitly reference binaries or use /usr/bin/env to find them:
# Prefer this
/usr/bin/grep -q "foo" "$file"
# Avoid this
grep -q "foo" "$file"
Why? On a compromised host, an attacker could prepend a malicious directory to PATH, causing your script to invoke the wrong binary. Explicit paths make scripts deterministic.
5. Validate input rigorously
Never trust user‑supplied arguments or environment variables. Use regular expressions or built‑in tests:
validate_email() {
local email="$1"
if ! [[ "$email" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
error_exit "Invalid email address: $email"
fi
}
If a script is intended to run on multiple distributions, prefer portable checks over distro‑specific tools.
6. Make your scripts idempotent
Idempotence means running the script multiple times yields the same state without adverse effects. This property is essential for configuration management and backups.
# Example: Create a directory only if it doesn't exist
mkdir -p "$TARGET_DIR" || error_exit "Failed to create $TARGET_DIR"
Avoid patterns that delete or overwrite files unconditionally unless that is explicitly the goal.
7. Testing and continuous integration
Treat scripts like code:
- Unit tests – use
bats(Bash Automated Testing System) to write small test cases for each function. - Linting – run
shellcheckon every commit to catch common pitfalls (SC2006,SC2086, etc.). - CI pipeline – integrate the lint and test steps into your GitHub Actions or Jenkins job.
# Example GitHub Action snippet
name: Shell Script CI
on: [push, pull_request]
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get install -y shellcheck
- name: Run shellcheck
run: shellcheck *.sh
- name: Run tests
run: bats test/*.bats
8. Exercise: Disk‑space monitor with alert
Below is a compact, production‑ready script that checks disk usage on a list of mount points and emails an alert if any exceed a threshold.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
LOGFILE="/var/log/disk_alert.log"
ALERT_EMAIL="ops@example.com"
THRESHOLD=90
log() {
local level="$1"; shift
printf '%s [%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "$level" "$*" >>"$LOGFILE"
}
send_alert() {
local msg="$1"
echo -e "Subject: Disk space alert\n\n$msg" | sendmail "$ALERT_EMAIL"
}
check_disk_space() {
local mount_point="$1"
local usage
usage=$(df --output=pcent "$mount_point" | tail -n1 | tr -d '% ')
if (( usage >= THRESHOLD )); then
log "WARN" "$usage% used on $mount_point"
send_alert "CRITICAL: $usage% used on $mount_point"
else
log "INFO" "$usage% free on $mount_point"
fi
}
main() {
local mounts=("/" "/var" "/home")
for m in "${mounts[@]}"; do
check_disk_space "$m"
done
}
main
What makes it robust?
- Safety header (
set -euo pipefail). - Explicit paths (
/usr/bin/sendmail,/bin/dfif needed). - Centralised logging.
- Email alert via a simple
sendmailcall (replace with your preferred notifier). - Idempotent: runs the same way each time.
9. Common pitfalls to avoid
| Pitfall | Why it hurts | Quick fix |
|---|---|---|
Using $@ without quotes |
Word splitting can break file names with spaces. | Use "$@". |
Assuming /bin/sh is bash |
Many scripts rely on bashisms (array syntax, [[ ]]). |
Explicitly use #!/usr/bin/env bash. |
Relying on cd to set context |
Scripts can be called from any directory. | Use absolute paths or change to a known directory at start (pushd /tmp). |
| Ignoring exit codes in pipelines | A failing middle command may go unnoticed. | Use set -o pipefail. |
| Hard‑coding passwords | Security breach if the script is readable. | Use environment variables or secure vaults (e.g., GPG, HashiCorp Vault). |
10. Takeaway checklist
- [ ] Start with a safety header (
#!/usr/bin/env bash,set -euo pipefail). - [ ] Write modular functions; keep the main flow readable.
- [ ] Centralise logging and use explicit paths for binaries.
- [ ] Validate all inputs rigorously.
- [ ] Ensure idempotence where appropriate.
- [ ] Test with
batsor similar, lint withshellcheck. - [ ] Keep secrets out of the script; use environment variables or a vault.
By following these guidelines you’ll produce scripts that are robust, maintainable, and secure—the foundation for any reliable Linux automation stack. Happy scripting!
Frequently Asked Questions (FAQ)
1. Why should I use #!/usr/bin/env bash instead of a hard‑coded path like /bin/bash?
Using env searches the user’s $PATH, ensuring that the script runs with whatever version of Bash is first in the path—often the most recent or the one explicitly installed by the system administrator. A hard‑coded path may point to an older, incompatible shell on some distributions, causing syntax errors or missing features.
2. What does set -euo pipefail do and why are all three options important?
set -estops execution when any command exits with a non‑zero status, preventing silent failures.set -utreats unset variables as errors, catching typos or missing environment variables early.set -o pipefailensures that the exit status of a pipeline reflects the first failing command, not just the last one. Together they make scripts fail fast and predictably.
3. How can I keep my scripts modular without overcomplicating them?
Define small, single‑purpose functions for each logical block (e.g., check_disk_space, send_alert). Give each function a descriptive name and use local variables inside it. This encapsulation makes the script easier to read, test, and debug—each function can be called independently from the shell prompt.
4. What is the best practice for logging in a shell script?
Create a dedicated log file (e.g., /var/log/${0##*/}.log) and write a log() helper that prefixes messages with timestamps and severity levels (INFO, WARN, ERROR). Use this function throughout your script, and rotate the log with logrotate to prevent it from growing indefinitely. This centralizes output, simplifies troubleshooting, and provides audit trails for automated tasks.


