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.