make was built to compile C programs, but it’s evolved into something far more useful: a self-documenting task runner that lives in your project root and works on every Unix system without dependencies. Whether you’re building a Go binary, running a test suite, pushing Docker images, or generating docs, a Makefile makes the command discoverable, repeatable, and consistent across machines.

The basic structure

A Makefile is a collection of rules. Each rule has a target, optional prerequisites, and a recipe:

target: prerequisites
	recipe
	recipe

The recipe lines must be indented with a tab (not spaces). This is the single most common Makefile gotcha.

A minimal example:

build:
	go build -o bin/myapp ./cmd/myapp

test:
	go test ./...

clean:
	rm -rf bin/

Run a target with:

$ make build
go build -o bin/myapp ./cmd/myapp

$ make test
go test ./...
ok      myapp/pkg/api   0.342s
ok      myapp/pkg/db    0.128s

Phony targets

If a file named build or test exists in the project root, make will think the target is already up-to-date and skip it. Declare targets as .PHONY to prevent this:

.PHONY: build test clean lint run

build:
	go build -o bin/myapp ./cmd/myapp

test:
	go test -race ./...

lint:
	golangci-lint run

clean:
	rm -rf bin/

Almost every task-runner Makefile should have .PHONY for all non-file targets.

Variables

APP_NAME = myapp
BIN_DIR  = bin
CMD_PATH = ./cmd/$(APP_NAME)

.PHONY: build

build:
	go build -o $(BIN_DIR)/$(APP_NAME) $(CMD_PATH)

Variables can also be set at the command line, which overrides the Makefile default:

$ make build APP_NAME=myapp-debug

Environment variables

Make inherits all shell environment variables, so $(HOME), $(PATH), and $(CI) work without declaration.

A practical Makefile for a Python project

VENV = .venv
PYTHON = $(VENV)/bin/python
PIP = $(VENV)/bin/pip

.PHONY: install test lint format clean run

install:
	python3 -m venv $(VENV)
	$(PIP) install -r requirements.txt -r requirements-dev.txt

test:
	$(VENV)/bin/pytest tests/ -v

lint:
	$(VENV)/bin/ruff check .
	$(VENV)/bin/mypy src/

format:
	$(VENV)/bin/ruff format .

run:
	$(PYTHON) -m myapp

clean:
	rm -rf $(VENV) __pycache__ .pytest_cache .mypy_cache
$ make install
$ make test
$ make lint

A practical Makefile for a Docker project

IMAGE   = myapp
TAG    ?= latest
REGISTRY = ghcr.io/mukulkadel

.PHONY: build push run clean

build:
	docker build -t $(REGISTRY)/$(IMAGE):$(TAG) .

push: build
	docker push $(REGISTRY)/$(IMAGE):$(TAG)

run:
	docker run --rm -p 8080:8080 $(REGISTRY)/$(IMAGE):$(TAG)

clean:
	docker rmi $(REGISTRY)/$(IMAGE):$(TAG) || true

The ?= operator sets a default that the caller can override:

$ make push TAG=v1.2.3

Prerequisites chain targets

If push depends on build, declare it as a prerequisite:

push: build
	docker push ...

Now make push automatically runs build first. If build was already done and nothing changed, make skips it (for file targets; for .PHONY targets it always reruns).

Silent commands with @

By default, make prints each command before running it. Prefix a line with @ to suppress the echo:

test:
	@echo "Running tests..."
	@go test ./...

Without @:

echo "Running tests..."
Running tests...
go test ./...

With @:

Running tests...

Error handling

By default, make stops if any recipe command exits non-zero. To intentionally ignore an error (e.g., rm when the file may not exist), prefix the line with -:

clean:
	-rm -rf bin/
	-rm -rf .cache/

A help target every project should have

Add a help target that documents all targets — great for teams and for your future self:

.PHONY: help
help:
	@echo "Available targets:"
	@echo "  make install   Install dependencies"
	@echo "  make test      Run test suite"
	@echo "  make lint      Run linters"
	@echo "  make build     Build binary"
	@echo "  make clean     Remove build artifacts"

A fancier pattern auto-generates help from comments in the Makefile itself:

## install: Install project dependencies
install:
	pip install -r requirements.txt

## test: Run the full test suite
test:
	pytest tests/

.PHONY: help
help:
	@grep -E '^## ' Makefile | sed 's/## //'
$ make help
install: Install project dependencies
test: Run the full test suite

Default target

The first target in a Makefile is the default — running make with no arguments runs it. A common convention is to make help or build the default:

.DEFAULT_GOAL := help

Or just put your preferred default first.

make vs shell scripts

You could write all of this as shell scripts. The advantage of make is that the targets are named, declarable, and composable — you get a single entry point (make <thing>), dependency tracking, and convention that new contributors already know. The disadvantage is the tab-vs-space rule, limited control flow, and the fact that complex logic in a Makefile gets unreadable fast.

The practical rule: use make as a thin orchestrator that calls scripts. Keep the scripts in scripts/ for complex logic, and keep the Makefile as the entry point.

Conclusion

make is one of those tools that pays back its learning cost on the first project you use it for. A well-written Makefile with a help target turns a directory of scripts into a self-documenting command interface that any developer can pick up with make help. Start with five targets — install, build, test, lint, clean — and grow from there. The tab indentation is the only sharp edge; once you know it’s there, everything else is straightforward.