Every Linux box ships bash (or close enough — Alpine ships ash, macOS now ships zsh by default). Knowing how to write a 50-line script the right way separates "operator" from "sysadmin."
Prerequisites
- A Linux/macOS shell.
- A text editor.
Step 1: The safe shebang and strict mode
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
What this does:
set -e— exit on the first command that fails.set -u— error on use of undefined variable.set -o pipefail— pipelines fail if any stage fails, not just the last.IFS=$'\n\t'— split words on newline + tab only, not spaces (prevents "name with spaces" bugs).
Drop these four lines at the top of every script and you avoid 80 percent of bash footguns.
Step 2: Variables and quoting
name="World"
echo "Hello, ${name}"
# Always quote unless you specifically want word-splitting
file="my report.txt"
rm "$file" # correct
rm $file # WRONG — tries to delete "my" and "report.txt"
# Default value if unset
: "${PORT:=8080}"
echo "Port: $PORT"
Step 3: Conditionals
if [[ "$1" == "deploy" ]]; then
echo "Deploying..."
elif [[ -z "$1" ]]; then
echo "No arg given" >&2
exit 2
else
echo "Unknown action: $1" >&2
exit 2
fi
# File tests
if [[ -f "/etc/passwd" ]]; then echo "exists"; fi
if [[ -d "/var/log" ]]; then echo "dir"; fi
if [[ -x "$(command -v jq)" ]]; then echo "jq installed"; fi
if [[ "$EUID" -ne 0 ]]; then echo "not root" >&2; exit 1; fi
Use [[ ]] not [ ] — modern bash, no surprising word-splitting, supports =~ regex.
Step 4: Loops
# Iterate files
for f in /var/log/*.log; do
echo "Processing $f"
done
# Numbered range
for i in {1..5}; do
echo "iteration $i"
done
# Read a file line by line
while IFS= read -r line; do
echo "got: $line"
done < hosts.txt
# Loop over command output safely
while IFS= read -r host; do
ping -c1 -W2 "$host" >/dev/null && echo "$host UP"
done < <(awk '{print $1}' /etc/hosts)
The < <(...) form runs in the current shell so any variables set inside the loop persist.
Step 5: Functions and arguments
log() {
local level="$1"; shift
echo "$(date +'%F %T') [$level] $*"
}
backup() {
local src="$1"
local dest="$2"
log INFO "Backing up $src to $dest"
rsync -a --delete "$src" "$dest"
}
# Main
[[ $# -lt 2 ]] && { log ERROR "Usage: $0 SRC DEST"; exit 2; }
backup "$1" "$2"
log INFO "Done"
Always local-scope variables inside functions — without it they leak into the global scope.
Step 6: Trap on exit for cleanup
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
# Use $tmpdir freely; it's cleaned up on any exit, even errors.
curl -sfL https://example.sa/data.tar.gz -o "$tmpdir/data.tar.gz"
tar xzf "$tmpdir/data.tar.gz" -C "$tmpdir"
trap '...' EXIT runs your cleanup whether the script exits cleanly, errors out, or is killed.
Step 7: Common script template
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly LOG_FILE="/var/log/$(basename "$0" .sh).log"
log() { echo "$(date +'%F %T') [$1] ${*:2}" | tee -a "$LOG_FILE" >&2; }
die() { log ERROR "$*"; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; }
usage() {
cat <<EOF
Usage: $0 [-h] -i INPUT -o OUTPUT
-h show this help
-i INPUT input file
-o OUTPUT output directory
EOF
exit 0
}
input=""; output=""
while getopts "hi:o:" opt; do
case "$opt" in
h) usage ;;
i) input="$OPTARG" ;;
o) output="$OPTARG" ;;
*) die "Bad flag" ;;
esac
done
[[ -z "$input" ]] && die "Missing -i"
[[ -z "$output" ]] && die "Missing -o"
need rsync
log INFO "Copying $input -> $output"
rsync -a "$input" "$output"
log INFO "Done"
Step 8: Lint your scripts
sudo apt install -y shellcheck
shellcheck deploy.sh
shellcheck catches quoting bugs, missing $, mistaken [ ] vs [[ ]], all of it. CI it.
Verify
bash --version | head -1
shellcheck --version
Conclusion
set -euo pipefail, always-quote variables, [[ ]] instead of [ ], trap '...' EXIT for cleanup, and shellcheck in CI — that is bash done right.
Next steps
- Match patterns with grep.
- Edit streams with sed and awk.
- Run scripts on a schedule via cron on Debian.
Comments
0 total · 0 threads