Writing a make bootstrap Target for One-Command Setup
Build a complete make bootstrap target: dependency-version checks, .env creation, docker compose up --wait, idempotent seeding, and a re-runnable, drift-proof setup.
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
- Set strict shell semantics so a failing stage aborts the whole bootstrap.
- Check tool versions, not just presence, against pinned floors.
- Pre-flight the ports the stack binds, failing early with the offending PID.
- Create
.envonly when absent so local edits survive re-runs. - Start the stack with
--waitso health gates block before seeding. - 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
- Run
make bootstrapon 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). - Validate the
.envcontract before startup with catching missing env vars before container startup. - Keep
make doctorfrom 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 withbrew install makeand rungmake bootstrap, or split multi-line recipes. WSL2: runmakefrom the Linux filesystem;docker compose up --waittimes out unpredictably when the project sits on/mnt/c. Apple Silicon (ARM64): if thedbor seed image lacks an arm64 manifest, addplatform: linux/amd64to its Compose service orupaborts beforeseedruns.
Rollback
#!/usr/bin/env bash
set -euo pipefail
docker compose down -v && rm -f .env # discard containers, volumes, and generated env