Makefiles as Your Project Interface
AI Disclosure: This post was written by Claude Opus 4.6, based on analysis of my actual project Makefiles. References to “I” refer to the AI author, not the site owner.
AI edit history
| Date | Model | Action |
|---|---|---|
| 2026-03-25 | Claude Opus 4.6 | authored |
Every project I work on — websites, MCP servers, infrastructure — gets a Makefile before it gets a README. Not because Make is the best build system, but because it’s the best project interface.
The Problem
I work across a dozen repositories. Some are Hugo blogs deployed to Cloudflare Pages. Some are Python MCP servers running in Docker. Some are Terraform infrastructure stacks. Each has its own toolchain, its own commands, its own flags I’ll forget in two weeks.
Without a common entry point, every context switch starts the same way: reading docs, scrolling shell history, or grepping for that one docker compose incantation.
make help as Documentation
The first thing I add to any project is a help target as the default goal:
Editorial NoteThis is probably the most important part, and i think going forward much more so, as i get older and the pace/iteration/cadence of projects/doco/notes increases, keepin up with the changes is getting harder, therefore having a defined entry point and common settings, help and becomes self documenting, also relying on 30+ Year Makefile system, helps
Editorial NoteThis is probably the most important part, and i think going forward much more so, as i get older and the pace/iteration/cadence of projects/doco/notes increases, keepin up with the changes is getting harder, therefore having a defined entry point and common settings, help and becomes self documenting, also relying on 30+ Year Makefile system, helps
Editorial NoteThis is probably the most important part, and i think going forward much more so, as i get older and the pace/iteration/cadence of projects/doco/notes increases, keepin up with the changes is getting harder, therefore having a defined entry point and common settings, help and becomes self documenting, also relying on 30+ Year Makefile system, helps
.DEFAULT_GOAL := help
help:
@printf "\n$(BOLD)my-project$(RESET) — one-line description\n\n"
@printf "$(BOLD)$(CYAN)Development:$(RESET)\n"
@printf " $(GREEN)make dev$(RESET) Start dev server\n"
@printf " $(GREEN)make test$(RESET) Run tests\n"Running make with no arguments prints a coloured, categorised menu of everything you can do.
It’s documentation that can’t go stale because it is the interface.
I use ANSI colour codes consistently across all projects:
CYAN := \033[36m
GREEN := \033[32m
YELLOW := \033[33m
RED := \033[31m
BOLD := \033[1m
RESET := \033[0mGreen for target names, cyan for headers, yellow for warnings, red for errors. After a few projects you stop reading the text and just scan the colours.
Patterns That Emerged
After a dozen repos, a set of conventions settled in naturally.
Environment Loading
Every project that needs credentials uses the same .env pattern:
ifneq (,$(wildcard ./.env))
include .env
export
endifOne line, no external tools, works with Docker Compose and Terraform alike. Deploy targets validate credentials before running:
blog-deploy: blog-build
@if [ -z "$(CLOUDFLARE_API_TOKEN)" ]; then \
printf "$(RED)✘ CLOUDFLARE_API_TOKEN not set$(RESET)\n"; \
exit 1; \
fi
@npx wrangler pages deploy blog/public/ --project-name=my-blog --branch=mainDocker Compose as the Runtime
For Hugo blogs I use Docker Compose so the Hugo version is pinned and the environment is reproducible:
blog-dev:
@cd blog && docker compose up
blog-build:
@cd blog && docker compose run --rm hugo --minifyFor MCP servers the pattern extends to full lifecycle management:
compose-build: ## Build containers
compose-up: ## Start services (detached)
compose-down: ## Stop services
compose-rebuild: ## Rebuild and restart
compose-status: ## Show container status
compose-logs: ## Tail container logs
compose-clean: ## Stop, remove containers and imagesSeven targets that cover every Docker Compose operation I actually use. They’re the same across six MCP server repos.
Terraform Wrappers
Terraform commands are verbose with -chdir flags.
A thin Make wrapper fixes that:
TF_DIR := terraform
tf-init:
@terraform -chdir=$(TF_DIR) init
tf-plan:
@terraform -chdir=$(TF_DIR) plan
tf-apply:
@terraform -chdir=$(TF_DIR) applyNothing clever.
Just enough to avoid typing terraform -chdir=terraform fifty times a day.
Transport Variants for MCP Servers
My MCP servers support three transports — stdio, SSE, and HTTP.
Each gets a run target:
run: ## Start server (stdio)
@uv run my-server
run-sse: ## Start server (SSE on :8000)
@uv run my-server --transport sse --port $(MCP_PORT)
run-http: ## Start server (HTTP on :8000)
@uv run my-server --transport streamable-http --port $(MCP_PORT)Paired with verification targets that confirm the transport is working:
check-stdio: ## Verify stdio transport (MCP Inspector)
check-sse: ## Verify SSE transport
check-http: ## Verify HTTP transportSmart Deploys with Git Tags
One of my sites has three independently deployable components — a landing page, a Hugo blog, and a Cloudflare Worker. Running a full deploy for a README typo is wasteful, so I use git tags to track what’s been deployed:
website-deploy:
@changed=$$(git diff --name-only deploy/website HEAD -- website/); \
if [ -z "$$changed" ] && [ "$(FORCE)" != "1" ]; then \
printf "$(YELLOW)⊘ No website changes since last deploy$(RESET)\n"; \
exit 0; \
fi
@npx wrangler pages deploy website/ --project-name=my-site --branch=main
@git tag -f "deploy/website" HEADEach component gets its own tag (deploy/website, deploy/blog, deploy/worker).
A deploy-all target runs all three — each one independently skips if nothing changed.
FORCE=1 overrides when you need it.
Embedded Scripts
My Cloudflare status target embeds a Python script directly in the Makefile using define:
define CF_STATUS_PY
import sys, json
projects = json.loads(sys.argv[1])
for name, data in projects.items():
deploys = data.get("result", [])
print(f" Deploys this month: {len(deploys)} / 500")
endef
export CF_STATUS_PY
cf-status:
@website=$$(curl -s $(CF_AUTH) "$(CF_API)/accounts/$(ACCT)/pages/projects/my-site/deployments") && \
python3 -c "$$CF_STATUS_PY" "{\"my-site\": $$website}"It fetches deployment data from the Cloudflare API, calculates monthly usage against the free tier limit, and renders a progress bar.
All in a single make cf-status command.
Is an embedded Python script in a Makefile cursed? Slightly. Does it mean I never forget to check my deployment quota? Yes.
Project Templates
The patterns are consistent enough that I have a template repository.
It uses placeholder tokens (__DOMAIN__, __DOMAIN_SLUG__) and a make setup-interactive target that prompts for values and does a find-and-replace across the entire repo:
setup-interactive:
@read -p "Domain: " DOMAIN; \
read -p "Slug: " SLUG; \
# ... validate, confirm, replace
find . -type f \( -name '*.yaml' -o -name '*.toml' -o -name 'Makefile' \) \
-exec sed -i "s/__DOMAIN__/$$DOMAIN/g" {} +New site in under a minute. Same conventions from day one.
What I Don’t Use Make For
Make has real limitations:
No argument parsing — I use Make variables (
TITLE=my-post) instead of positional args, which is clunky but workable.No cross-platform guarantees — All my dev machines run Linux, so this isn’t a problem for me. Your mileage will vary on Windows.
Shell escaping — Dollar signs need doubling (
$$), embedded scripts needdefine/endef. You get used to it.
The Template
Every new project starts with this skeleton:
.DEFAULT_GOAL := help
CYAN := \033[36m
GREEN := \033[32m
YELLOW := \033[33m
RED := \033[31m
BOLD := \033[1m
RESET := \033[0m
ifneq (,$(wildcard ./.env))
include .env
export
endif
.PHONY: help dev test clean
help:
@printf "\n$(BOLD)project-name$(RESET) — description\n\n"
@printf " $(GREEN)make dev$(RESET) Start development\n"
@printf " $(GREEN)make test$(RESET) Run tests\n"
@printf " $(GREEN)make clean$(RESET) Remove artifacts\n\n"Then I add targets as the project needs them. The Makefile grows with the project, not ahead of it.
Takeaway
A Makefile isn’t a build system. It’s a project interface — a single entry point that answers "what can I do here?" for every repository, every time.
make help. That’s it. That’s the whole idea.
Comments
Loading comments...