Makefiles aren’t just for C projects. They’re a simple, universal way to document and run project tasks.

Why Make

Every project has tasks: build, test, deploy, clean. Make provides:

  • Documentation — Tasks are visible in the Makefile
  • Consistency — Same commands for everyone
  • Dependencies — Tasks can depend on others
  • Portability — Make is everywhere

Basic Syntax

1
2
target: dependencies
	command

Important: Commands must be indented with a tab, not spaces.

Simple Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
.PHONY: build test clean deploy

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

test:
	go test ./...

clean:
	rm -rf bin/

deploy: build
	scp bin/app server:/opt/app/
1
2
3
4
make build    # Build the app
make test     # Run tests
make deploy   # Build then deploy (dependency)
make          # Runs first target (build)

.PHONY Targets

By default, Make checks if a file named after the target exists. .PHONY tells Make these are commands, not files:

1
2
3
4
5
6
7
.PHONY: test clean

test:
	pytest

clean:
	rm -rf __pycache__/

Without .PHONY, if a file named test exists, make test would do nothing.

Variables

1
2
3
4
5
6
7
8
9
APP_NAME := myapp
VERSION := $(shell git describe --tags --always)
DOCKER_IMAGE := registry.example.com/$(APP_NAME):$(VERSION)

build:
	docker build -t $(DOCKER_IMAGE) .

push: build
	docker push $(DOCKER_IMAGE)

Override variables:

1
make build VERSION=v2.0.0

Common Patterns

Python Project

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
.PHONY: install test lint format clean run

PYTHON := python3
VENV := .venv

install:
	$(PYTHON) -m venv $(VENV)
	$(VENV)/bin/pip install -r requirements.txt
	$(VENV)/bin/pip install -e .

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

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

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

clean:
	rm -rf $(VENV) __pycache__ .pytest_cache .mypy_cache *.egg-info

run:
	$(VENV)/bin/python -m myapp

Node.js Project

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.PHONY: install test lint build clean dev

install:
	npm ci

test:
	npm test

lint:
	npm run lint

build:
	npm run build

clean:
	rm -rf node_modules dist

dev:
	npm run dev

Docker Project

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.PHONY: build push run stop logs clean

IMAGE := myapp
TAG := latest

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

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

run:
	docker run -d --name $(IMAGE) -p 8080:8080 $(IMAGE):$(TAG)

stop:
	docker stop $(IMAGE) || true
	docker rm $(IMAGE) || true

logs:
	docker logs -f $(IMAGE)

clean: stop
	docker rmi $(IMAGE):$(TAG) || true

Kubernetes Project

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.PHONY: apply delete logs status port-forward

NAMESPACE := production
APP := myapp

apply:
	kubectl apply -f k8s/ -n $(NAMESPACE)

delete:
	kubectl delete -f k8s/ -n $(NAMESPACE)

logs:
	kubectl logs -f -l app=$(APP) -n $(NAMESPACE)

status:
	kubectl get pods,svc,deploy -n $(NAMESPACE) -l app=$(APP)

port-forward:
	kubectl port-forward svc/$(APP) 8080:80 -n $(NAMESPACE)

Advanced Features

Help Target

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
.PHONY: help
help:
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'

build: ## Build the application
	go build -o bin/app

test: ## Run tests
	go test ./...

deploy: ## Deploy to production
	./scripts/deploy.sh
1
2
3
4
$ make help
build           Build the application
deploy          Deploy to production
test            Run tests

Conditional Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
OS := $(shell uname -s)

ifeq ($(OS),Darwin)
    OPEN := open
else
    OPEN := xdg-open
endif

docs:
	$(OPEN) docs/index.html

Include Other Makefiles

1
2
3
include .env          # Load environment variables
include mk/docker.mk  # Docker-related targets
include mk/test.mk    # Testing targets

Default Goal

1
.DEFAULT_GOAL := help

Now make with no arguments shows help.

Real-World Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
.DEFAULT_GOAL := help
.PHONY: help install test lint format build push deploy clean

# Config
APP := api
VERSION := $(shell git describe --tags --always --dirty)
DOCKER_REPO := registry.example.com
IMAGE := $(DOCKER_REPO)/$(APP):$(VERSION)
NAMESPACE := production

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

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

test: ## Run tests
	pytest -v --cov=app

lint: ## Run linters
	ruff check .
	mypy app/

format: ## Format code
	ruff format .

build: ## Build Docker image
	docker build -t $(IMAGE) .
	@echo "Built: $(IMAGE)"

push: build ## Push Docker image
	docker push $(IMAGE)

deploy: push ## Deploy to Kubernetes
	kubectl set image deployment/$(APP) $(APP)=$(IMAGE) -n $(NAMESPACE)
	kubectl rollout status deployment/$(APP) -n $(NAMESPACE)

clean: ## Clean build artifacts
	rm -rf .pytest_cache .mypy_cache __pycache__ dist *.egg-info
	docker rmi $(IMAGE) 2>/dev/null || true

Tips

Silent Commands

1
2
install:
	@pip install -r requirements.txt  # @ suppresses command echo

Continue on Error

1
2
clean:
	-rm -rf build/  # - continues even if command fails

Multi-line Commands

1
2
3
4
deploy:
	@echo "Deploying..."; \
	kubectl apply -f k8s/; \
	kubectl rollout status deploy/app

Or use backslashes:

1
2
3
deploy:
	kubectl apply -f k8s/ && \
	kubectl rollout status deploy/app

The Makefile Checklist

  • Common tasks documented as targets
  • .PHONY for non-file targets
  • help target that lists commands
  • Variables for configurable values
  • Dependencies between targets
  • Works on team members’ machines

Make is simple, universal, and just works. Stop writing shell scripts for project tasks — write a Makefile instead.