Most shell scripts fail in production for a boring reason: an unquoted variable that word-split on a path with a space, or a glob that expanded when nobody intended it to. A linter catches that entire class of bug in milliseconds. There is no good reason to let it reach main.
Shell is the one language most teams write without a safety net. Application code goes through a type checker, a linter, a test suite and review. The deploy script, the backup cron, the CI helper — the code that actually touches your production estate — gets eyeballed once and merged. That is backwards. ShellCheck and shfmt turn the gap into a gate: one static-analysis pass that rejects unsafe patterns, and one formatter that ends the tab-versus-spaces argument for good. Wired into a pre-commit setup that actually runs and mirrored in CI, they stop the bug before the commit exists.
What actually goes wrong in shell
ShellCheck’s value is that it knows the failure modes you do not. The recurring ones are dull and expensive:
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
- Unquoted variables (SC2086).
rm -rf $dir/where$diris empty becomesrm -rf /. Where it contains a space, it deletes the wrong things. Quoting fixes it; the linter flags every instance. - Unchecked
cd(SC2164).cd "$build" && rm -rf ./*is safe;cd "$build"; rm -rf ./*runs the delete in whatever directory you were already in when thecdsilently failed. - Masked exit codes (SC2181, SC2312).
local out=$(some_command)throws away the command’s return status, so your error handling never fires. - Word-split command output. Iterating
for f in $(ls)breaks on any filename with a space. ShellCheck points you at the array orfind -print0alternative.
None of these is exotic. All of them ship regularly because shell tolerates them at parse time and only bites at run time, usually in the middle of a deploy. A gate like this is one of the cheapest ways to move your change-failure rate in the right direction, because the failures it prevents are precisely the ones that surface in production rather than review.
The .pre-commit-config.yaml
Two hooks, both pinned to a specific release so every developer and the CI runner get identical behaviour. Drop this into any repo:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1 # bundles ShellCheck 0.10.0
hooks:
- id: shellcheck
args: [--severity=warning, --external-sources]
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.12.0-1 # bundles shfmt 3.12.0
hooks:
- id: shfmt
args: [-w, -i, "2", -ci, -bn]
The shellcheck-py hook downloads a pre-built ShellCheck binary, so there is no system dependency to manage. --external-sources lets it follow source and . directives into sibling files rather than warning that it cannot find them. The shfmt flags mean: write in place (-w), indent with two spaces (-i 2), indent switch cases (-ci) and put binary operators at the start of a continued line (-bn). If you would rather keep formatting rules in .editorconfig, drop the shfmt args entirely and it will read them from there.
Then the usual two commands — pre-commit install to wire the git hook, and pre-commit run --all-files once to see the backlog on the existing scripts.
Choosing a severity level
ShellCheck grades findings as error, warning, info or style, and --severity sets the floor at which the run fails. The default is style, which reports everything. On a fresh repo that is fine. On an existing codebase with hundreds of scripts, opening the gate at style produces a wall of pedantic noise that the team learns to ignore — which defeats the point.
Start at --severity=warning. That catches the genuinely dangerous patterns — the unquoted variables, the masked exit codes, the rm traps — without arguing about whether a read should have a -r. Once the codebase is clean at that level, tighten to info as a deliberate, separate change. Ratcheting the severity down over time is far more effective than starting strict and watching people disable the whole hook out of frustration.
Suppressing false positives honestly
ShellCheck is occasionally wrong, or right in a way you have deliberately chosen to override. The correct response is an inline directive with a justification, not switching the check off. The disable directive applies to the next line:
# FLAGS holds several arguments; word-splitting here is intentional
# shellcheck disable=SC2086
rsync $FLAGS "$src" "$dest"
The rule is: every disable carries a comment explaining why, and it names the specific code (SC2086), never a blanket suppression. A directive placed at the top of the file, just after the shebang, applies to the whole script — useful for sourced libraries where a warning is structurally unavoidable, dangerous everywhere else. In review, an unexplained disable is a finding in its own right. The suppression is part of the record of a decision someone made, which is exactly the discipline you want the gate to enforce rather than erode.
The CI job that makes it a gate
A local hook is a convenience; a developer can always commit with --no-verify. The gate is the CI job that runs the same hooks on the pull request and blocks the merge. Reuse the exact config so there is no drift between what runs on the laptop and what runs on the branch:
# .github/workflows/shell-lint.yml
name: shell-lint
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: pre-commit/action@v3.0.1
Because shfmt runs with -w, it reformats any badly-laid-out file in the runner’s checkout. pre-commit then exits non-zero because a hook modified a tracked file, and the job fails — the reformatting never merges, but the pull request is blocked until the author runs the formatter themselves. ShellCheck fails the job directly on any finding at or above your severity floor. Make this workflow a required status check in branch protection and the gate is real: unquoted variables and dangerous rm patterns cannot reach main, whatever anyone does with --no-verify locally.
Shell is where the sharpest edges of your infrastructure live, and it is the code least likely to have a test. A linter and a formatter are the cheapest controls you will ever add, and the one place a two-line config genuinely stops the delete that takes out production.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.