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.

The technique rests on a single design rule: there is exactly one source of truth for every setup step, and it is executable. A prose instruction such as "copy .env.example to .env and fill in the database password" is a promise no compiler enforces; six months later the example file has three new keys the prose never mentions. Convert that instruction into a make env target and the promise becomes a program that either runs or fails loudly. The README then stops describing how to set up the project and instead describes what command to run and what to expect — a contract narrow enough that it rarely goes stale. This page walks through the four moving parts that make the contract hold: a self-documenting Makefile, an aggregate bootstrap target composed of small re-runnable stages, a doctor diagnostic that explains failures in plain language, and a CI guard that fails a pull request the moment documentation and tooling diverge. Everything here uses GNU Make as the entry point because it is already installed almost everywhere, needs no runtime, and expresses task dependencies natively.

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

Before the two-command contract can be trusted, the pieces it orchestrates must already work by hand. The automation does not fix a broken stack; it removes the manual steps around a stack that already starts cleanly. Confirm each of the following on the machine you author from, and pin the same minimums inside doctor so every teammate is held to them.

  • 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. The Makefile becomes the canonical list of things a developer can do, and the README quotes it verbatim rather than paraphrasing it.

  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

The help recipe deserves a line-by-line reading because it is the mechanism the whole approach hangs on. $(MAKEFILE_LIST) expands to every Makefile Make has read, so included fragments contribute their targets too. The grep -E pattern matches lines that look like name: followed somewhere by ## text, which is exactly the shape of an annotated public target and nothing else — private helpers without a ## comment are invisible to the menu, which is what you want. The awk block splits each line on the :.*?## separator and prints the target name padded to eighteen columns, then its description. The \033[36m and \033[0m sequences colour the target names cyan in a terminal; the drift check below strips them with sed so the comparison is colour-blind. Because .DEFAULT_GOAL := help sits at the top, a developer who types a bare make gets the menu instead of an error, which removes the most common first-run stumble.

Two Make settings above are load-bearing and worth pinning explicitly. .ONESHELL runs every line of a recipe in a single shell invocation rather than spawning a fresh shell per line, so a cd or a variable set on one line survives to the next — essential once a recipe grows past a single command. .SHELLFLAGS := -euo pipefail -c makes every recipe abort on the first failed command, on an unset variable, or on a failed pipe stage, which turns silent half-completed bootstraps into loud, early failures. Without these, a recipe that fails on step two happily continues to step three and reports success, and the developer discovers the breakage only when the app misbehaves.

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."
Prose README versus self-documenting Makefile A comparison of a hand-written prose README against a Makefile that generates its own help, across three properties. Where the Setup Steps Live Prose README steps duplicated in text drift is silent nothing runs the docs verified by humans Self-Documenting Make one source of truth drift fails CI the docs are runnable verified by machine
Moving setup steps into annotated targets removes the copy that rots.

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. Expressing the order as Make prerequisites rather than as a shell script gives you two things a script cannot: Make runs each prerequisite exactly once even if several targets depend on it, and a developer can invoke any stage in isolation (make seed after a schema change, make up after a reboot) without re-running the whole chain.

  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

Three properties make this chain safe to hand to someone on their first morning. First, check runs before anything mutates the machine, so a missing jq or a stopped Docker daemon aborts the run before a half-written .env or a partially started stack can confuse the next step. Second, env is guarded against clobbering: the [ ! -f .env ] test means re-running bootstrap never overwrites secrets a developer has already filled in, which is the single most common way naive setup scripts destroy someone's afternoon. Third, up uses --wait rather than a bare docker compose up -d, so the target does not return until every service reports healthy against its Compose healthcheck; without --wait, seed races the database and fails intermittently with connection refused, the kind of flake that erodes trust in the whole tool.

The seed stage must be idempotent because developers will run it more than once — after a schema change, after wiping a table, after a colleague suggests it. Idempotency is a property of the SQL, not of Make: write seeds as INSERT ... ON CONFLICT DO NOTHING or wrap them in TRUNCATE plus INSERT so a second run converges to the same state instead of erroring on a duplicate key. The -T flag on docker compose exec disables pseudo-TTY allocation, which matters in CI where no terminal is attached and the command otherwise hangs.

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. The value is not that it catches problems bootstrap would not — it is that it names them in a sentence a human can act on. "PORT BUSY: 5432 held by 8123" points straight at the offending process; a raw connection refused from a database driver five layers deep does not.

  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; }

Version comparison is the part people get wrong. A string comparison says node v9.0.0 is greater than node v10.0.0 because 9 sorts after 1; you need a numeric-aware comparison. The portable trick is printf '%s\n%s\n' "$required" "$actual" | sort -V | head -1 — if the required version sorts first, the actual version meets or exceeds the floor. Pin the floors in one place at the top of the script (NODE_MIN=20.0.0, DOCKER_MIN=24.0.0) so bumping a requirement is a one-line change that both doctor and the README's stated prerequisites can read.

doctor should also exit non-zero when it finds a problem. That single detail is what lets you reuse the same script in CI: a green doctor run against a clean checkout proves the documented environment is still buildable, and a red one blocks the merge. Keep its output grouped — tools, then ports, then the .env diff — so a developer scanning the report finds the one failing line without reading the passing ones.

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.

The payoff is measurable. The chart below tracks the median time from git clone to a running app for one team before and after adopting the two-command contract, broken down by the phase where the time went. Manual environment fiddling — the steps a prose README leaves to the reader — collapses once bootstrap owns them.

Time to a running app by setup phase Bar chart comparing minutes spent in tool install, env setup, and debugging before and after adopting make bootstrap. Minutes From Clone to Running App manual README 95m first bootstrap 27m re-bootstrap 9m Most of the first-run time is Docker pulling images, not human steps.
Median clone-to-running time before and after the two-command contract.

The .env Contract and Deterministic Seeds

The .env file is where most silent onboarding failures hide, because a missing or misnamed key does not fail loudly — it produces a service that starts and then behaves subtly wrong. Treat .env.example as a contract: it lists every key the stack needs, with a safe default or an obvious placeholder, and it is the only file committed to the repository. The doctor diff between .env and .env.example is what enforces the contract, so the example file must stay complete. Add a key to Compose, add it to .env.example in the same commit, and CI's doctor run will flag any teammate whose local .env lacks it.

Deciding what env should do on each run is a small branch worth making explicit, because the wrong choice either destroys a developer's secrets or leaves them running against a stale template. The rule is: create when absent, never overwrite when present, and let doctor — not env — report keys that are present in the example but missing locally. That separation keeps the mutating step dumb and safe while the reporting step stays read-only.

What the env target does per run A decision on whether a local .env file already exists, leading to either creating it or leaving it untouched. Does .env Already Exist? test -f .env No copy from .env.example Yes leave it, let doctor diff
The env target creates but never clobbers; doctor owns reporting drift.

Deterministic seeds close the loop. If two developers run make seed and end up with different row counts, every downstream test and screenshot becomes a coin flip. Write seeds so a second run converges: prefer INSERT ... ON CONFLICT DO NOTHING for reference data, or TRUNCATE then INSERT for tables you fully own, and pin any generated ids and timestamps rather than letting them float. A seed that produces byte-identical state on every machine is what lets the doctor health check and the CI parity run actually mean the same thing everywhere.

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.

The guard works because CI is a permanent stand-in for the new hire. Every pull request spins up a machine that has never seen your project, checks out the branch, and runs the exact two commands the README tells a human to run. If bootstrap fails on that clean machine, it would have failed for the next person to join, and now the author finds out in the pull request instead of over a screen-share three weeks later. Running the drift guard on pull_request rather than on a nightly schedule matters: the failure lands on the change that caused it, while the author still has the context to fix it in a one-line edit.

  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."

Platform caveats

The two-command contract is designed to hide platform differences behind a single entry point, but a handful of them leak into the Makefile and the doctor probe itself. Handle them explicitly so bootstrap behaves the same on every laptop the team runs.

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. This is the quiet advantage of keeping every setup step behind a target: recovery is not a special procedure a developer has to look up, it is just the inverse of the same two commands. Tear down, re-run make bootstrap, and the environment converges to exactly the state the README describes — no manual cleanup, no stray files, no leftover containers holding a port. If bootstrap still fails after a clean teardown, the problem is in the tooling, not the developer's machine, and make doctor will point at the offending line.

Frequently Asked Questions

Why use a Makefile instead of an npm script or a shell script?

Three reasons. Make is already installed on almost every developer machine and CI runner, so it adds no dependency. It expresses task dependencies natively, so bootstrap: check env up seed runs each prerequisite once and in order without you writing sequencing logic. And it gives every stage a name a developer can invoke in isolation — make seed after a schema change, make up after a reboot — which a monolithic setup.sh does not. An npm script ties setup to a Node toolchain that a non-Node service may not want; a bare shell script loses the dependency graph. If your team already standardizes on just or task, the same self-documenting pattern applies — the entry point matters less than the one-source-of-truth rule.

Does re-running make bootstrap overwrite my filled-in .env?

No. The env target guards the copy with [ ! -f .env ], so it writes .env from .env.example only when the file is missing and otherwise leaves your edits untouched. Re-running bootstrap is safe: check is read-only, env is a no-op once .env exists, up reconciles the running stack, and seed is written to be idempotent. That is what makes the target usable as a repair command, not just a first-run command.

How is make doctor different from make bootstrap?

bootstrap changes the machine — it writes files and starts containers. doctor only reads: it compares tool versions against pinned floors, probes ports, checks the Docker daemon, and diffs .env against .env.example, then exits non-zero if anything is wrong. Run bootstrap once to set up and doctor any time the environment misbehaves. Because doctor is side-effect-free and exits non-zero on failure, the same script runs in CI to prove a clean checkout is still buildable.

How do I stop the README from quoting stale make targets?

Automate the check. The drift diagnostic in the first section strips the colour codes from make help, extracts the target names, extracts every make <target> reference in README.md, and fails if the two sets differ. Run it in the same CI job that runs bootstrap, so a pull request that adds a target without documenting it — or documents a target that no longer exists — is rejected before merge. The README stops being a thing you remember to update and becomes a thing CI keeps honest.