Building an Onboarding Health-Check Script
A teammate runs make bootstrap, it fails with an opaque Docker error, and they have no idea whether the cause is an old Node, a busy port, a stopped daemon, or a missing env var. A scripts/doctor.sh health check turns that guessing game into one actionable report, completing the README-driven automation loop that pairs with a one-command make bootstrap target.
Diagnostic
The symptom is a setup failure with no signal about which precondition broke. Confirm the gap — there is no single command that inventories the environment:
#!/usr/bin/env bash
set -euo pipefail
[ -x scripts/doctor.sh ] || echo "BAD: no scripts/doctor.sh health check present"
Expected BAD output when the check is missing:
BAD: no scripts/doctor.sh health check present
Without it, a developer sees only the downstream failure — for example Error response from daemon: Ports are not available: 0.0.0.0:5432 — and cannot tell that the real problem is a local Postgres already holding 5432. The same class of message appears when a port is already allocated in Compose, when a Docker Desktop VM has not finished booting, or when a .env written for an older schema is missing a newly required key. Every one of those root causes is invisible in the surface error, so the new contributor pings the team channel and waits. Each such interruption costs a maintainer a context switch and the newcomer half a morning, which is exactly the friction a health check is meant to erase.
The deeper failure is that the knowledge of "what a healthy machine looks like" lives only in senior engineers' heads. When a build breaks, they mentally run a checklist — is Docker up, is the port free, is the Node version current — and reach a diagnosis in seconds. A doctor script is that checklist written down and made executable so anyone can run it.
Root cause
Bootstrap failures are confusing because the failing command is several steps removed from the actual unmet precondition. docker compose up fails on a busy port, but the message blames Docker, not the conflicting process; a build fails on an old toolchain, but the error is a cryptic native-module compile. Each precondition — tool version, free port, running daemon, present env var — has a clear, cheap test, but nobody runs all of them up front. A doctor script runs every test, collects failures instead of aborting on the first, and prints a remediation line per failure, so the developer fixes causes rather than chasing symptoms.
The collect-don't-abort behaviour matters more than it looks. A script that exits on the first failure forces the reader through a slow fix-run-fail-fix cycle: repair the Node version, re-run, discover the port conflict, repair that, re-run, discover the missing env var. Four round trips for three problems. Accumulating every failure into a single report collapses that into one pass, so the contributor fixes the whole batch before running make bootstrap again. The script's job is not to fix the environment — it is to give a complete, ranked list of what stands between the developer and a working stack.
Resolution
- Start with strict mode and accumulate failures in a counter so one bad check does not hide the rest.
- Compare each tool's version against a pinned floor, not mere presence.
- Confirm the Docker daemon is reachable, not just installed.
- Probe each required port and name the process holding it.
- Diff
.envkeys against.env.exampleso missing config surfaces early. - Exit non-zero with a summary so CI and humans both get a clear verdict.
#!/usr/bin/env bash
# scripts/doctor.sh — onboarding health check
set -euo pipefail
FAIL=0
note() { printf ' - %s\n' "$1"; FAIL=$((FAIL + 1)); }
# 1. Tool versions against pinned floors.
require_version() {
local bin=$1 min=$2 got
if ! command -v "$bin" >/dev/null 2>&1; then note "$bin not installed (need >= $min)"; return; fi
got=$("$bin" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
if [ "$(printf '%s\n%s\n' "$min" "$got" | sort -V | head -1)" != "$min" ]; then
note "$bin $got is older than required $min"
fi
}
echo "Checking tools..."
require_version docker 24.0
require_version jq 1.6
# 2. Docker daemon reachable.
echo "Checking Docker daemon..."
docker info >/dev/null 2>&1 || note "Docker daemon not reachable — start Docker Desktop or 'systemctl start docker'"
# 3. Required ports free.
echo "Checking ports..."
for port in 3000 5432; do
if lsof -iTCP:"$port" -sTCP:LISTEN -P -n >/dev/null 2>&1; then
pid=$(lsof -tiTCP:"$port" -sTCP:LISTEN | head -1)
note "port $port in use by PID $pid ($(ps -p "$pid" -o comm= 2>/dev/null || echo unknown)) — stop it or remap"
fi
done
# 4. Required env vars present.
echo "Checking env contract..."
if [ -f .env.example ] && [ -f .env ]; then
while IFS= read -r key; do
[ -z "$key" ] && continue
grep -qE "^${key}=" .env || note "missing env var '$key' in .env (declared in .env.example)"
done < <(grep -vE '^\s*#|^\s*$' .env.example | cut -d= -f1)
else
note ".env or .env.example missing — run 'make env'"
fi
# 5. Verdict.
if [ "$FAIL" -eq 0 ]; then
echo "doctor: environment healthy"
else
echo "doctor: $FAIL issue(s) found — fix the items above and re-run"
exit 1
fi
A few implementation choices are worth calling out. The note helper is the whole design in three lines: it prints an indented, human-readable line and bumps a shared FAIL counter, so every check body is just "test the precondition, call note on failure." Because note never returns non-zero, set -e does not abort the script when a check fails — only an unexpected command error stops the run, which is the behaviour you want. The version comparison uses sort -V (version sort) rather than a string or numeric compare so that 1.10 correctly sorts above 1.9; a naïve [ "$got" \< "$min" ] would rank 1.10 as older and raise a false alarm. The port loop resolves the holding PID with lsof -t and then maps it to a process name with ps, because "port 5432 is busy" is far less useful than "port 5432 in use by PID 8123 (postgres)."
Extending the script stays cheap because every check follows the same shape: run a test, and on failure call note with a message that names the observed state and the fix. To add a disk-space guard you would compare df output against a floor and note when free space is low; to add a required binary you would call require_version with a new floor. Resist the urge to make checks clever — a health check that itself fails on an edge case is worse than no check, so keep each test to a single command with a clear exit code and let note carry the nuance in prose. When a check genuinely needs multiple steps, wrap it in a named function so the top-level flow reads as a checklist rather than a wall of shell.
The env-contract check treats .env.example as the source of truth for which keys must exist, deliberately ignoring values — a value check would leak secrets into logs and produce noisy diffs. If you also need to validate that values are non-empty or well-typed, keep that in a dedicated step so the doctor stays a fast structural check; the deeper validation belongs with catching missing env vars before container startup. Order the checks cheapest-first: tool presence and version cost milliseconds, docker info may block for a second while it reaches the daemon socket, and the port probes are the slowest because lsof walks the file-descriptor table. Running the fast checks first means a broken machine surfaces its most common failures almost instantly.
Wire it into the Makefile so make doctor is the documented entry point:
.PHONY: doctor
doctor: ## Diagnose a broken local environment
@./scripts/doctor.sh
Expected output
A healthy workstation prints a clean pass:
$ make doctor
Checking tools...
Checking Docker daemon...
Checking ports...
Checking env contract...
doctor: environment healthy
A broken one names each cause and its fix, then exits non-zero:
$ make doctor
Checking tools...
- jq 1.5 is older than required 1.6
Checking Docker daemon...
Checking ports...
- port 5432 in use by PID 8123 (postgres) — stop it or remap
Checking env contract...
- missing env var 'DATABASE_URL' in .env (declared in .env.example)
doctor: 3 issue(s) found — fix the items above and re-run
Read the two runs as a contract. The section headers (Checking tools...) always print, so the reader can see how far the script got even if it exits early on an unexpected error. Every failure line is indented and self-contained: it names the failing precondition, the observed value, and the remediation, which means a contributor can act on it without reading the script. The final doctor: line is the machine-readable verdict — a count and a non-zero exit — that lets make bootstrap, a pre-commit hook, or a CI job branch on the result. Keeping the human report and the exit code aligned is what makes the same script usable by both people and pipelines.
One subtlety worth planning for is how strict mode interacts with the report. Under set -euo pipefail, any command that returns non-zero outside a guarded context aborts the whole script, which would swallow the summary. That is why every check either ends in || note "…" or lives inside an if; the counter increments, the shell keeps running, and the verdict still prints. If you add a check that pipes through a command which can legitimately fail — grep returning 1 on no match, for instance — guard it the same way rather than relying on the pipeline's exit status. Test the script deliberately on a known-broken machine before you ship it, because a doctor that aborts silently on its own second check is worse than none: it reports one problem, exits, and sends the contributor back to the maintainer for the rest.
The measurable win is time-to-diagnosis. Before a doctor script, a stuck contributor greps logs, searches the error string, and eventually asks a maintainer; after it, one command names the cause. The pattern below is drawn from onboarding sessions where the same three-fault machine was handed to developers with and without the script.
Prevention
- Run
make doctoras the last step ofmake bootstrapso a successful setup is also a verified one. - Execute
scripts/doctor.shin CI against a clean checkout to keep the env contract honest, alongside catching missing env vars before container startup. - Add new ports and tools to the script in the same commit that introduces them — gate this with the drift workflow in README-driven automation.
Treat the script itself as code that can rot. The most common failure mode for a health check is silent staleness: a service moves from port 3000 to 3001, the doctor still probes 3000, and it passes on a machine that will not boot. Guard against that by running the doctor in CI against a clean checkout — a container with none of your dotfiles or globally installed tools — so the script's assumptions are re-tested on every push. When CI's doctor passes but a new hire's fails, the difference is almost always an undocumented global dependency, which is exactly the drift you want the script to catch. Reviewing doctor changes in the same pull request that adds a port or tool keeps the checklist and the stack in lockstep, and it makes the health check the canonical answer to "what does this project need to run," complementing the human-readable runtime parity checks between local and staging.
Platform caveats
macOS (Docker Desktop):
lsofandpsship by default;sort -Vfor version comparison requires GNU coreutils — install withbrew install coreutilsand prefergsort, or the floor check may misorder versions. WSL2:lsofdoes not see processes bound on the Windows side; a port held by a Windows app shows as free here. Cross-check withnetsh interface portproxy show allfrom PowerShell. Apple Silicon (ARM64): no behavioral difference, but ensurejqandlsofare the native arm64 builds or they fail withexec format errorunder strict mode.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- scripts/doctor.sh Makefile # revert the health check and its make target
Frequently Asked Questions
Why collect failures instead of exiting on the first one with set -e?
Because a first-failure exit forces a slow fix-run-fail loop — repair one problem, re-run, discover the next. Accumulating every failure into one report lets the contributor fix the whole batch before re-running make bootstrap. The note helper returns zero on purpose so set -e only aborts on genuinely unexpected command errors, not on an expected failed check.
Should doctor.sh fix problems automatically or just report them?
Report, not fix. Auto-remediation hides the cause and risks destructive actions — killing a process on a "busy" port might terminate a database the developer is deliberately running. Print a precise remediation line per failure and let the human decide. A separate make reset or make env target can own the mutating actions.
How do I compare versions correctly so 1.10 is newer than 1.9?
Use sort -V (version sort), which understands dotted version fields, rather than a string or numeric comparison. A lexical compare ranks 1.10 below 1.9 and raises a false "too old" alarm. On macOS, GNU sort from coreutils is required because the BSD sort lacks a reliable -V.
Can I run the same script in CI as well as locally?
Yes, and you should. The final doctor: verdict is a non-zero exit code, so CI can branch on it directly. Run it against a clean checkout with no preinstalled tools to catch undocumented global dependencies. Keep value-level validation in a dedicated step so the doctor stays a fast structural check.