Skip to main content
RunBook Academy

LinuxXXXV · Shell Scripting for SysadminsStructure

Script structure and conventions - the production shell script

Intermediate⏱ ~10 minbashshellcheck

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

Not yet marked complete on this device.

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 bash or #!/bin/bash.
  • set -euo pipefail at 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

ErrorFix
command not foundPATH issue; use #!/usr/bin/env bash
syntax errorMissing quote, escaped character
unbound variableAdd set -u; quote the variable
pipefail failureAdd set -o pipefail
Word splitting bugQuote variables: "$var" not $var
Script hangs, no output, 100% CPUAn option branch in the argument loop does not shift
$2: unbound variable on a flagGuard with [[ $# -ge 2 ]] before reading "$2"

Style

  • 4-space or 2-space indentation; be consistent.
  • Lower-case variable names (input_file, not InputFile).
  • 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

  1. Q1. What does "set -euo pipefail" do?

  2. Q2. ShellCheck is optional for production scripts.

  3. Q3. Which of the following are good script conventions? Select all that apply.

  4. 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?

  5. 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.