Infrastructure

Writing a Makefile that doesn't make you cry.


Make was first written in 1976. It's available on every Unix-like system, requires no installation, no language runtime, no package manager. It's the lowest common denominator task runner that everyone already has. And yet most Makefiles are either cryptically terse or so poorly structured that adding a new target is a 20-minute archaeology exercise.

This post covers the conventions and patterns that turn a Makefile into something a new team member can pick up in five minutes โ€” self-documenting, predictable, and useful beyond the original author's intended use cases.

The basics, done right

A Makefile target looks like this:

target: prerequisite1 prerequisite2
	recipe command here   # must be a TAB, not spaces

The most common beginner mistake is using spaces for indentation. Make requires a literal tab character before each recipe line. Your editor probably auto-converts tabs to spaces. Set it to not do this for Makefiles, or use something like .RECIPEPREFIX := > to change the recipe prefix to a character your editor won't mangle.

Phony targets

If you have a target called clean and there happens to be a file named clean in the directory, Make thinks the target is already up to date and skips it. Declare targets that don't produce files as .PHONY:

.PHONY: build test clean deploy help

build:
	docker build -t myapp:$(VERSION) .

test:
	go test ./...

clean:
	rm -rf dist/ .cache/

Put .PHONY at the top of the file or immediately before each target โ€” either works. We prefer a single declaration at the top listing all phony targets, which also serves as a quick table of contents for the file.

Automatic variables: the ones worth knowing

Make has a set of automatic variables that refer to parts of the current rule. Three are essential:

  • $@ โ€” the target name. In a rule building dist/app, $@ is dist/app.
  • $< โ€” the first prerequisite. Useful in pattern rules.
  • $^ โ€” all prerequisites, space-separated, with duplicates removed.
dist/app: cmd/main.go $(wildcard pkg/**/*.go)
	go build -o $@ $<

The $@ avoids repeating the target name in the recipe, which prevents the class of bug where you rename the target but forget to rename the output path in the recipe.

Pattern rules and the wildcard function

Pattern rules let you describe how to build any file matching a pattern:

# Build any .html from the corresponding .md
dist/%.html: src/%.md
	pandoc -o $@ $<

# Compile any .o from the corresponding .c
%.o: %.c
	$(CC) -c -o $@ $<

The wildcard function expands glob patterns at Makefile parse time:

SOURCES := $(wildcard src/*.go)
TESTS   := $(wildcard **/*_test.go)

# Use patsubst to transform a list of files
OBJECTS := $(patsubst src/%.go,dist/%.o,$(SOURCES))

Variables and environment

Make variables are set with := (immediate, evaluated once) or = (deferred, evaluated each use). Prefer := unless you specifically need lazy evaluation โ€” deferred variables can cause confusing behaviour when the value changes:

# Immediately expanded - VERSION is fixed at parse time
VERSION := $(shell git describe --tags --always --dirty)

# Override from environment or command line:
# make deploy ENV=production
ENV ?= development   # ?= only assigns if not already set

Variables set in the environment or on the command line (make TARGET VAR=value) take precedence over Makefile definitions unless you use override. Use ?= for values that should be overridable as defaults.

Self-documenting help target

The single highest-leverage improvement to any Makefile: a help target that prints what the file does. The pattern below extracts comments from target lines automatically:

.PHONY: help
help: ## Show this help message
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}'

build: ## Build the Docker image
	docker build -t $(IMAGE):$(VERSION) .

test: ## Run the test suite
	go test -race ./...

lint: ## Run linters (golangci-lint)
	golangci-lint run ./...

deploy: ## Deploy to production (requires ENV=production)
	kubectl apply -f k8s/

Running make help (or just make if help is the first target) prints a formatted list of all documented targets. New team members can orient themselves without reading the whole file. Make help the first target so it's the default when someone runs make with no arguments.

Convention: Any target without a ## comment is intentionally private โ€” it's a helper called by other targets, not meant for direct use. This is a convention, not enforced by Make, but it communicates intent clearly.

A complete example

# Project Makefile
# Usage: make help

.PHONY: help build test lint clean docker-build docker-push deploy

# โ”€โ”€ Configuration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
IMAGE   := registry.example.com/myapp
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
ENV     ?= development

# โ”€โ”€ Default target โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
help: ## Show available targets
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}'

# โ”€โ”€ Development โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
build: ## Build the application binary
	go build -ldflags="-X main.Version=$(VERSION)" -o dist/app ./cmd/app

test: ## Run all tests with race detection
	go test -race -count=1 ./...

test-cover: ## Run tests with coverage report
	go test -coverprofile=dist/coverage.out ./...
	go tool cover -html=dist/coverage.out -o dist/coverage.html

lint: ## Run golangci-lint
	golangci-lint run ./...

clean: ## Remove build artifacts
	rm -rf dist/

# โ”€โ”€ Docker โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
docker-build: ## Build Docker image (VERSION from git)
	docker build --build-arg VERSION=$(VERSION) -t $(IMAGE):$(VERSION) .
	docker tag $(IMAGE):$(VERSION) $(IMAGE):latest

docker-push: docker-build ## Build and push to registry
	docker push $(IMAGE):$(VERSION)
	docker push $(IMAGE):latest

# โ”€โ”€ Deployment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
deploy: ## Deploy to Kubernetes (ENV=production|staging)
	@echo "Deploying $(VERSION) to $(ENV)..."
	helm upgrade --install myapp ./helm/myapp \
		--namespace $(ENV) \
		--set image.tag=$(VERSION) \
		--values helm/values-$(ENV).yaml

A few more conventions worth adopting

Print what you're doing. Prefix recipes with @echo "โ†’ Building..." lines so the user knows what's happening. The @ suppresses printing the command itself (Make prints each command by default); use it selectively โ€” for noisy commands that output their own status, suppress the echoing. For quiet commands that produce no output, let Make print them so there's some feedback.

Fail fast and loudly. Add .SHELLFLAGS := -eu -o pipefail -c near the top of the file. This makes the shell exit on any error (-e), on unbound variables (-u), and on pipe failures (pipefail). Without this, a command in the middle of a pipe can fail silently and Make won't notice.

Include guards for required tools. If your Makefile requires specific tools, check for them early:

REQUIRED_TOOLS := docker kubectl helm golangci-lint
$(foreach tool,$(REQUIRED_TOOLS),\
  $(if $(shell command -v $(tool) 2>/dev/null),,\
    $(error Required tool '$(tool)' not found in PATH)))

This runs at parse time and gives a clear error before attempting to run any targets, rather than a cryptic failure message mid-build.


โ† Back to Blog Docker Compose vs K8s โ†’