Writing a make bootstrap Target for One-Command 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. The goal is one command that any engineer can run on their first hour and get an identical result to the person who wrote it.
Diagnostic
Confirm the symptom: setup is a sequence of manual commands with no single entry point, and re-running any of them is unsafe. Two signals give it away — there is no bootstrap (or equivalent) target in the Makefile, and the "getting started" prose in the README lists individual commands the reader is expected to copy in order. Run this check at the repo root:
#!/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. The tell is that the second run of a "setup" fails where the first succeeded: a genuine bootstrap target converges on the same end state no matter how many times it runs. Keep this diagnostic in mind as the acceptance test for the target you are about to write — if you cannot run it twice in a row with no errors and no manual cleanup between runs, it is not finished.
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.
The fix is to fold the whole sequence into one Make target with explicit, idempotent stages. That makes the tooling the source of truth and turns the README into a thin quote of make help. Each stage becomes a named prerequisite that Make runs in dependency order, so the order lives in one line of the Makefile instead of in a numbered list a reader can skip through. Because make resolves prerequisites left to right, bootstrap: check env up seed is both the documentation and the executable contract.
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 header block earns its keep. .ONESHELL runs each recipe in a single shell so multi-line if/for constructs keep their variables, and .SHELLFLAGS := -euo pipefail -c makes every recipe abort on the first failed command, an unset variable, or a broken pipe. Without those two lines a failed cp inside a recipe would be silently swallowed and the next stage would run against a half-built environment — exactly the drift you are trying to kill.
The check stage tests versions, not mere presence. command -v docker proves the binary exists, but a machine with Node 16 will still fail your build with a confusing runtime error three minutes later; parsing node -v down to the major version and comparing it against NODE_MIN fails in two seconds with a message a new hire can act on. Port pre-flighting with lsof catches the most common local collision — a stray Postgres or a previous stack still holding 5432 — and prints the offending PID so the reader can kill it instead of guessing.
The env stage is the guard that makes re-runs safe: it copies .env.example only when .env is absent, so a second make bootstrap never overwrites the token you pasted in by hand. For validating the contents of that file rather than just its existence, pair this with dotenv configuration management. The up stage leans on docker compose up -d --wait, which blocks until every service with a healthcheck reports healthy; that ordering guarantee is what lets seed run against a database that is actually accepting connections instead of one that is still starting.
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;
The ON CONFLICT (id) DO NOTHING clause is the seed's own idempotency guard: the first run inserts the row, every run after that is a no-op instead of a primary-key violation. Combined with the env guard and Compose's --wait, all three stages converge on the same end state whether they run once or ten times.
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. The three lines that change are the tells: .env present, leaving it instead of wrote .env, and INSERT 0 0 instead of INSERT 0 1, because the row already exists:
$ 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
Measuring the payoff
The reason to invest in a bootstrap target is measurable: it collapses the wall-clock time from git clone to a running, seeded stack, and — more importantly — it removes the variance between a senior engineer who knows the shortcuts and a first-day hire who does not. A rough audit on a mid-size Compose project usually looks like the chart below: the manual path is dominated by reading the README, resolving one wrong tool version, and freeing a busy port, while the automated path front-loads all of that into the check stage.
Track the bottom bar over time. If make bootstrap starts creeping upward, it is a leading indicator that a new dependency, a slow image pull, or a heavier seed has been added without anyone noticing — the same drift the target exists to prevent, now visible as a number instead of a Slack complaint.
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). A GitHub Actions job that checks out the repo, runs the target, and asserts the final URL returns200catches the case where someone bumpsNODE_MINor renames a Compose service and forgets the ripple. - Validate the
.envcontract before startup with catching missing env vars before container startup, so a missing key fails atcheckwith a clear message rather than deep inside a container's entrypoint. - Keep
make doctorfrom building an onboarding health-check script as the fallback when bootstrap fails mid-stage; it reports why the environment is unhealthy where bootstrap only reports that a stage failed.
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
When a bootstrap leaves a half-built or wedged environment, tear the whole thing down and start clean rather than poking at individual containers:
#!/usr/bin/env bash
set -euo pipefail
docker compose down -v && rm -f .env # discard containers, volumes, and generated env
Because .env is regenerated by the env stage and every container is disposable, this is always safe: the next make bootstrap rebuilds the identical state from the committed .env.example, Compose file, and seed script. The -v flag is deliberate — it drops the named volumes so a corrupted database or a stale seed cannot survive the reset. If you want to preserve data across a rollback, drop the -v and delete only the specific volume that is wedged, but for a first-hour setup a full clean slate is almost always the right default.
Frequently Asked Questions
Why use make instead of a plain setup.sh script?
Both work, but Make gives you two things for free that a script has to re-implement: named stages that run in dependency order (bootstrap: check env up seed), and a self-documenting help target that lists every entry point. A single setup.sh tends to grow into one long function where the order is implicit and re-running from the middle is impossible. With Make, each stage is independently invocable — a developer can run just make seed after editing seed data — and the prerequisite line doubles as documentation of the order.
Does docker compose up --wait guarantee the database is ready to seed?
Only if the db service declares a healthcheck. The --wait flag blocks until every service with a healthcheck reports healthy, but a service with no healthcheck is treated as ready the moment its container starts, which for Postgres is well before it accepts connections. Add a healthcheck using pg_test or pg_isready to the db service so --wait actually gates the seed stage. Without it you will get intermittent connection refused failures in seed that pass on retry.
Why check the Node version instead of just checking that node exists?
Presence checks catch the empty machine; version checks catch the far more common case of a developer with the wrong major version already installed. A command -v node succeeds on a laptop running Node 16, and the failure then surfaces minutes later as a cryptic syntax or dependency error deep in an install step. Parsing node -v to the major number and comparing it against a pinned NODE_MIN fails in the check stage with a message the reader can act on immediately: node >= 20 required (found 16).
Is it safe to run make bootstrap on a machine that already has the stack running?
Yes — that is the point of the guards. The env stage leaves an existing .env untouched, docker compose up -d --wait is a no-op for containers already healthy, and the seed's ON CONFLICT DO NOTHING skips rows that already exist. The one thing to watch is the check stage's port pre-flight: it will report a busy port if the stack is already up on 3000 or 5432. If you want re-runs to be fully silent even while the stack runs, scope the port check to skip ports owned by the project's own containers.