LinuxXXXV · Shell Scripting for SysadminsStructure
Script structure and conventions - the production shell script
What you'll learn
- Structure a script for readability and maintainability
- Apply production conventions
- Handle errors safely
- Document the script
- Parse arguments without hanging: shift in every branch, or use getopts
Prerequisites
Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-09
A shell script is a program. It should be treated like one: structured, documented, testable, maintainable. This lesson covers the conventions for production shell scripts.
Skeleton
#!/usr/bin/env bash
#---BEGIN-USAGE
# Brief description of what this script does.
#
# Usage: my-script [options] <input>
#
# Options:
# -h Show this help
# -v Verbose output
# -f FILE Use FILE as input
#
# Exit codes:
# 0 Success
# 1 Invalid arguments
# 2 Runtime error
#---END-USAGE
#
# Author: <name>
# Version: 1.0
# Date: 2026-08-09
set -euo pipefail
IFS=$'\n\t'
# --- functions ---
usage() {
# Bounded by the markers above, so the shebang, the author block and
# the section dividers stay out of the help text. `grep '^#' "$0"`
# prints all of them.
sed -n '/^#---BEGIN-USAGE/,/^#---END-USAGE/{ /^#---/d; s/^# \?//; p; }' "$0"
# `-h` is a successful request for help; only a usage ERROR exits non-zero.
exit "${1:-0}"
}
log() {
echo "[$(date -Is)] $*" >&2
}
err() {
log "ERROR: $*"
exit 1
}
# --- argument parsing ---
VERBOSE=0
INPUT=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h) usage 0 ;;
-v) VERBOSE=1; shift ;;
-f) [[ $# -ge 2 ]] || err "-f requires an argument"
INPUT="$2"; shift 2 ;;
--) shift; break ;;
-*) err "Unknown option: $1" ;;
*) break ;;
esac
done
# --- main ---
main() {
log "Starting"
[[ -n "$INPUT" ]] || err "INPUT required (-f)"
[[ -f "$INPUT" ]] || err "INPUT not found: $INPUT"
# ... do the work ...
log "Done"
}
main "$@"
Every option branch must shift
The argument loop is where hand-written skeletons go wrong, and the failure is not a syntax error - it is a hang.
# WRONG - the -v branch never consumes its argument
while [[ $# -gt 0 ]]; do
case "$1" in
-v) VERBOSE=1 ;; # no shift: $# never decreases
-f) INPUT="$2"; shift 2 ;;
esac
done
while [[ $# -gt 0 ]] terminates only when the positional
parameters run out. A branch without shift leaves $1
exactly as it was, the condition stays true, and the loop
spins at 100% CPU forever. Confirm it for yourself with a
bounded run:
timeout 3 bash ./broken.sh -v; echo "exit=$?" # exit=124 - timed out
Under cron or a systemd timer this is silent. The job never finishes, the next scheduled run stacks on top of it, and within a few hours the host is out of PIDs. Monitoring sees “started” and never sees “failed”, because the script produces no output and never exits.
The second defect is subtler. set -u is mandated at the
top of the skeleton, and INPUT="$2" reads an argument
that may not exist:
./my-script -f # bash: $2: unbound variable
The operator gets a bash internal error instead of the
usage message the script was written to print. Guard the
option argument before reading it - [[ $# -ge 2 ]] || err "-f requires an argument" - as the corrected skeleton
above does.
-- ends option parsing so a filename beginning with -
can still be passed. -* catches typos as unknown options,
while a bare * breaks out and leaves genuine positional
arguments in $@ for main.
The getopts variant
For short options, bash’s built-in getopts does the
shifting for you and cannot be got wrong in this way:
VERBOSE=0
INPUT=""
while getopts ':hvf:' opt; do
case "$opt" in
h) usage 0 ;;
v) VERBOSE=1 ;;
f) INPUT="$OPTARG" ;;
:) err "-$OPTARG requires an argument" ;;
\?) err "Unknown option: -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
The leading : in ':hvf:' selects silent error handling,
which is what makes the : and \? branches reachable and
lets the script report the problem in its own voice. The
trailing : after f declares that -f takes an
argument, delivered in $OPTARG. The single
shift $((OPTIND - 1)) after the loop consumes everything
getopts processed.
getopts handles only short options - it will not parse
--verbose. If you need long options, keep the manual
case loop and make sure every branch shifts.
Conventions
- Shebang:
#!/usr/bin/env bashor#!/bin/bash. set -euo pipefailat the top: fail on error, undefined variable, pipeline failure.- IFS to newline + tab: prevents word splitting on spaces.
- Functions for
usage,log,err: reusable, testable. main "$@"at the end: clear entry point.- Comments: why, not what. The code says what; comments say why.
Common errors
| Error | Fix |
|---|---|
command not found | PATH issue; use #!/usr/bin/env bash |
syntax error | Missing quote, escaped character |
unbound variable | Add set -u; quote the variable |
pipefail failure | Add set -o pipefail |
| Word splitting bug | Quote variables: "$var" not $var |
| Script hangs, no output, 100% CPU | An option branch in the argument loop does not shift |
$2: unbound variable on a flag | Guard with [[ $# -ge 2 ]] before reading "$2" |
Style
- 4-space or 2-space indentation; be consistent.
- Lower-case variable names (
input_file, notInputFile). - Upper-case constants (
E_OK,E_FAIL). - One statement per line.
- Quote every variable expansion.
- Use
[[ ]]not[ ](bash builtin; more features).
Documentation
The header comment block describes:
- What the script does.
- Usage syntax.
- Options.
- Exit codes.
- Author and version.
The header is what usage() displays with -h. Future
operators read this first.
Knowledge check
Knowledge check · 5 questions
Q1. What does "set -euo pipefail" do?
Q2. ShellCheck is optional for production scripts.
Q3. Which of the following are good script conventions? Select all that apply.
Q4. A cleanup script runs from a systemd timer. Since someone added -v to the ExecStart line, top shows a growing pile of bash processes each at 100% CPU, the journal shows "Starting" and nothing else, and no run has ever reported failure. The argument loop is: while [[ $# -gt 0 ]]; do case "$1" in -v) VERBOSE=1 ;; -f) INPUT="$2"; shift 2 ;; esac; done. What is the fault?
Q5. Your script starts with set -euo pipefail and parses -f with INPUT="$2". An operator runs it as `./my-script -f` with no filename. What do they see?
Passing score: 75%. Answers are checked in this browser.