Make is 47 years old and still useful. Not for building C programs (though it does that), but as a simple task runner. Every Unix system has it. No installation required. One file defines all your project commands.

Basic Structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Makefile

.PHONY: help build test clean

help:
	@echo "Available targets:"
	@echo "  build  - Build the application"
	@echo "  test   - Run tests"
	@echo "  clean  - Clean build artifacts"

build:
	npm run build

test:
	npm test

clean:
	rm -rf dist/ node_modules/
1
2
3
make build
make test
make clean

.PHONY Explained

Without .PHONY, make checks if a file named “build” exists:

1
2
3
4
5
6
# If a file named "build" exists, this won't run
build:
	npm run build

# .PHONY tells make these aren't files
.PHONY: build test clean

Always use .PHONY for task targets.

Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Define variables
APP_NAME := myapp
VERSION := $(shell git describe --tags --always)
DOCKER_IMAGE := $(APP_NAME):$(VERSION)

# Use variables
build:
	docker build -t $(DOCKER_IMAGE) .

push:
	docker push $(DOCKER_IMAGE)

# Override from command line
# make build VERSION=v1.0.0

Dependencies

1
2
3
4
5
6
7
8
9
# "deploy" requires "build" and "test" to run first
deploy: build test
	./deploy.sh

build:
	npm run build

test:
	npm test
1
make deploy  # Runs build, then test, then deploy

Default Target

The first target is the default:

1
2
3
4
5
6
7
8
# Running just "make" executes "all"
all: build test

build:
	npm run build

test:
	npm test

Or be explicit:

1
2
3
4
.DEFAULT_GOAL := help

help:
	@echo "Run 'make <target>'"

Practical Example: 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
.PHONY: help install dev test lint format clean run docker-build docker-run

PYTHON := python3
VENV := .venv
BIN := $(VENV)/bin

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

install: $(VENV)/bin/activate ## Install dependencies

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

dev: install ## Install dev dependencies
	$(BIN)/pip install -r requirements-dev.txt

test: install ## Run tests
	$(BIN)/pytest tests/ -v

lint: install ## Run linter
	$(BIN)/flake8 src/
	$(BIN)/mypy src/

format: install ## Format code
	$(BIN)/black src/ tests/
	$(BIN)/isort src/ tests/

clean: ## Clean build artifacts
	rm -rf $(VENV) __pycache__ .pytest_cache .mypy_cache dist/ *.egg-info

run: install ## Run the application
	$(BIN)/python -m src.main

docker-build: ## Build Docker image
	docker build -t myapp:latest .

docker-run: ## Run Docker container
	docker run -p 8000:8000 myapp:latest

Practical Example: Node.js 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
.PHONY: help install dev build test lint clean docker-build docker-push deploy

NODE_ENV ?= development
VERSION := $(shell node -p "require('./package.json').version")

help:
	@echo "Targets: install, dev, build, test, lint, clean, docker-build, deploy"

install:
	npm ci

dev:
	npm run dev

build:
	npm run build

test:
	npm test

lint:
	npm run lint

clean:
	rm -rf node_modules dist .next

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

docker-push: docker-build
	docker push myapp:$(VERSION)

# Deployment
deploy-staging: docker-push
	kubectl set image deployment/myapp myapp=myapp:$(VERSION) -n staging

deploy-prod: docker-push
	@echo "Deploying $(VERSION) to production..."
	kubectl set image deployment/myapp myapp=myapp:$(VERSION) -n production

Conditional Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Check for required tools
check-deps:
	@which docker > /dev/null || (echo "Docker not installed" && exit 1)
	@which kubectl > /dev/null || (echo "kubectl not installed" && exit 1)

# Environment-specific behavior
deploy:
ifeq ($(ENV),production)
	@echo "Deploying to production..."
	./deploy.sh production
else
	@echo "Deploying to staging..."
	./deploy.sh staging
endif

Including Other Makefiles

1
2
3
4
5
# Include environment-specific config
-include .env.mk

# Include shared targets
include common.mk

Self-Documenting Help

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
.PHONY: help

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

build: ## Build the application
	npm run build

test: ## Run tests
	npm test

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

Parallel Execution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Run targets in parallel
.PHONY: all lint-js lint-css lint-py

all: lint-js lint-css lint-py

lint-js:
	npm run lint:js

lint-css:
	npm run lint:css

lint-py:
	flake8 .
1
make -j3 all  # Run all three lint targets in parallel

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Continue on error with -
clean:
	-rm -rf dist/
	-docker rmi myapp:latest

# Explicit error
check-env:
ifndef API_KEY
	$(error API_KEY is not set)
endif

Suppress Output

1
2
3
4
5
6
7
8
9
# @ suppresses command echo
quiet:
	@echo "This command is not printed"
	@npm run build > /dev/null

# Compare
loud:
	echo "This command IS printed"
	npm run build

Complete Project Template

 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# Makefile
SHELL := /bin/bash
.DEFAULT_GOAL := help

# Variables
APP_NAME := myapp
VERSION := $(shell git describe --tags --always --dirty)
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
DOCKER_IMAGE := $(APP_NAME):$(VERSION)

# Colors
CYAN := \033[36m
RESET := \033[0m

.PHONY: help
help: ## Display this help
	@echo "$(CYAN)$(APP_NAME)$(RESET) - Available targets:"
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  $(CYAN)%-15s$(RESET) %s\n", $$1, $$2}'

.PHONY: install
install: ## Install dependencies
	npm ci

.PHONY: dev
dev: ## Start development server
	npm run dev

.PHONY: build
build: ## Build for production
	npm run build

.PHONY: test
test: ## Run tests
	npm test

.PHONY: lint
lint: ## Run linter
	npm run lint

.PHONY: format
format: ## Format code
	npm run format

.PHONY: clean
clean: ## Clean build artifacts
	rm -rf dist node_modules .cache

.PHONY: docker-build
docker-build: ## Build Docker image
	docker build \
		--build-arg VERSION=$(VERSION) \
		--build-arg BUILD_TIME=$(BUILD_TIME) \
		-t $(DOCKER_IMAGE) .

.PHONY: docker-run
docker-run: ## Run Docker container locally
	docker run -p 3000:3000 $(DOCKER_IMAGE)

.PHONY: docker-push
docker-push: docker-build ## Push Docker image
	docker push $(DOCKER_IMAGE)

.PHONY: deploy
deploy: docker-push ## Deploy to Kubernetes
	kubectl set image deployment/$(APP_NAME) $(APP_NAME)=$(DOCKER_IMAGE)

.PHONY: logs
logs: ## Tail application logs
	kubectl logs -f deployment/$(APP_NAME)

.PHONY: info
info: ## Show build info
	@echo "App: $(APP_NAME)"
	@echo "Version: $(VERSION)"
	@echo "Image: $(DOCKER_IMAGE)"

Makefiles are simple, universal, and require no installation. They’re documentation that runs. Anyone can clone your repo and run make help to see what’s available.

Start with the basics: make build, make test, make deploy. Add complexity as needed. The Makefile that documents your workflow is the workflow that gets followed.