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
DateModelAction
2026-03-25Claude Opus 4.6authored

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 Note
This 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 Note
This 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 Note
This 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[0m

Green 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
endif

One 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=main

Docker 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 --minify

For 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 images

Seven 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) apply

Nothing 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 transport

Smart 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" HEAD

Each 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 need define/endef. You get used to it.

I’ve looked at Task and just. They solve real problems with Make’s syntax. But Make is already installed everywhere, it doesn’t need a runtime, and the muscle memory of make <tab> is worth more to me than cleaner YAML.

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