Make has been around since 1976. It’s installed on virtually every Unix system. And while it was designed for compiling C programs, it’s become a universal task runner for any project.

No npm, no pip, no cargo — just make.

Why Make?

  • Zero dependencies — Already on your system
  • Declarative — Describe what you want, not how to get it
  • Incremental — Only runs what’s needed
  • Self-documentingmake help shows available targets
  • Universal — Works the same on Linux, macOS, CI systems

Basic Structure

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Variables
APP_NAME := myapp
VERSION := 1.0.0

# Default target (runs when you just type 'make')
.DEFAULT_GOAL := help

# Phony targets don't create files
.PHONY: build test clean help

build:
	go build -o $(APP_NAME) ./cmd/$(APP_NAME)

test:
	go test ./...

clean:
	rm -f $(APP_NAME)

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

Self-Documenting Makefiles

The best pattern: automatic help generation from comments.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.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}'

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

.PHONY: test
test: ## Run tests
	go test -v ./...

.PHONY: lint
lint: ## Run linter
	golangci-lint run

.PHONY: clean
clean: ## Clean build artifacts
	rm -rf bin/

Now make help automatically lists all targets with descriptions:

hbtlceueillisneplttadnSBRRChuuuloinnewladtlnteihtsnbihttuseseirlhadeplpaplritciaftaicotns

Variables and Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Simple assignment (evaluated when used)
CC = gcc

# Immediate assignment (evaluated now)
DATE := $(shell date +%Y-%m-%d)

# Conditional (only set if not already defined)
VERSION ?= dev

# Append
CFLAGS += -Wall

# Environment variable with default
DATABASE_URL ?= postgres://localhost/myapp

Multi-line Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
define HELP_TEXT
Usage: make [target]

Targets:
  build    Build the app
  test     Run tests
  deploy   Deploy to production
endef
export HELP_TEXT

help:
	@echo "$$HELP_TEXT"

Dependencies

Targets can depend on other targets:

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

all: build test ## Build and test

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

test: build ## Run tests (builds first)
	go test ./...

deploy: test ## Deploy (tests first)
	./scripts/deploy.sh

File Dependencies

Make’s real power: only rebuild when sources change.

1
2
3
4
5
6
7
# Rebuild binary only if source files changed
bin/app: $(wildcard cmd/app/*.go) $(wildcard internal/**/*.go)
	go build -o $@ ./cmd/app

# Generate docs only if source changed
docs/api.html: openapi.yaml
	redoc-cli bundle $< -o $@

Common Patterns

Environment Setup

1
2
3
4
5
6
7
8
9
.PHONY: setup
setup: ## Set up development environment
	@echo "Installing dependencies..."
	go mod download
	pip install -r requirements.txt
	npm install
	@echo "Creating directories..."
	mkdir -p bin logs tmp
	@echo "Done! Run 'make build' to build."

Docker Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
IMAGE_NAME := myapp
IMAGE_TAG := $(shell git rev-parse --short HEAD)

.PHONY: docker-build docker-push docker-run

docker-build: ## Build Docker image
	docker build -t $(IMAGE_NAME):$(IMAGE_TAG) .
	docker tag $(IMAGE_NAME):$(IMAGE_TAG) $(IMAGE_NAME):latest

docker-push: docker-build ## Push to registry
	docker push $(IMAGE_NAME):$(IMAGE_TAG)
	docker push $(IMAGE_NAME):latest

docker-run: ## Run container locally
	docker run --rm -p 8080:8080 $(IMAGE_NAME):latest

Database Operations

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
.PHONY: db-up db-down db-migrate db-seed db-reset

db-up: ## Start database
	docker compose up -d postgres

db-down: ## Stop database
	docker compose down

db-migrate: ## Run migrations
	migrate -path ./migrations -database "$$DATABASE_URL" up

db-seed: db-migrate ## Seed database
	psql "$$DATABASE_URL" < seeds/data.sql

db-reset: db-down db-up ## Reset database
	@sleep 2
	$(MAKE) db-migrate
	$(MAKE) db-seed

Python Projects

 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
VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip

.PHONY: venv install test lint format clean

$(VENV)/bin/activate:
	python3 -m venv $(VENV)
	$(PIP) install --upgrade pip

venv: $(VENV)/bin/activate ## Create virtual environment

install: venv ## Install dependencies
	$(PIP) install -r requirements.txt
	$(PIP) install -r requirements-dev.txt

test: install ## Run tests
	$(PYTHON) -m pytest

lint: install ## Run linter
	$(PYTHON) -m flake8 src/
	$(PYTHON) -m mypy src/

format: install ## Format code
	$(PYTHON) -m black src/ tests/
	$(PYTHON) -m isort src/ tests/

clean: ## Clean up
	rm -rf $(VENV) __pycache__ .pytest_cache .mypy_cache
	find . -type d -name __pycache__ -exec rm -rf {} +

Node.js Projects

 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
NODE_MODULES := node_modules
NPM := npm

.PHONY: install build test lint dev clean

$(NODE_MODULES): package.json
	$(NPM) install
	@touch $(NODE_MODULES)

install: $(NODE_MODULES) ## Install dependencies

build: install ## Build for production
	$(NPM) run build

test: install ## Run tests
	$(NPM) test

lint: install ## Run linter
	$(NPM) run lint

dev: install ## Start development server
	$(NPM) run dev

clean: ## Clean up
	rm -rf $(NODE_MODULES) dist .cache

CI/CD Targets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.PHONY: ci cd

ci: lint test build ## Run CI pipeline
	@echo "CI passed!"

cd: ci ## Run CD pipeline
	@if [ "$(CI)" != "true" ]; then \
		echo "CD should only run in CI environment"; \
		exit 1; \
	fi
	$(MAKE) docker-push
	$(MAKE) deploy

Advanced Techniques

Conditional Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Different behavior based on OS
ifeq ($(shell uname),Darwin)
    OPEN := open
else
    OPEN := xdg-open
endif

docs-open: docs ## Open docs in browser
	$(OPEN) docs/index.html

# Check for required tools
check-deps:
	@command -v docker >/dev/null || (echo "docker required" && exit 1)
	@command -v kubectl >/dev/null || (echo "kubectl required" && exit 1)

Include Other Makefiles

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

# Include if exists (- prefix ignores errors)
-include local.mk

Recursive Make

1
2
3
4
5
6
7
SUBDIRS := cmd/app cmd/worker pkg/lib

.PHONY: all $(SUBDIRS)
all: $(SUBDIRS)

$(SUBDIRS):
	$(MAKE) -C $@

Parallel Execution

1
2
# Run targets in parallel
make -j4 build test lint
1
2
3
# Mark targets as parallelizable
.PHONY: all
all: build test lint  # These run in parallel with -j

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Continue on error (prefix with -)
clean:
	-rm -f *.o
	-docker rm -f mycontainer

# Silent (prefix with @)
version:
	@echo $(VERSION)

# Both
quiet-clean:
	-@rm -f *.tmp 2>/dev/null

Template 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
42
43
44
45
46
47
48
49
50
51
# === Configuration ===
APP_NAME := myapp
VERSION := $(shell git describe --tags --always --dirty)
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)

# === Directories ===
BIN_DIR := bin
SRC_DIR := src

# === Tools ===
GO := go
DOCKER := docker

# === Build Flags ===
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)"

# === Targets ===
.DEFAULT_GOAL := help

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

.PHONY: build
build: ## Build the application
	$(GO) build $(LDFLAGS) -o $(BIN_DIR)/$(APP_NAME) ./cmd/$(APP_NAME)

.PHONY: test
test: ## Run tests
	$(GO) test -race -cover ./...

.PHONY: lint
lint: ## Run linter
	golangci-lint run

.PHONY: clean
clean: ## Remove build artifacts
	rm -rf $(BIN_DIR)

.PHONY: run
run: build ## Build and run
	./$(BIN_DIR)/$(APP_NAME)

.PHONY: docker
docker: ## Build Docker image
	$(DOCKER) build -t $(APP_NAME):$(VERSION) .

.PHONY: all
all: lint test build ## Run full build pipeline

Common Gotchas

  1. Tabs, not spaces — Make requires tabs for indentation
  2. Shell per line — Each line runs in a new shell; use \ for continuation
  3. .PHONY matters — Without it, targets can conflict with filenames
  4. Variables expand late — Use := for immediate evaluation

Make is old, quirky, and has a learning curve. But once you know it, you have a task runner that works everywhere, requires no installation, and does exactly what you tell it. That’s worth the initial investment.