SkillByAIOpen interactive version →

Lesson 22 / 25

A Production Script Template

Start every serious script from a template with strict mode, logging, usage and cleanup.

Good habits in one file

Most of this course fits into a reusable template. Start with #!/usr/bin/env bash and set -Eeuo pipefail. Resolve the script's own directory so it works from anywhere. Define small helpers: log and die writing timestamped messages to stderr, require_command for dependencies. Parse options with getopts and print usage. Create temporary space with mktemp -d and remove it in an EXIT trap, plus an ERR trap that reports the failing line. Put the main logic in a main function called at the bottom with main "$@", so the whole file is parsed before anything runs, which also protects against a script being edited while it executes. Support --dry-run for anything destructive, make the script idempotent (running it twice is safe), and keep it ShellCheck-clean. With this skeleton, a new script starts safe instead of being made safe later.

The skeleton of a robust script

Header, helpers, option parsing, traps and a main function, in a fixed order.

Figure 8.1 — Sections of a production-ready Bash script.

Template

Copy, rename and fill in main.

#!/usr/bin/env bash
# rotate-logs.sh - compress and prune application logs
set -Eeuo pipefail

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
require_command() { for c in "$@"; do command -v "$c" > /dev/null || die "missing: $c"; done; }

usage() { printf 'usage: %s [-n] [-d DAYS] DIR\n' "${0##*/}"; }

main() {
    local dry_run=0 days=14 opt
    while getopts ":nd:h" opt; do
        case $opt in
            n) dry_run=1 ;;
            d) days=$OPTARG ;;
            h) usage; return 0 ;;
            *) usage >&2; return 2 ;;
        esac
    done
    shift $(( OPTIND - 1 ))
    local dir="${1:?$(usage)}"
    require_command find gzip

    workdir=$(mktemp -d)
    trap 'rm -rf "${workdir:?}"' EXIT
    trap 'log "failed at line $LINENO: $BASH_COMMAND"' ERR

    log "compressing logs older than 1 day in $dir"
    if (( dry_run )); then
        find "$dir" -name '*.log' -mtime +1 -print
    else
        find "$dir" -name '*.log' -mtime +1 -exec gzip -- {} +
        find "$dir" -name '*.log.gz' -mtime +"$days" -delete
    fi
    log "done"
}

main "$@"

Idempotency makes scripts safe to rerun

Use mkdir -p, check before creating users or files, and compare desired and current state. When a script fails halfway, the fix should be to run it again, not to clean up by hand.

Quick check: Why wrap a script's logic in a main function called at the end with main "$@"?

  • Bash requires a main function
  • It makes the script run faster
  • The whole file is parsed before anything runs, and the structure is clearer
  • It disables set -e
Answer

The whole file is parsed before anything runs, and the structure is clearer — Calling main at the bottom ensures every function is defined first and guards against partial execution.