Shell scripting and Linux automation standards
Criteria verified as of August 2026. Re-verify on the web before committing to anything (§8).
1. Scope and triggers
Applies when writing, reviewing or refactoring shell: .sh, .bash, .zsh files, executables with a shell
shebang, ~/.bashrc/~/.zshrc functions turned into tools, deployment wrappers, boot and maintenance
scripts, and any automation glued together with bash that already has users. Triggers:
set -euo pipefail, shellcheck, shfmt, .shellcheckrc, bats, getopts, trap, mktemp, flock,
IFS, "POSIX portability", "dry-run", "the script has grown huge".
Guiding principle: bash is an excellent glue language and a terrible application language. Its job is
to orchestrate processes and files; the moment data structures, concurrency, real parsing or business
logic show up, maintaining it costs more than rewriting it (§7, rewrite threshold). Write shell
assuming the script will live five years and someone will run it at 3 in the morning.
Not applicable: see onprem-standards (platform umbrella: systemd unit design,
inventory, fleet patching cadence — here only the script and its minimal wrapper),
linux-hardening-standards (CIS/STIG baseline, sysctl, auditd, sudoers, unit sandboxing
as a security control — here only script hygiene: eval, quoting, temporaries, flock),
selinux-standards (if the script fails because of an AVC denial, the diagnosis is there), cicd-standards (scripts embedded in
pipelines, runners, CI secrets and build gates), python-standards and go-standards (the natural
destination once the §7 threshold is crossed: Python for data-driven automation, Go for distributable
binaries with no dependencies), ruby-standards and perl-standards (alternative destination
for the growing script only if the team already maintains them — for new code, the default
destination is still Python or Go), powershell-standards (reciprocal shell-choice
boundary: in a Windows or mixed environment with AD, PowerShell wins —objects instead of text, without
POSIX's quoting and word splitting problem—; on Linux and in any script that must run
on a minimal system with no added dependencies, this skill wins. Cross-platform is not a
sufficient argument for choosing PowerShell if the real target is Linux only; it is if Windows
has to be administered).
2. Toolchain
Verify the latest version on the web before pinning it in a real project (§8).
| Tool |
Default |
Reason / note |
| Interpreter |
bash 5.x, shebang #!/usr/bin/env bash |
5.3 is the stable branch (Jul 2025; patches ongoing). Verified Aug 2026: Fedora 44 ships 5.3.x; Debian stable is still on 5.2.x → do not assume 5.3 on third-party targets |
| Strict mode |
set -Eeuo pipefail + IFS=$'\n\t' + shopt -s inherit_errexit |
With its known limits (§3); -E is essential for the ERR trap to work inside functions |
| Linter |
ShellCheck 0.11.x (0.11.0, Aug 2025) with .shellcheckrc in the repo |
A CI gate, not a suggestion. Recommended by the Google Shell Style Guide itself "for all your scripts, large or small" |
| Formatter |
shfmt 3.13.x (3.13.1, Apr 2026) |
Configured via .editorconfig; supports POSIX sh, bash, mksh and zsh since 3.13.0 |
| Tests |
bats-core 1.14.x (Jul 2026) |
With bats-support/bats-assert; alternative: shunit2 in environments that already use it |
| Style guide |
Google Shell Style Guide (google.github.io/styleguide/shellguide.html) |
The old shell.xml is deprecated. It is the reference with the most traction; its hard rules are captured here |
| Dialect |
bash exclusively, unless portability is an explicit requirement |
Google: restricting yourself to bash "means there is generally no need to look for POSIX compatibility or to avoid bashisms" |
| POSIX sh |
#!/bin/sh + shellcheck -s sh only if the target demands it (initramfs, BusyBox, Alpine, dash, distroless images, multi-OS installers) |
Current standard: POSIX.1-2024 / Issue 8 (free text at pubs.opengroup.org) |
| Running scheduled tasks |
systemd unit + timer (§6) |
cron only in containers without systemd, BSD/macOS or legacy |
3. Structure and conventions
Mandatory skeleton
#!/usr/bin/env bash
# What it does, what it assumes and who maintains it. One line, not a novel.
set -Eeuo pipefail
shopt -s inherit_errexit # errexit also inside $( ); bash >= 4.4
IFS=$'\n\t'
SCRIPT_NAME="${0##*/}"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
readonly SCRIPT_NAME SCRIPT_DIR # separate from the assignment: SC2155
: "${LOG_LEVEL:=info}" # configurable via the environment, with a default
DRY_RUN=false
WORKDIR=''
log() { printf '%s [%s] %s: %s\n' "$(date -u +%FT%TZ)" "$1" "$SCRIPT_NAME" "${*:2}" >&2; }
die() { log error "$*"; exit 1; }
cleanup() { local rc=$?; [[ -d "$WORKDIR" ]] && rm -rf -- "$WORKDIR"; exit "$rc"; }
trap cleanup EXIT
trap 'log error "failure in ${BASH_SOURCE[0]}:${LINENO} -> ${BASH_COMMAND}"' ERR
main() {
WORKDIR="$(mktemp -d)" # cleanup guaranteed by the EXIT trap
...
}
main "$@"
main "$@" at the end: makes the file sourceable for tests and prevents partial execution if the
download or the edit is truncated halfway through.
- Executables in
PATH without an extension; libraries with .sh and without the execute bit (Google Shell
Style Guide). snake_case for functions and locals, UPPERCASE only for constants and the environment.
local for every variable inside functions, no exceptions; readonly for constants.
Quoting, word splitting and globbing
- Double-quote every expansion (
"$var", "${arr[@]}", "$(cmd)"), with the exception justified in a
comment; "${arr[@]}" never ${arr[*]} for lists. IFS=$'\n\t' reduces splitting, it does not replace
quoting.
-- before positionals (rm -rf -- "$dir") and ./ in front of globs (rm -- ./*.log): a file
named -rf is a trivial exploit. To walk files, find -print0 +
while IFS= read -r -d '', or globs with shopt -s nullglob; names with spaces, newlines and UTF-8
are the norm, not a rare case.
[[ … ]] preferred over [/test (no word splitting or globbing, with =~); arithmetic with (( … )).
They do not exist in POSIX sh: there it is [ … ] with everything quoted.
Arguments, help and exit codes
- Parsing with
getopts (built-in and portable). If long options, repeatable flags and
subcommands are needed, that complexity is already a threshold signal (§7); if it stays in bash anyway, a
while [[ $# -gt 0 ]] loop with case, without getopt(1) (divergent behaviour across systems).
usage() is mandatory: synopsis, options, environment variables it reads, exit codes and a real
example. To stdout with exit 0 when requested with -h; to stderr with exit 2 on a usage error.
- Exit codes:
0 success; 1 generic failure; 2 usage error; 3+ for documented business
conditions. Respect 126/127 and 128+N: a script that always exits 0 is undetectable from CI.
stdout for useful output, stderr for everything else: that separation is what makes the script
composable. Configuration: flag > environment > default; no paths or hostnames halfway through the code.
Idempotence, --dry-run, temporaries and concurrency
Every script that modifies something must be runnable twice without harm: check state before
acting ([[ -L $link ]], id -u "$user" &>/dev/null), use convergent operations (mkdir -p,
ln -sfn, install -D -m 0644) and treat "already as it should be" as success, not as an error.
--dry-run in every destructive script, with a single choke point — two different execution
paths (real and "simulated") always diverge:
run() { [[ "$DRY_RUN" == true ]] && { log info "DRY-RUN: $*"; return 0; }; "$@"; }
Configuration files: write to a temporary, validate with the service's checker (sshd -t,
nginx -t, visudo -c, systemd-analyze verify) and only then an atomic mv onto the target (same
filesystem). Keep a timestamped previous copy.
mktemp always (mktemp -d for directories). /tmp/$$, /tmp/fixed-name or $RANDOM are forbidden:
race condition and symlink escalation. Cleanup goes in the EXIT trap, never at the end of the
body — the script can die before getting there. One single trap … EXIT per script (the second replaces
the first): centralise it in cleanup().
flock for mutual exclusion in every script that can overlap with itself (timer, cron, webhook):
exec 9>"/run/lock/${SCRIPT_NAME}.lock" || die "cannot open the lock"
flock -n 9 || { log info "another instance running; exiting"; exit 0; }
With -n (fail fast) or -w <seconds>, never wait indefinitely; descriptor ≥ 9 and the file in
/run/lock or /run, not in a shared /tmp. Under systemd it is enough that the unit does not allow overlap.
Hand-written PID locks (echo $$ > /var/run/x.pid) are forbidden: they are not atomic and leave
orphans after a kill -9.
Logging, here-docs and pipelines
- Structured, stable format from the first version:
<ISO-8601 UTC timestamp> [level] script: message
to stderr; single-line JSON if the consumer is an aggregator. Levels error|warn|info|debug filtered
by LOG_LEVEL; -v raises the level, -q lowers it.
set -x is not logging: it is interactive debugging and it leaks secrets. Behind an explicit flag
(--debug) and with PS4='+ ${BASH_SOURCE[0]}:${LINENO}: ' so the trace is readable.
- Under systemd, writing to
stderr already means writing to the journal: do not reinvent rotation or your own files.
- Here-doc quoted (
<<'EOF') by default: no expansion, no surprises; unquoted only when
interpolation is wanted. Never build content that will be interpreted (SQL, another
script) via here-doc by concatenating unescaped variables: that is injection by another name — pass the data via arguments,
files or stdin.
- Process substitution (
< <(cmd), diff <(a) <(b)) so as not to lose variables in a subshell: the classic
cmd | while read that "forgets" what was assigned is solved with while read … < <(cmd). It is a pure bashism.
- Exit status in pipelines:
pipefail is almost always enough; if you need the code of each stage,
PIPESTATUS copied into an array immediately (it is overwritten by the next command).
Privileges
- The script does not run as root "because it is easier": check what it needs and fail clearly
(
[[ $EUID -eq 0 ]] || die "requires root") or, better, run unprivileged and elevate only the
specific commands with sudo (sudo -n when non-interactive, so it fails instead of hanging asking for a password)
and a sudoers rule scoped to exact commands. NOPASSWD: ALL for a script is forbidden.
- Drop privileges for the real work:
runuser -u <user> -- cmd or setpriv; under systemd,
User=/Group= in the unit — let the service manager do the work (see onprem-standards).
- SUID/SGID forbidden on shell scripts (an explicit rule of the Google Shell Style Guide): the shell
cannot be secured well enough. If privilege is needed: scoped
sudo or a compiled binary.
umask 077 before creating any file with sensitive content.
4. Quality and CI gates
In order of increasing cost; the first three break the build:
shfmt -d -i 2 -ci -sr — fails if any file is unformatted; shared config in
.editorconfig so editor and CI agree (shfmt -l already returns a non-zero status when it lists something).
shellcheck over every script, with --severity=style and .shellcheckrc in the repo; -x so
it follows source, -s bash/-s sh according to the actual shebang. Suppressions per line and justified
(# shellcheck disable=SC2016 # literal pattern for sed); real false positives (SC2016 in
sed/printf/find -exec sh -c) are suppressed case by case, never by lowering the project's severity.
- Syntax and startup:
bash -n over each file and running --help (must exit 0) in CI.
- Tests with
bats: happy path, failure of each external dependency (missing command, non-zero
or empty output), invalid arguments (does it exit 2 with a message?) and double execution (idempotence). Isolate with
PATH pointing at stubs; do not touch the real system. Every fixed bug leaves a regression test.
- Test on the target interpreter: if it is going to Debian stable or an Alpine image, it is run there in
CI.
${var,,} and associative arrays do not exist in dash, and bash 5.3 is not everywhere.
Definition of done for a script: it passes shfmt + shellcheck with no unjustified suppressions, it has usage(),
documented exit codes, cleanup via trap, tests for edges, and it has actually been run on the target.
5. Security
eval forbidden, with no practical exceptions: almost everything is solved with arrays
(cmd=(rsync -a "$src" "$dst"); "${cmd[@]}"), with declare -n or with a function. Same rule for its
cousins (bash -c "$var", source "$var", ssh host "$built_cmd", find -exec sh -c "…$var…"):
building commands by concatenating strings is command injection; use arrays and -- to separate data
from options. If there really is no alternative, it is justified in the PR and the input goes against an allowlist.
- Parsing the output of
ls is forbidden (and that of df, ps, stat in human-readable format): its format is not a
contract and it breaks with odd names and different locales. Use globs, find -print0 or stat --format.
curl | bash forbidden, both in the script and in the instructions you give the user: download to a file,
verify signature or checksum, review, run. Downloads with --fail --proto '=https' --tlsv1.2; never -k.
- Secrets: never in the code, in command arguments (visible in
ps to the whole machine) or in the
log. Via an environment variable injected by the secrets manager, via a 0600 file or via stdin; under
systemd, LoadCredential=. If the script dumps its configuration, redact them.
- Explicit
PATH at the start of privileged or boot scripts; do not trust the inherited one. Check
dependencies at startup (command -v jq >/dev/null || die "jq missing") instead of failing halfway.
- Validate all external input (arguments, environment, files, HTTP responses) against the expected pattern
before using it in a path, command or comparison; in paths, reject
.. and absolute ones where you expect
relative ones.
rm -rf with a variable: check first that it is not empty and that it points where you think
([[ -n "$dir" && "$dir" == /srv/app/* ]]). rm -rf "$PREFIX/" with an empty PREFIX has wiped out entire
production systems.
- Working files with minimal permissions (
umask 077, install -m 0600); never chmod 777 "just to test".
6. Operability
Scheduled execution: systemd units versus cron
- Default with systemd: a
Type=oneshot unit + .timer. Advantages over cron: journal with metadata,
OnFailure= to alert, resource limits and sandboxing, real dependencies, Persistent=true to
recover missed runs and RandomizedDelaySec= to spread out the fleet (if 50 machines renew
certificates at the same time, the problem is yours and your provider's). Validate before deploying with
systemd-analyze calendar '<expr>' and systemd-analyze verify.
cron only in containers without systemd, BSD/macOS or legacy; and then: explicit PATH, output to a
real log (not to local mail), mandatory flock and manual jitter.
- Verified Aug 2026: systemd 259 on Fedora 44, with support for SysV init scripts deprecated and
removal announced for v260 — migrate any
/etc/init.d/ of your own now. Unit design
(sandboxing, ProtectSystem=, DynamicUser=) is the subject of onprem-standards.
Signals and orderly shutdown
- Long-running script:
trap 'on_term' TERM INT to shut down in an orderly way (finish the work in
progress, release the lock, delete temporaries) and exit with 128+N.
- Children must die with the parent: save the PIDs and kill them in
cleanup, or run under systemd, which kills
the whole cgroup — that is the operational reason to prefer the unit. If shutting down takes time, raise it in
TimeoutStopSec; otherwise SIGKILL will arrive and leave the state half-done.
timeout on every network operation (timeout 30 curl …, curl --max-time); retries with backoff
only on idempotent operations and with a cap. An until loop with no limit is an incident with an open date.
Performance
- The dominant cost is forking: no five-process pipelines per line of file. Parameter
expansion (
${var%%…}, ${var/…/…}) instead of sed/cut for trivial things, and a single-pass awk where
you were going to put a loop. Never for line in $(cat f): while IFS= read -r line; do … done < f.
- bash 5.3 adds command substitution without forking (
${ cmd; } and ${| cmd; }, result in REPLY):
useful, but they break on 5.2 and earlier — only with a verified 5.3+ target.
- If performance really matters, the answer is in §7: it is not a problem of optimising bash, it is a
language problem.
7. Sustainability, rewrite threshold and prohibitions
REWRITE THRESHOLD
Hard rule from the Google Shell Style Guide: shell only for "small utilities or simple wrapper scripts", and
a script of more than 100 lines or with non-trivial control flow is rewritten in a more
structured language now — not "when there is time". This document adopts it and adds the triggers that in
practice arrive before the line counter does. One alone is enough:
- Data structures beyond flat and associative arrays: records, nesting, JSON that is manipulated
rather than just having a field extracted with
jq.
- Real error handling (distinguishing failure types, retrying by class, partially undoing) or
controlled concurrency:
wait -n and xargs -P cover the trivial case and nothing more.
- Parsing (not extracting): formats with escapes, CSV with quotes, HTML, output meant for humans.
- Unit tests of internal logic, not of a command's observable behaviour.
- More than a dozen options, subcommands or file-based configuration; or it is going to be a dependency of
other teams / distributed outside the machine where it was born.
- It has three incidents attributable to shell behaviour (quoting, globbing, exit code).
Destination: Python (python-standards) for automation with data, APIs and logic; Go
(go-standards) for a single binary with no dependencies on the target. Migrate in parts: the script
remains a thin wrapper while the logic moves out, and it is retired when it no longer adds anything.
Cadence and maintenance
- Pin the minimum bash version in the repo and check it in the script if you use modern features
(
(( BASH_VERSINFO[0] >= 5 )) || die "requires bash 5+"): cheaper than a silent failure on the
old server.
- Update ShellCheck and shfmt along with the rest of the tooling (at least twice a year) and pin the version in CI: each
release adds checks that find real bugs.
- Scripts expire: review annually which ones are not being run and delete them. A dead script in
PATH is
a trap.
FORBIDDEN
- ❌ A script without
set -euo pipefail (or without written justification), and unquoted expansions ($var,
${arr[*]}, bare $(cmd)).
- ❌
eval, bash -c "$var", uncontrolled source "$var", or commands built by concatenating strings.
- ❌ Parsing the output of
ls or of any human-oriented output to obtain file names.
- ❌
curl … | bash, downloads without verifying checksum/signature, or curl -k/--insecure.
- ❌ Predictable temporaries (
/tmp/foo, /tmp/$$) instead of mktemp; cleanup outside the EXIT trap.
- ❌ A script that can overlap with itself without
flock or equivalent; hand-written PID files.
- ❌ SUID/SGID on a shell script;
sudo with NOPASSWD: ALL for automation.
- ❌ Secrets in the code, in command arguments or in logs;
set -x without an explicit flag.
- ❌
rm -rf on a variable without checking that it is not empty and that it points to the expected prefix.
- ❌ Always exiting with
0; reusing 126, 127 or 128+N; mixing useful output and logs on stdout.
- ❌ A destructive script without
--dry-run, or with different execution paths for real and simulated.
- ❌ A network operation without a timeout, or retries with no cap and no backoff.
- ❌ Bashisms with a
#!/bin/sh shebang (and vice versa), or options in the shebang (#!/bin/bash -e): they are
ignored when invoking bash script, they go in set.
- ❌ New SysV init scripts in
/etc/init.d/ (deprecated, removal planned in systemd 260).
- ❌ Suppressing ShellCheck codes globally without a comment justifying it.
- ❌ Growing a script that has already crossed the rewrite threshold "because rewriting it takes time".
8. Mandatory web verification
Before pinning any version, flag or behaviour, look it up — do not recall it:
- bash version on the real target (not on your laptop) and its patch level:
ftp.gnu.org/gnu/bash/ and the
distro package. Verified Aug 2026: stable branch 5.3 (Jul 2025) with patches ongoing; Fedora 44
ships 5.3.x, Debian stable is still on 5.2.x. The funsubs ${ cmd; } are 5.3+.
- ShellCheck and shfmt (
github.com/mvdan/sh/releases), with their new codes and flags. Verified
Aug 2026: ShellCheck 0.11.0 (Aug 2025), shfmt v3.13.1 (Apr 2026, zsh since 3.13.0), bats-core
1.14.0 (Jul 2026). Distro packages lag behind: pin the version in CI.
- Google Shell Style Guide at
google.github.io/styleguide/shellguide.html: it is updated by commits,
with no dated editions; the old shell.xml is deprecated.
- POSIX if you write portable
sh: current edition POSIX.1-2024 (Issue 8), free text at
pubs.opengroup.org (Shell Command Language), with a first technical corrigendum in progress. Verify there
what is standard and what is a bashism, not from memory.
- systemd: version on the target and changes that affect you (verified Aug 2026: 259 on Fedora 44;
SysV deprecated with removal announced for 260, which raises the minimum dependencies). Check the repo's
NEWS
before using a recent directive.
- CVEs of the utilities you invoke with privileges (
sudo, util-linux/flock, tar, curl).
If the web contradicts this document, the web wins — flag the discrepancy.
1---2name: bash-linux-scripting-standards3description: Shell scripting and Linux automation standards. Use when writing or reviewing .sh/.bash/.zsh files, "#!/usr/bin/env bash" scripts, set -euo pipefail, shellcheck, shfmt, .shellcheckrc, .editorconfig shell rules, bats tests, getopts parsing, traps, mktemp, flock or POSIX sh portability.4---56# Shell scripting and Linux automation standards78Criteria verified as of **August 2026**. Re-verify on the web before committing to anything (§8).910## 1. Scope and triggers1112Applies when writing, reviewing or refactoring shell: `.sh`, `.bash`, `.zsh` files, executables with a shell13shebang, `~/.bashrc`/`~/.zshrc` functions turned into tools, deployment wrappers, boot and maintenance14scripts, and any automation glued together with `bash` that already has users. Triggers:15`set -euo pipefail`, `shellcheck`, `shfmt`, `.shellcheckrc`, `bats`, `getopts`, `trap`, `mktemp`, `flock`,16`IFS`, "POSIX portability", "dry-run", "the script has grown huge".1718Guiding principle: **bash is an excellent glue language and a terrible application language**. Its job is19to orchestrate processes and files; the moment data structures, concurrency, real parsing or business20logic show up, maintaining it costs more than rewriting it (§7, **rewrite threshold**). Write shell21assuming the script will live five years and someone will run it at 3 in the morning.2223**Not applicable**: see `onprem-standards` (platform umbrella: systemd unit design,24inventory, fleet patching cadence — here only the script and its minimal wrapper),25`linux-hardening-standards` (CIS/STIG baseline, `sysctl`, auditd, sudoers, unit sandboxing26as a security control — here only script hygiene: `eval`, quoting, temporaries, `flock`),27`selinux-standards` (if the script fails because of an AVC denial, the diagnosis is there), `cicd-standards` (scripts embedded in28pipelines, runners, CI secrets and build gates), `python-standards` and `go-standards` (the natural29destination once the §7 threshold is crossed: Python for data-driven automation, Go for distributable30binaries with no dependencies), `ruby-standards` and `perl-standards` (alternative destination31for the growing script **only if the team already maintains them** — for new code, the default32destination is still Python or Go), `powershell-standards` (**reciprocal shell-choice33boundary**: in a Windows or mixed environment with AD, PowerShell wins —objects instead of text, without34POSIX's quoting and *word splitting* problem—; on Linux and in any script that must run35on a minimal system with no added dependencies, this skill wins. **Cross-platform is not a36sufficient argument for choosing PowerShell** if the real target is Linux only; it is if Windows37has to be administered).3839## 2. Toolchain4041> Verify the latest version on the web before pinning it in a real project (§8).4243| Tool | Default | Reason / note |44|---|---|---|45| Interpreter | **bash 5.x**, shebang `#!/usr/bin/env bash` | 5.3 is the stable branch (Jul 2025; patches ongoing). Verified Aug 2026: Fedora 44 ships 5.3.x; **Debian stable is still on 5.2.x** → do not assume 5.3 on third-party targets |46| Strict mode | `set -Eeuo pipefail` + `IFS=$'\n\t'` + `shopt -s inherit_errexit` | With its known limits (§3); `-E` is essential for the ERR trap to work inside functions |47| Linter | **ShellCheck 0.11.x** (0.11.0, Aug 2025) with `.shellcheckrc` in the repo | A CI gate, not a suggestion. Recommended by the Google Shell Style Guide itself "for all your scripts, large or small" |48| Formatter | **shfmt 3.13.x** (3.13.1, Apr 2026) | Configured via `.editorconfig`; supports POSIX sh, bash, mksh and **zsh since 3.13.0** |49| Tests | **bats-core 1.14.x** (Jul 2026) | With `bats-support`/`bats-assert`; alternative: `shunit2` in environments that already use it |50| Style guide | **Google Shell Style Guide** (`google.github.io/styleguide/shellguide.html`) | The old `shell.xml` is deprecated. It is the reference with the most traction; its hard rules are captured here |51| Dialect | **bash exclusively**, unless portability is an explicit requirement | Google: restricting yourself to bash "means there is generally no need to look for POSIX compatibility or to avoid bashisms" |52| POSIX sh | `#!/bin/sh` + `shellcheck -s sh` **only** if the target demands it (initramfs, BusyBox, Alpine, `dash`, distroless images, multi-OS installers) | Current standard: **POSIX.1-2024 / Issue 8** (free text at `pubs.opengroup.org`) |53| Running scheduled tasks | systemd unit + timer (§6) | `cron` only in containers without systemd, BSD/macOS or legacy |5455## 3. Structure and conventions5657### Mandatory skeleton5859```bash60#!/usr/bin/env bash61# What it does, what it assumes and who maintains it. One line, not a novel.62set -Eeuo pipefail63shopt -s inherit_errexit # errexit also inside $( ); bash >= 4.464IFS=$'\n\t'65SCRIPT_NAME="${0##*/}"66SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"67readonly SCRIPT_NAME SCRIPT_DIR # separate from the assignment: SC215568: "${LOG_LEVEL:=info}" # configurable via the environment, with a default69DRY_RUN=false70WORKDIR=''71log() { printf '%s [%s] %s: %s\n' "$(date -u +%FT%TZ)" "$1" "$SCRIPT_NAME" "${*:2}" >&2; }72die() { log error "$*"; exit 1; }73cleanup() { local rc=$?; [[ -d "$WORKDIR" ]] && rm -rf -- "$WORKDIR"; exit "$rc"; }74trap cleanup EXIT75trap 'log error "failure in ${BASH_SOURCE[0]}:${LINENO} -> ${BASH_COMMAND}"' ERR7677main() {78 WORKDIR="$(mktemp -d)" # cleanup guaranteed by the EXIT trap79 ...80}81main "$@"82```8384- **`main "$@"` at the end**: makes the file *sourceable* for tests and prevents partial execution if the85 download or the edit is truncated halfway through.86- Executables in `PATH` **without an extension**; **libraries with `.sh` and without the execute bit** (Google Shell87 Style Guide). `snake_case` for functions and locals, `UPPERCASE` only for constants and the environment.88- `local` for every variable inside functions, no exceptions; `readonly` for constants.8990### Quoting, word splitting and globbing9192- **Double-quote every expansion** (`"$var"`, `"${arr[@]}"`, `"$(cmd)"`), with the exception justified in a93 comment; `"${arr[@]}"` never `${arr[*]}` for lists. `IFS=$'\n\t'` reduces splitting, **it does not replace94 quoting**.95- `--` before positionals (`rm -rf -- "$dir"`) and `./` in front of globs (`rm -- ./*.log`): a file96 named `-rf` is a trivial exploit. To walk files, `find -print0` +97 `while IFS= read -r -d ''`, or globs with `shopt -s nullglob`; names with spaces, newlines and UTF-898 are the norm, not a rare case.99- `[[ … ]]` preferred over `[`/`test` (no word splitting or globbing, with `=~`); arithmetic with `(( … ))`.100 They do not exist in POSIX sh: there it is `[ … ]` with everything quoted.101102### Arguments, help and exit codes103104- Parsing with **`getopts`** (built-in and portable). If long options, repeatable flags and105 subcommands are needed, that complexity **is already a threshold signal** (§7); if it stays in bash anyway, a106 `while [[ $# -gt 0 ]]` loop with `case`, without `getopt(1)` (divergent behaviour across systems).107- `usage()` is mandatory: synopsis, options, environment variables it reads, exit codes and **a real108 example**. To `stdout` with `exit 0` when requested with `-h`; to `stderr` with `exit 2` on a usage error.109- Exit codes: `0` success; `1` generic failure; **`2` usage error**; `3+` for documented business110 conditions. Respect `126`/`127` and `128+N`: a script that always exits `0` is undetectable from CI.111- **`stdout` for useful output, `stderr` for everything else**: that separation is what makes the script112 composable. Configuration: flag > environment > default; no paths or hostnames halfway through the code.113114### Idempotence, `--dry-run`, temporaries and concurrency115116- **Every script that modifies something must be runnable twice without harm**: check state before117 acting (`[[ -L $link ]]`, `id -u "$user" &>/dev/null`), use convergent operations (`mkdir -p`,118 `ln -sfn`, `install -D -m 0644`) and treat "already as it should be" as success, not as an error.119- **`--dry-run` in every destructive script**, with a single choke point — two different execution120 paths (real and "simulated") always diverge:121122 ```bash123 run() { [[ "$DRY_RUN" == true ]] && { log info "DRY-RUN: $*"; return 0; }; "$@"; }124 ```125- Configuration files: write to a temporary, **validate** with the service's checker (`sshd -t`,126 `nginx -t`, `visudo -c`, `systemd-analyze verify`) and only then an atomic `mv` onto the target (same127 filesystem). Keep a timestamped previous copy.128- **`mktemp` always** (`mktemp -d` for directories). `/tmp/$$`, `/tmp/fixed-name` or `$RANDOM` are forbidden:129 race condition and symlink escalation. Cleanup goes **in the EXIT trap**, never at the end of the130 body — the script can die before getting there. One single `trap … EXIT` per script (the second replaces131 the first): centralise it in `cleanup()`.132- **`flock` for mutual exclusion** in every script that can overlap with itself (timer, cron, webhook):133134 ```bash135 exec 9>"/run/lock/${SCRIPT_NAME}.lock" || die "cannot open the lock"136 flock -n 9 || { log info "another instance running; exiting"; exit 0; }137 ```138 With `-n` (fail fast) or `-w <seconds>`, **never** wait indefinitely; descriptor ≥ 9 and the file in139 `/run/lock` or `/run`, not in a shared `/tmp`. Under systemd it is enough that the unit does not allow overlap.140 Hand-written PID locks (`echo $$ > /var/run/x.pid`) are **forbidden**: they are not atomic and leave141 orphans after a `kill -9`.142143### Logging, here-docs and pipelines144145- Structured, stable format from the first version: `<ISO-8601 UTC timestamp> [level] script: message`146 to `stderr`; single-line JSON if the consumer is an aggregator. Levels `error|warn|info|debug` filtered147 by `LOG_LEVEL`; `-v` raises the level, `-q` lowers it.148- `set -x` **is not logging**: it is interactive debugging and **it leaks secrets**. Behind an explicit flag149 (`--debug`) and with `PS4='+ ${BASH_SOURCE[0]}:${LINENO}: '` so the trace is readable.150- Under systemd, writing to `stderr` already means writing to the journal: do not reinvent rotation or your own files.151- Here-doc **quoted** (`<<'EOF'`) by default: no expansion, no surprises; unquoted only when152 interpolation is wanted. **Never** build content that will be interpreted (SQL, another153 script) via here-doc by concatenating unescaped variables: that is injection by another name — pass the data via arguments,154 files or `stdin`.155- Process substitution (`< <(cmd)`, `diff <(a) <(b)`) so as not to lose variables in a subshell: the classic156 `cmd | while read` that "forgets" what was assigned is solved with `while read … < <(cmd)`. It is a pure bashism.157- Exit status in pipelines: `pipefail` is almost always enough; if you need the code of each stage,158 `PIPESTATUS` copied into an array **immediately** (it is overwritten by the next command).159160### Privileges161162- **The script does not run as root "because it is easier"**: check what it needs and fail clearly163 (`[[ $EUID -eq 0 ]] || die "requires root"`) or, better, run unprivileged and elevate only the164 specific commands with `sudo` (`sudo -n` when non-interactive, so it fails instead of hanging asking for a password)165 and a `sudoers` rule scoped to exact commands. `NOPASSWD: ALL` for a script is forbidden.166- Drop privileges for the real work: `runuser -u <user> -- cmd` or `setpriv`; under systemd,167 `User=`/`Group=` in the unit — let the service manager do the work (see `onprem-standards`).168- **SUID/SGID forbidden on shell scripts** (an explicit rule of the Google Shell Style Guide): the shell169 cannot be secured well enough. If privilege is needed: scoped `sudo` or a compiled binary.170- `umask 077` before creating any file with sensitive content.171172## 4. Quality and CI gates173174In order of increasing cost; the first three break the build:1751761. **`shfmt -d -i 2 -ci -sr`** — fails if any file is unformatted; shared config in177 `.editorconfig` so editor and CI agree (`shfmt -l` already returns a non-zero status when it lists something).1782. **`shellcheck` over every script**, with `--severity=style` and `.shellcheckrc` in the repo; `-x` so179 it follows `source`, `-s bash`/`-s sh` according to the actual shebang. Suppressions **per line and justified**180 (`# shellcheck disable=SC2016 # literal pattern for sed`); real false positives (SC2016 in181 `sed`/`printf`/`find -exec sh -c`) are suppressed case by case, never by lowering the project's severity.1823. **Syntax and startup**: `bash -n` over each file and running `--help` (must exit `0`) in CI.1834. **Tests with `bats`**: happy path, **failure of each external dependency** (missing command, non-zero184 or empty output), invalid arguments (does it exit `2` with a message?) and **double execution** (idempotence). Isolate with185 `PATH` pointing at *stubs*; do not touch the real system. Every fixed bug leaves a regression test.1865. **Test on the target interpreter**: if it is going to Debian stable or an Alpine image, it is run there in187 CI. `${var,,}` and associative arrays do not exist in `dash`, and bash 5.3 is not everywhere.188189Definition of *done* for a script: it passes shfmt + shellcheck with no unjustified suppressions, it has `usage()`,190documented exit codes, cleanup via trap, tests for edges, and it has actually been run on the target.191192## 5. Security193194- **`eval` forbidden**, with no practical exceptions: almost everything is solved with arrays195 (`cmd=(rsync -a "$src" "$dst"); "${cmd[@]}"`), with `declare -n` or with a function. Same rule for its196 cousins (`bash -c "$var"`, `source "$var"`, `ssh host "$built_cmd"`, `find -exec sh -c "…$var…"`):197 building commands by concatenating strings **is command injection**; use arrays and `--` to separate data198 from options. If there really is no alternative, it is justified in the PR and the input goes against an allowlist.199- **Parsing the output of `ls` is forbidden** (and that of `df`, `ps`, `stat` in human-readable format): its format is not a200 contract and it breaks with odd names and different locales. Use globs, `find -print0` or `stat --format`.201- **`curl | bash` forbidden**, both in the script and in the instructions you give the user: download to a file,202 verify signature or checksum, review, run. Downloads with `--fail --proto '=https' --tlsv1.2`; never `-k`.203- **Secrets**: never in the code, in command arguments (visible in `ps` to the whole machine) or in the204 log. Via an environment variable injected by the secrets manager, via a `0600` file or via `stdin`; under205 systemd, `LoadCredential=`. If the script dumps its configuration, redact them.206- Explicit `PATH` at the start of privileged or boot scripts; do not trust the inherited one. Check207 dependencies at startup (`command -v jq >/dev/null || die "jq missing"`) instead of failing halfway.208- Validate all external input (arguments, environment, files, HTTP responses) against the expected pattern209 before using it in a path, command or comparison; in paths, reject `..` and absolute ones where you expect210 relative ones.211- `rm -rf` with a variable: check first that it is **not empty** and that it points where you think212 (`[[ -n "$dir" && "$dir" == /srv/app/* ]]`). `rm -rf "$PREFIX/"` with an empty `PREFIX` has wiped out entire213 production systems.214- Working files with minimal permissions (`umask 077`, `install -m 0600`); never `chmod 777` "just to test".215216## 6. Operability217218### Scheduled execution: systemd units versus cron219220- **Default with systemd**: a `Type=oneshot` unit + `.timer`. Advantages over cron: journal with metadata,221 `OnFailure=` to alert, resource limits and sandboxing, real dependencies, `Persistent=true` to222 recover missed runs and **`RandomizedDelaySec=`** to spread out the fleet (if 50 machines renew223 certificates at the same time, the problem is yours and your provider's). Validate before deploying with224 `systemd-analyze calendar '<expr>'` and `systemd-analyze verify`.225- **`cron` only** in containers without systemd, BSD/macOS or legacy; and then: explicit `PATH`, output to a226 real log (not to local mail), mandatory `flock` and manual jitter.227- Verified Aug 2026: **systemd 259** on Fedora 44, with support for SysV init scripts **deprecated and228 removal announced for v260** — migrate any `/etc/init.d/` of your own now. Unit design229 (sandboxing, `ProtectSystem=`, `DynamicUser=`) is the subject of `onprem-standards`.230231### Signals and orderly shutdown232233- Long-running script: `trap 'on_term' TERM INT` to shut down in an orderly way (finish the work in234 progress, release the lock, delete temporaries) and exit with `128+N`.235- Children must die with the parent: save the PIDs and kill them in `cleanup`, or run under systemd, which kills236 the whole cgroup — that is the operational reason to prefer the unit. If shutting down takes time, raise it in237 `TimeoutStopSec`; otherwise `SIGKILL` will arrive and leave the state half-done.238- **`timeout` on every network operation** (`timeout 30 curl …`, `curl --max-time`); retries with backoff239 only on idempotent operations and with a cap. An `until` loop with no limit is an incident with an open date.240241### Performance242243- The dominant cost is **forking**: no five-process pipelines per line of file. Parameter244 expansion (`${var%%…}`, `${var/…/…}`) instead of `sed`/`cut` for trivial things, and a single-pass `awk` where245 you were going to put a loop. Never `for line in $(cat f)`: `while IFS= read -r line; do … done < f`.246- bash 5.3 adds command substitution **without forking** (`${ cmd; }` and `${| cmd; }`, result in `REPLY`):247 useful, but **they break on 5.2 and earlier** — only with a verified 5.3+ target.248- If performance really matters, the answer is in §7: it is not a problem of optimising bash, it is a249 language problem.250251## 7. Sustainability, rewrite threshold and prohibitions252253### REWRITE THRESHOLD254255Hard rule from the Google Shell Style Guide: shell only for *"small utilities or simple wrapper scripts"*, and256a script of more than **100 lines or with non-trivial control flow is rewritten in a more257structured language *now*** — not "when there is time". This document adopts it and adds the triggers that in258practice arrive before the line counter does. **One alone is enough**:259260- **Data structures** beyond flat and associative arrays: records, nesting, JSON that is manipulated261 rather than just having a field extracted with `jq`.262- **Real error handling** (distinguishing failure types, retrying by class, partially undoing) or263 controlled **concurrency**: `wait -n` and `xargs -P` cover the trivial case and nothing more.264- **Parsing** (not extracting): formats with escapes, CSV with quotes, HTML, output meant for humans.265- **Unit tests of internal logic**, not of a command's observable behaviour.266- More than **a dozen options**, subcommands or file-based configuration; or it is going to be a **dependency of267 other teams** / distributed outside the machine where it was born.268- It has **three incidents** attributable to shell behaviour (quoting, globbing, exit code).269270Destination: **Python** (`python-standards`) for automation with data, APIs and logic; **Go**271(`go-standards`) for a single binary with no dependencies on the target. Migrate in parts: the script272remains a thin wrapper while the logic moves out, and it is retired when it no longer adds anything.273274### Cadence and maintenance275276- Pin the minimum bash version in the repo and **check it in the script** if you use modern features277 (`(( BASH_VERSINFO[0] >= 5 )) || die "requires bash 5+"`): cheaper than a silent failure on the278 old server.279- Update ShellCheck and shfmt along with the rest of the tooling (at least twice a year) and **pin the version in CI**: each280 release adds checks that find real bugs.281- Scripts expire: review annually which ones are not being run and delete them. A dead script in `PATH` is282 a trap.283284**FORBIDDEN**285- ❌ A script without `set -euo pipefail` (or without written justification), and unquoted expansions (`$var`,286 `${arr[*]}`, bare `$(cmd)`).287- ❌ `eval`, `bash -c "$var"`, uncontrolled `source "$var"`, or commands built by concatenating strings.288- ❌ Parsing the output of `ls` or of any human-oriented output to obtain file names.289- ❌ `curl … | bash`, downloads without verifying checksum/signature, or `curl -k`/`--insecure`.290- ❌ Predictable temporaries (`/tmp/foo`, `/tmp/$$`) instead of `mktemp`; cleanup outside the EXIT trap.291- ❌ A script that can overlap with itself without `flock` or equivalent; hand-written PID files.292- ❌ SUID/SGID on a shell script; `sudo` with `NOPASSWD: ALL` for automation.293- ❌ Secrets in the code, in command arguments or in logs; `set -x` without an explicit flag.294- ❌ `rm -rf` on a variable without checking that it is not empty and that it points to the expected prefix.295- ❌ Always exiting with `0`; reusing `126`, `127` or `128+N`; mixing useful output and logs on `stdout`.296- ❌ A destructive script without `--dry-run`, or with different execution paths for real and simulated.297- ❌ A network operation without a timeout, or retries with no cap and no backoff.298- ❌ Bashisms with a `#!/bin/sh` shebang (and vice versa), or options in the shebang (`#!/bin/bash -e`): they are299 ignored when invoking `bash script`, they go in `set`.300- ❌ New SysV init scripts in `/etc/init.d/` (deprecated, removal planned in systemd 260).301- ❌ Suppressing ShellCheck codes globally without a comment justifying it.302- ❌ Growing a script that has already crossed the rewrite threshold "because rewriting it takes time".303304## 8. Mandatory web verification305306Before pinning any version, flag or behaviour, **look it up — do not recall it**:3073081. **bash version on the real target** (not on your laptop) and its patch level: `ftp.gnu.org/gnu/bash/` and the309 distro package. Verified Aug 2026: stable branch **5.3** (Jul 2025) with patches ongoing; Fedora 44310 ships 5.3.x, **Debian stable is still on 5.2.x**. The funsubs `${ cmd; }` are 5.3+.3112. **ShellCheck** and **shfmt** (`github.com/mvdan/sh/releases`), with their new codes and flags. Verified312 Aug 2026: ShellCheck **0.11.0** (Aug 2025), shfmt **v3.13.1** (Apr 2026, zsh since 3.13.0), bats-core313 **1.14.0** (Jul 2026). Distro packages lag behind: pin the version in CI.3143. **Google Shell Style Guide** at `google.github.io/styleguide/shellguide.html`: it is updated by commits,315 with no dated editions; the old `shell.xml` is deprecated.3164. **POSIX** if you write portable `sh`: current edition **POSIX.1-2024 (Issue 8)**, free text at317 `pubs.opengroup.org` (Shell Command Language), with a first technical corrigendum in progress. Verify there318 what is standard and what is a bashism, not from memory.3195. **systemd**: version on the target and changes that affect you (verified Aug 2026: **259** on Fedora 44;320 SysV deprecated with removal announced for **260**, which raises the minimum dependencies). Check the repo's `NEWS`321 before using a recent directive.3226. **CVEs** of the utilities you invoke with privileges (`sudo`, `util-linux`/`flock`, `tar`, `curl`).323324If the web contradicts this document, **the web wins** — flag the discrepancy.