A new hire follows your README's eleven manual steps, misses one, and spends a morning debugging a half-started stack — the setup is not automated, so it drifts. This page builds a single make bootstrap target that takes a fresh clone to a running, seeded stack and is safe to re-run, as the runnable core of README-driven automation.

Diagnostic

Confirm the symptom: setup is a sequence of manual commands with no single entry point, and re-running them is unsafe.

#!/usr/bin/env bash
set -euo pipefail
# A repo without one-command setup: no bootstrap target, env copied by hand.
grep -qE '^bootstrap:' Makefile 2>/dev/null || echo "BAD: no bootstrap target"
[ -f .env ] && echo "BAD: .env already hand-edited and uncommitted-safe? verify"

Expected BAD output on an un-automated repo:

BAD: no bootstrap target

Running the existing manual steps twice typically produces errors like .env already exists from a blind cp, or Conflict. The container name "/app" is already in use from a second docker compose up without teardown — proof the steps are not idempotent.

Root cause

Manual setup drifts because each instruction is an independent, unguarded side effect with no record of what already happened. A cp .env.example .env clobbers local edits on the second run; a bare docker compose up collides with existing containers; a seed script doubles rows. There is no single command that encodes the order and the guards, so the README — the only place the order is written — becomes the source of truth and rots the instant someone changes a script without updating the prose. Folding the whole sequence into one Make target with explicit, idempotent stages makes the tooling the source of truth and the README a thin quote of make help.

Resolution

  1. Set strict shell semantics so a failing stage aborts the whole bootstrap.
  2. Check tool versions, not just presence, against pinned floors.
  3. Pre-flight the ports the stack binds, failing early with the offending PID.
  4. Create .env only when absent so local edits survive re-runs.
  5. Start the stack with --wait so health gates block before seeding.
  6. Seed with an idempotent script (ON CONFLICT DO NOTHING).
# Makefile
.DEFAULT_GOAL := help
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -euo pipefail -c

NODE_MIN := 20
COMPOSE_PORTS := 3000 5432

.PHONY: bootstrap check env up seed help

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

check: ## Verify tool versions and free ports
	@command -v docker >/dev/null || { echo "docker missing"; exit 1; }
	@docker compose version >/dev/null || { echo "compose v2 plugin missing"; exit 1; }
	@node_major=$$(node -v 2>/dev/null | sed 's/v\([0-9]*\).*/\1/'); \
	if [ -z "$$node_major" ] || [ "$$node_major" -lt $(NODE_MIN) ]; then \
	  echo "node >= $(NODE_MIN) required (found $${node_major:-none})"; exit 1; fi
	@for p in $(COMPOSE_PORTS); do \
	  if lsof -iTCP:$$p -sTCP:LISTEN -P -n >/dev/null 2>&1; then \
	    echo "port $$p busy (PID $$(lsof -tiTCP:$$p -sTCP:LISTEN | head -1))"; exit 1; fi; \
	done

env: ## Create .env from .env.example without clobbering edits
	@if [ ! -f .env ]; then cp .env.example .env && echo "wrote .env"; \
	else echo ".env present, leaving it"; fi

up: ## Start services and block until healthy
	@docker compose up -d --wait

seed: ## Load deterministic seed data (safe to re-run)
	@docker compose exec -T db psql -v ON_ERROR_STOP=1 -U postgres -d app_db -f /seed/seed.sql

help: ## Show available targets
	@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
	  | awk 'BEGIN {FS = ":.*?## "}; {printf "  %-12s %s\n", $$1, $$2}'

The matching idempotent seed file makes re-runs harmless:

-- seed/seed.sql
INSERT INTO users (id, email) VALUES
  (1, '[email protected]')
ON CONFLICT (id) DO NOTHING;

Expected output

A first run on a clean clone prints each stage and the final URL:

$ make bootstrap
wrote .env
[+] Running 2/2
 ✔ Container app-db-1   Healthy
 ✔ Container app-app-1  Healthy
INSERT 0 1
Bootstrap complete -> http://localhost:3000

A second run proves idempotency — no clobber, no container conflict, no duplicate rows:

$ make bootstrap
.env present, leaving it
[+] Running 2/2
 ✔ Container app-db-1   Healthy
 ✔ Container app-app-1  Healthy
INSERT 0 0
Bootstrap complete -> http://localhost:3000

Prevention

  1. Run make bootstrap on a clean checkout in CI so a broken target fails a pull request, not a new hire (see the drift workflow in README-driven automation).
  2. Validate the .env contract before startup with catching missing env vars before container startup.
  3. Keep make doctor from building an onboarding health-check script as the fallback when bootstrap fails mid-stage.

macOS (Docker Desktop): GNU Make 3.81 ships by default and lacks .ONESHELL; install 4.x with brew install make and run gmake bootstrap, or split multi-line recipes. WSL2: run make from the Linux filesystem; docker compose up --wait times out unpredictably when the project sits on /mnt/c. Apple Silicon (ARM64): if the db or seed image lacks an arm64 manifest, add platform: linux/amd64 to its Compose service or up aborts before seed runs.

Rollback

#!/usr/bin/env bash
set -euo pipefail
docker compose down -v && rm -f .env   # discard containers, volumes, and generated env