How to Write a Shell Script Template You Can Reuse
A reusable Bash script template with strict mode, usage text, option parsing, logging, temp file cleanup and a main function, plus notes on what to strip out.

Most scripts begin as a copy of the last script, and the habits that matter, such as cleaning up and failing loudly, get lost in the copying. A maintained template keeps them. This one targets Bash 5.2 and builds on the layout from structuring a script with main.
The template
#!/usr/bin/env bash
# Short description of what the script does.
set -euo pipefail
readonly PROG=${0##*/}
tmp=
log() { printf '%s %s\n' "$(date +%T)" "$*" >&2; }
die() { log "error: $*"; exit 1; }
usage() {
cat <<EOF
usage: $PROG [-v] [-o file] input
-v verbose
-o file write result to file
EOF
}
cleanup() {
[[ -n $tmp && -d $tmp ]] && rm -rf -- "$tmp"
}
main() {
local verbose=0 out=/dev/stdout opt
while getopts ':vo:h' opt; do
case $opt in
v) verbose=1 ;;
o) out=$OPTARG ;;
h) usage; exit 0 ;;
*) usage >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
(( $# == 1 )) || { usage >&2; exit 2; }
tmp=$(mktemp -d)
trap cleanup EXIT
(( verbose )) && log "reading $1"
[[ -r $1 ]] || die "cannot read $1"
sort -- "$1" > "$tmp/sorted"
cat -- "$tmp/sorted" > "$out"
}
main "$@"Each part earns its place. The trap on EXIT runs on normal exit, on exit inside a function, and after most errors under set -e, so the temporary directory does not outlive the run; see cleaning up with trap EXIT. Exit status 2 for a usage error follows a long convention that separates a bad call from a run that started and then failed.
What to remove
Delete the temp directory block if the script does not write files. Delete getopts if it takes no options, and keep a one-line usage check if it takes arguments. Delete set -e if the script deliberately runs commands that may fail; the trade-offs are in Bash strict mode. A template that keeps every feature in every script produces long scripts that hide their real logic.
Keep stdout for data
The log function writes to stderr, so script input > result.txt captures only the result and the progress messages still reach the terminal. Anything a caller might pipe goes to stdout; everything meant for a person goes to stderr. This matches the behaviour of standard tools, described in echoing to stderr.
Reading the template line by line
The first non-comment line, set -euo pipefail, makes the script stop on the first unhandled error, treat unset variables as mistakes and fail a pipeline when any stage fails. It is a trade: it catches whole classes of bugs, and it has edge cases that surprise newcomers, such as set -e being ignored inside a condition. The template keeps it because a script that carries on after a failed step does more damage than one that stops.
The readonly PROG=${0##*/} line strips the directory from the script's name, so usage messages say backup.sh instead of ./scripts/../backup.sh. The tmp= line initialises a variable that the cleanup function reads, and it matters under set -u: if the trap fires before the temporary directory exists, an unset variable would raise a second error inside the handler and hide the first one. Defining it empty and testing for emptiness before removing anything is the standard answer.
The rest of the layout puts the work in a main function that is called on the last line with "$@". That ordering lets functions be defined in any sequence, keeps variables local, and makes the script safe to read from the top. A reader sees the helpers, then the logic, then one line that starts it.
Options and exit statuses
Option parsing with getopts is a built-in that handles short options and clustered flags such as -vf. It does not handle long options like --verbose, and the template makes no attempt to; a script that needs long options is usually big enough that a different tool, or a different language, is the better answer. Inside the loop, an unknown option falls to the \? case, which prints usage and exits with status 2.
The status convention is worth keeping consistent across scripts. Zero means success, one means a general failure, and two means the command line was wrong. A caller, such as a CI step or a wrapper, can tell a misuse from a runtime failure without parsing text. Higher values are best left to the shell, which uses 126 and 127 for "not executable" and "not found" and 128 plus a signal number for a killed process.
The temporary directory pattern
mktemp -d creates a directory with a random name and mode 0700, so no other user can read or pre-create it, which avoids the predictable /tmp/myscript.$$ pattern that has caused real vulnerabilities. Passing a template such as mktemp -d "${TMPDIR:-/tmp}/${PROG}.XXXXXX" keeps the name recognisable in ls output and honours a custom temporary location. The trap then removes it. Trapping EXIT alone covers normal ends, errors under set -e and exit calls. For a script that must also clean up after Ctrl-C, add INT and TERM traps that call exit, so the EXIT handler runs.
A short second example shows the same structure trimmed down for a script that takes one argument and needs no temporary files. It keeps the pieces that carry the habits and drops the rest.
#!/usr/bin/env bash
# Print the line count of each file given as an argument.
set -euo pipefail
usage() { echo "usage: ${0##*/} FILE..." >&2; exit 2; }
main() {
[[ $# -ge 1 ]] || usage
local f
for f in "$@"; do
[[ -r $f ]] || { echo "cannot read: $f" >&2; return 1; }
printf '%s\t%s\n' "$(wc -l < "$f")" "$f"
done
}
main "$@"Keeping the template alive
A template only helps if it improves. When a script teaches a lesson, such as a locale problem with sort or a missing quote around a path, fix the template in the same sitting, and tell the team where it lives. Review the template whenever a new Bash release lands, and run ShellCheck against it in CI so that the file everyone copies never has a warning. A small test that runs the template with no arguments and with a bad option, and checks the exit codes, takes a few lines and keeps it from rotting.
Where the template fits in a project
A project with a handful of scripts benefits most from a shared header. Put the template in a scripts/_template.sh file, mark it as a template in the first comment, and have a test that runs it. A new script then starts with cp scripts/_template.sh scripts/deploy.sh, and the first edit is the description line. Reviewers can compare any script against the template and ask why a difference exists, which is a much cheaper question than finding out in production that a script never cleaned up its files.
Shared functions are the next step. When three scripts all contain the same log and die helpers, move them into a lib.sh that each script loads with source "$(dirname "${BASH_SOURCE[0]}")/lib.sh". This keeps one copy to fix, at the cost of a dependency between files that must be deployed together. For scripts that run on a server by themselves, copy the helpers inline instead and accept the duplication.
Where this does not apply
For a one-off command in a deploy pipeline, the template is too much; a short script with set -eu is enough. If the script must run on Alpine or Debian's dash, replace [[ ]], pipefail and BASH_SOURCE with POSIX forms, as in writing a POSIX sh script. Check the result with ShellCheck before committing, and read the set builtin documentation to see exactly what each option does.
Store the template in a dotfiles or tools repository and copy from it rather than from the last script you wrote. Fixes then flow in one direction, and a lesson learned in one script, such as quoting the temp path, reaches the next one.


