Most onboarding friction is documentation rot: a README that lists eleven manual steps, three of which are stale and one of which only the original author remembers. README-driven automation inverts that relationship — the README describes a single command, and that command is the setup. Every instruction a human reads maps to a target a machine runs, so the docs cannot silently diverge from reality. This is the runnable counterpart to the broader work of developer onboarding architecture and friction mapping: instead of measuring friction after the fact, you remove the manual steps that generate it.

The contract is simple. A new hire clones the repository and runs make bootstrap. One command checks their tools, writes their .env, starts the stack, seeds the database, and tells them what to open. A second command, make doctor, verifies the environment any time it misbehaves. The README contains those two commands and almost nothing else, because the Makefile is self-documenting. When something changes, you change the target, and the help text — the thing the README quotes — updates with it.

One-command bootstrap flow A clone feeds make bootstrap, which runs four ordered stages — tool checks, env creation, compose up, and seed — with a doctor health check feeding back into the developer. make bootstrap Flow git clone make bootstrap Check tools docker, node, jq Write .env from .env.example compose up start services Seed data idempotent make doctor verify and report Every README step maps to a make target — docs cannot drift from the tooling.

Prerequisites

  • GNU Make 4.x (make --version). BSD make on stock macOS works for simple targets but lacks .ONESHELL semantics used below; install GNU make via brew install make and invoke it as gmake.
  • Docker Engine 24+ with the Compose v2 plugin (docker compose version).
  • jq 1.6+ for parsing health-check JSON and .env.example annotations.
  • A repository that already has a working docker-compose.yml and a checked-in .env.example. If your Compose stack is not yet stable, fix containers that exit immediately on startup first — bootstrap automation will only amplify a flaky stack.

Self-Documenting Makefiles as the README Source

The root cause of README rot is duplication: instructions live in Markdown and in scripts, so they drift. Eliminate the duplication by making the Makefile generate its own help, then quote that help in the README. Annotate each target with a ## comment and add a help target that parses those comments.

  1. Annotate every public target with a trailing ## description.
  2. Add a help target that greps the Makefile for those annotations.
  3. Make help the default goal so a bare make prints the menu.
# Makefile
.DEFAULT_GOAL := help
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -euo pipefail -c

.PHONY: help
help: ## Show this help
	@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
	  | sort \
	  | awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-18s\033[0m %s\n", $$1, $$2}'

.PHONY: bootstrap
bootstrap: check env up seed ## One-command setup for a fresh clone
	@echo "Bootstrap complete. Open http://localhost:3000"

.PHONY: doctor
doctor: ## Diagnose a broken local environment
	@./scripts/doctor.sh

Drift diagnostic — confirm the README quotes the live help output, not a stale copy:

#!/usr/bin/env bash
set -euo pipefail
# Fails if the README's command list diverges from `make help`.
make help | sed 's/\x1b\[[0-9;]*m//g' | awk '{print $1}' | sort -u > /tmp/make-targets.txt
grep -oE 'make [a-z-]+' README.md | awk '{print $2}' | sort -u > /tmp/readme-targets.txt
if ! diff -u /tmp/make-targets.txt /tmp/readme-targets.txt; then
  echo "README references targets that do not match the Makefile."
  exit 1
fi
echo "README and Makefile targets are in sync."

The make bootstrap One-Command Setup

bootstrap is an aggregate target: it depends on smaller, independently runnable targets so a developer can re-run any single stage. The ordering — checks, then .env, then services, then seed — is deliberate, because each stage assumes the previous one succeeded.

  1. check verifies required tools exist before anything mutates the workstation.
  2. env creates .env from .env.example without clobbering an existing file.
  3. up starts the stack and waits for health checks.
  4. seed loads deterministic data and is safe to run twice.
.PHONY: check env up seed

check: ## Verify required tools are installed
	@command -v docker >/dev/null || { echo "docker not found"; exit 1; }
	@docker compose version >/dev/null || { echo "compose v2 plugin missing"; exit 1; }
	@command -v jq >/dev/null || { echo "jq not found"; exit 1; }

env: ## Create .env from .env.example if missing
	@if [ ! -f .env ]; then cp .env.example .env && echo "Wrote .env"; else echo ".env exists, leaving it"; fi

up: ## Start services and wait for health
	@docker compose up -d --wait

seed: ## Load deterministic seed data (idempotent)
	@docker compose exec -T db psql -U postgres -d app_db -f /seed/seed.sql

The full target — including dependency-version checks, port pre-flight, and idempotency guards — is built step by step in writing a make bootstrap target for one-command setup.

Drift diagnostic — prove bootstrap is idempotent by running it twice and asserting a clean second pass:

#!/usr/bin/env bash
set -euo pipefail
make bootstrap
make bootstrap   # second run must not error or recreate .env
echo "Bootstrap is idempotent."

Onboarding Health Checks with make doctor

A bootstrap that works on the author's laptop still fails on a teammate with a busy port 5432 or an old Node. A doctor script turns those silent, confusing failures into one actionable report. It checks tool versions against pinned minimums, confirms required ports are free, verifies the Docker daemon is reachable, and asserts every key in .env.example is present in .env.

  1. Resolve and compare each tool version against a required floor.
  2. Probe each port the stack needs and report which process holds it.
  3. Diff .env against .env.example so missing keys surface before startup.
#!/usr/bin/env bash
set -euo pipefail
fail=0
need() { command -v "$1" >/dev/null || { echo "MISSING: $1"; fail=1; }; }
need docker; need jq
docker info >/dev/null 2>&1 || { echo "Docker daemon not reachable"; fail=1; }
for p in 3000 5432; do
  if lsof -iTCP:"$p" -sTCP:LISTEN -P -n >/dev/null 2>&1; then
    echo "PORT BUSY: $p held by $(lsof -tiTCP:"$p" -sTCP:LISTEN | head -1)"
    fail=1
  fi
done
[ "$fail" -eq 0 ] && echo "doctor: all checks passed" || { echo "doctor: failures above"; exit 1; }

The production-grade version with set -euo pipefail, version-floor comparison, and grouped output lives in building an onboarding health-check script. Pair it with reducing setup friction for junior engineers, since clear failure messages disproportionately help first-time contributors.

Keeping the README and Automation in Lockstep

Documentation drift returns the moment a target is added without a ## annotation or the .env.example gains a key the script does not check. Guard both in CI so drift fails a pull request instead of surfacing on a new hire's first day. This complements the env-contract work in catching missing env vars before container startup.

  1. Require every public target to carry a ## description.
  2. Run make doctor in CI against a clean checkout to prove bootstrap parity.
# .github/workflows/onboarding-drift.yml
name: Onboarding Drift Guard
on: [pull_request]
jobs:
  drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Reject undocumented targets
        run: |
          undoc=$(grep -E '^[a-zA-Z0-9_-]+:' Makefile | grep -v '##' || true)
          if [ -n "$undoc" ]; then echo "Undocumented targets:"; echo "$undoc"; exit 1; fi
      - name: Bootstrap on a clean clone
        run: make bootstrap
      - name: Health check
        run: make doctor

Drift diagnostic — list any target missing a help annotation:

#!/usr/bin/env bash
set -euo pipefail
grep -E '^[a-zA-Z0-9_-]+:' Makefile | grep -v '##' && echo "Targets above lack ## help text." || echo "All targets documented."

macOS (Docker Desktop): stock macOS ships GNU Make 3.81, which predates .ONESHELL; install 4.x with brew install make and call gmake, or keep recipes single-line. lsof for the port probe ships by default. WSL2: keep the repo on the Linux filesystem (~/code, not /mnt/c) so make and Docker bind-mounts behave; /mnt/c paths break --wait health timing under load. Apple Silicon (ARM64): pin platform: linux/amd64 in Compose for any seed or tooling image lacking an arm64 manifest, otherwise bootstrap fails at the up stage with exec format error.

Rollback / recovery

bootstrap only writes .env and starts containers, so recovery is cheap. Tear down the stack and discard the generated env file to return to a pristine clone:

#!/usr/bin/env bash
set -euo pipefail
docker compose down -v          # stop services and drop volumes (seed data)
rm -f .env                       # remove the generated env; .env.example is untouched
echo "Reverted to a clean checkout. Re-run 'make bootstrap' to start over."

Because every key in .env is reproducible from .env.example, deleting it is non-destructive. The only data loss is the seeded database, which make seed recreates deterministically.