Makefile Basics
A Makefile defines targets (what to build), dependencies (what it depends on), and recipes (how to build it). Run make target-name from your terminal. The first target is the default. Makefiles use tabs for indentation — spaces won't work.
.PHONY Targets for Dev Tasks
Modern projects use Makefiles as task runners: make test, make lint, make deploy, make docker-build. Declare these as .PHONY so Make doesn't look for files with those names. This pattern works for any language — Python, Go, Node.js, Rust.
Variables and Functions
Define variables with APP_NAME:= myapp. Use them with $(APP_NAME). Environment variables are automatically available. Use ?= for defaults that can be overridden: PORT?= 8080. Built-in functions like $(wildcard *.go) and $(shell git rev-parse HEAD) add dynamic behavior.
Pattern Rules and Dependencies
Pattern rules like %.o: %.c apply to matching files. Dependencies ensure targets rebuild when source files change. Use $@ (target), (first dependency), and $^ (all dependencies) as automatic variables. For file-based targets, Make's dependency tracking avoids unnecessary rebuilds.
Practical Examples
A typical project Makefile includes: dev (start development server), test (run tests), lint (run linter), build (create production build), docker-build (build container), deploy (deploy to production), and clean (remove artifacts). This serves as living documentation — new developers run make help to see all available commands.
Self-Documenting Makefiles
Add comments after targets: test: ## Run unit tests. Then create a help target that greps for ## and formats the output. Now make help prints a nicely formatted list of all available commands with descriptions.
Conclusion
Makefiles are underrated in modern development. They provide a universal, zero-dependency task runner that works across languages and platforms. Every project benefits from a Makefile — even if it just wraps npm or docker compose commands with shorter aliases.