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#
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#
Make is simple, universal, and just works. Stop writing shell scripts for project tasks — write a Makefile instead.