
This lesson turns frequent command sequences into reusable Bash scripts. It covers script structure, strict mode, arguments and options, exit codes, functions, logging, input and output, and safe cleanup with traps.
Focus is portable Bash scripting for admin tasks and automation. Advanced topics such as arrays, associative maps, and unit tests appear later in the series.
Prerequisites
Script anatomy and a standard template
Use a clear shebang, strict mode, and a simple logging helper. Save this as ~/bin/template.sh and mark it executable.
#!/usr/bin/env bash
# Purpose: one line summary
# Usage: template.sh [-n] [-f FILE] [ARGS...]
# Example: template.sh -n -f sample.txt foo bar
set -Eeuo pipefail
# -E: inherit ERR trap in functions
# -e: exit on error
# -u: error on unset variables
# -o pipefail: propagate failures in pipelines
IFS=$'\n\t' # safer splitting on newlines and tabs
SCRIPT_NAME=${0##*/}
VERSION="1.0.0"
VERBOSE=0
LOG_FILE=""
cleanup() {
# remove temp files or unlock resources
# test that a var is set before using it
[[ -n "${TMPDIR:-}" && -d "${TMPDIR:-}" ]] && rm -rf "$TMPDIR" || true
}
trap cleanup EXIT
trap 'echo "[$SCRIPT_NAME] error on line $LINENO" >&2' ERR
log() { printf '%s\n' "$*"; }
log_err() { printf '%s\n' "$*" >&2; }
logv() { (( VERBOSE )) && printf '%s\n' "$*"; }
usage() {
cat <<USAGE
$SCRIPT_NAME v$VERSION
Usage: $SCRIPT_NAME [-n] [-f FILE] [-o LOG] [ARGS...]
-n dry run
-f FILE input file
-o LOG write log to file
-v verbose
-h help
USAGE
}
DRY_RUN=0
INPUT=""
while getopts ":nf:o:vh" opt; do
case $opt in
n) DRY_RUN=1 ;;
f) INPUT=$OPTARG ;;
o) LOG_FILE=$OPTARG ;;
v) VERBOSE=1 ;;
h) usage; exit 0 ;;
:) log_err "Option -$OPTARG requires an argument"; usage; exit 2 ;;
\?) log_err "Unknown option: -$OPTARG"; usage; exit 2 ;;
esac
done
shift $((OPTIND-1))
# optional logging to a file
if [[ -n "$LOG_FILE" ]]; then
exec > >(tee -a "$LOG_FILE") 2>&1
fi
main() {
# create a private temp dir for this run
TMPDIR=$(mktemp -d)
logv "Temp dir: $TMPDIR"
# example guard
if [[ -n "$INPUT" && ! -r "$INPUT" ]]; then
log_err "Cannot read input file: $INPUT"; exit 3
fi
# dry run helper
run() { if (( DRY_RUN )); then printf '[dry-run] %s\n' "$*"; else "$@"; fi }
# demo actions
run echo "Processing..."
[[ -n "$INPUT" ]] && run cp "$INPUT" "$TMPDIR/"
log "Done"
}
main "$@"Make it executable and try the help.
chmod +x ~/bin/template.sh
~/bin/template.sh -hUnexpected empty variables or partial pipeline failures cause many bugs. set -Eeuo pipefail fails fast and surfaces errors with context.
Exit codes and testing conditions
Programs return an exit status from 0 to 255. Zero means success. The shell stores the last status in $?.
false; echo $?
true; echo $?Use if with commands directly.
if cp source.txt dest.txt; then
echo "copy ok"
else
echo "copy failed" >&2
fiModern test syntax uses [[ ... ]] for conditions.
file="/etc/hosts"
if [[ -f "$file" && -r "$file" ]]; then echo readable; fi
num=42
if [[ $num -gt 10 ]]; then echo bigger; fi
s="hello"
if [[ $s == h* ]]; then echo prefix; fiFunctions, scope, and returns
Functions group work and keep scripts readable. Return codes indicate success or failure.
is_port_open() {
local host=$1 port=$2
timeout 1 bash -c "</dev/tcp/$host/$port" &>/dev/null
}
if is_port_open 127.0.0.1 22; then echo open; else echo closed; fiVariables created with local live only inside the function. Use return codes instead of echoing booleans when possible.
Reading input and here documents
Read line by line and handle spaces correctly.
while IFS= read -r line; do
printf 'line: %s\n' "$line"
done < file.txtUse a here document for small template content.
cat > config.ini <<'EOF'
[server]
port=8080
mode=prod
EOFUse a quoted delimiter such as <<'EOF' to avoid variable expansion inside the block.
Traps and safe cleanup
Traps run code when signals or shell events occur. Use them to clean temporary files or to unlock resources.
TMPDIR=$(mktemp -d)
cleanup() { rm -rf "$TMPDIR"; }
trap cleanup EXITHandle INT and TERM as needed.
trap 'echo interrupted; exit 130' INT
trap 'echo terminated; exit 143' TERMSimple logging and timestamps
Add timestamps and levels to log messages.
log() { printf '%s %s\n' "$(date +%F' '%T)" "$*"; }
log_warn() { printf '%s WARN %s\n' "$(date +%F' '%T)" "$*" >&2; }
log_err() { printf '%s ERR %s\n' "$(date +%F' '%T)" "$*" >&2; }Redirect both stdout and stderr to a file while still seeing output.
script.sh |& tee script.logCommand line options with getopts
getopts parses short flags in a POSIX friendly way.
#!/usr/bin/env bash
set -Eeuo pipefail
name=""
count=1
usage(){ echo "Usage: $0 -n NAME [-c COUNT]"; }
while getopts ":n:c:h" opt; do
case $opt in
n) name=$OPTARG ;;
c) count=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "Missing arg for -$OPTARG" >&2; usage; exit 2 ;;
\?) echo "Unknown option -$OPTARG" >&2; usage; exit 2 ;;
esac
done
shift $((OPTIND-1))
[[ -z "$name" ]] && { echo "-n required" >&2; usage; exit 2; }
for i in $(seq 1 "$count"); do echo "Hello $name"; donegetopts does not handle --long options. For long flags, write a small parser or use a higher level language for complex CLIs.
Idempotent and safe file edits
Prefer creating a new file and using a move to replace atomically.
tmp=$(mktemp)
sed 's/DEBUG=false/DEBUG=true/' app.env > "$tmp"
install -m 0644 "$tmp" app.env
rm -f "$tmp"The install command sets mode and updates the target in a single step.
Reusable script: sync a directory with checks
This script syncs a local directory to a remote host with preflight checks and a dry run. Save as ~/bin/sync-site.sh.
#!/usr/bin/env bash
set -Eeuo pipefail
usage(){ echo "Usage: $0 -s SRC -d DEST -h HOST [-n]"; }
DRY=0; SRC=""; DEST=""; HOST=""
while getopts ":s:d:h:n" opt; do
case $opt in
s) SRC=$OPTARG ;;
d) DEST=$OPTARG ;;
h) HOST=$OPTARG ;;
n) DRY=1 ;;
:) usage; exit 2 ;;
\?) usage; exit 2 ;;
esac
done
shift $((OPTIND-1))
[[ -z "$SRC" || -z "$DEST" || -z "$HOST" ]] && { usage; exit 2; }
[[ ! -d "$SRC" ]] && { echo "Source not a directory" >&2; exit 3; }
opts=(-avzP -e ssh --delete)
(( DRY )) && opts+=(--dry-run)
# refuse to run with root as remote user
[[ "$HOST" == root@* ]] && { echo "Refusing root target" >&2; exit 4; }
rsync "${opts[@]}" "$SRC/" "$HOST:$DEST/"Make it executable and dry run once.
chmod +x ~/bin/sync-site.sh
~/bin/sync-site.sh -s ./site -d /srv/site -h user@server -nPractical lab
-
Create a script from the template that backs up a file to a timestamped directory, with
-ndry run and-vverbose. -
Extend it to accept
-o LOGand write both stdout and stderr to the log while still printing to the terminal. -
Add a trap to clean up a temporary work folder.
-
Write a small script that reads
domains.txtand fetches the HTTP status for each withcurl -s -o /dev/null -w '%{http_code}'and printsdomain,statuslines. Add a-c Nflag to control concurrency usingxargs -P.
Troubleshooting
bad substitutionor[: missing ]appears. Use Bash explicitly with#!/usr/bin/env bashand avoid features from other shells.set -eexits too early during a compound command. Group risky commands with|| true, or prefer explicitifchecks where a failure is expected.- Variables are empty in subshells. Remember that pipelines run in subshells in some shells. Avoid relying on side effects of commands within pipelines.
getoptsstops early. Ensure the options string matches flags and that flags expecting values include a trailing colon.- Traps do not run. Use
set -Eso theERRtrap propagates through functions, and avoidexecwhich replaces the shell process.
Next steps
Day 15 schedules work with cron and systemd timers. It covers crontab format, environment and PATH considerations, logging, and how to convert a cron job to a systemd timer with stronger dependency control.