Make and Makefiles
- lesson
- make
- dependency-graphs
- phony-targets
- pattern-rules
- devops-automation
- l1 ---# Make, Makefiles, and Why DevOps Still Uses a 1976 Tool
Topics: Make, dependency graphs, phony targets, pattern rules, DevOps automation Level: L1 (Foundations) Time: 45–60 minutes Prerequisites: Basic command line
The Mission¶
You clone a repo. The README says "run make test." You do. Tests run. Then make build.
A Docker image appears. make deploy. It deploys. You never installed a task runner,
learned a framework, or read a config file format. You just typed make and things happened.
Make is from 1976. It predates the internet, Linux, Python, Java, and most of computing. Why is it still everywhere? Because it does one thing perfectly: run commands based on dependencies, and skip work that's already done.
What Make Does¶
Make reads a Makefile (a recipe book) and executes targets (recipes):
# Makefile
test:
pytest tests/
build:
docker build -t myapp:latest .
deploy: build
kubectl set image deployment/myapp myapp=myapp:latest
clean:
rm -rf __pycache__ .pytest_cache dist/
make test # Runs pytest
make build # Builds the Docker image
make deploy # Builds THEN deploys (dependency: build)
make clean # Removes generated files
Name Origin: Make was created by Stuart Feldman at Bell Labs in 1976 after he wasted time debugging a program that was failing because he forgot to recompile a file. He wrote Make in a weekend. Forty-nine years later, it's installed on virtually every Unix system, used by the Linux kernel, and the foundation of most open-source project workflows.
The Dependency Graph¶
The power of Make is dependencies:
deploy: build test # deploy needs build AND test to pass first
build: lint # build needs lint to pass first
lint: # lint has no dependencies
test: # test has no dependencies
make deploy
→ make build (dependency)
→ make lint (dependency of build)
→ make test (dependency)
→ deploy (after both succeed)
Make builds a DAG (directed acyclic graph) and executes in the right order. If lint fails,
build doesn't run, and deploy doesn't run.
Phony Targets: The DevOps Pattern¶
In traditional Make, targets are files. make foo checks if the file foo exists and is
newer than its dependencies. If so, it's already "built" and nothing runs.
For DevOps tasks, targets aren't files — they're actions. Mark them .PHONY:
.PHONY: test build deploy clean help
test:
pytest tests/
build:
docker build -t myapp:latest .
deploy: build
kubectl set image deployment/myapp myapp=myapp:latest
clean:
rm -rf __pycache__ .pytest_cache
help:
@echo "Available targets:"
@echo " test - Run tests"
@echo " build - Build Docker image"
@echo " deploy - Build and deploy"
@echo " clean - Remove generated files"
Without .PHONY, if a file named test exists in your directory, make test says
"'test' is up to date" and does nothing. .PHONY tells Make these are always actions,
never file checks.
Variables and Conventions¶
# Variables (uppercase by convention)
IMAGE := myapp
TAG := $(shell git rev-parse --short HEAD)
REGISTRY := ghcr.io/myorg
# Use variables in targets
build:
docker build -t $(REGISTRY)/$(IMAGE):$(TAG) .
push: build
docker push $(REGISTRY)/$(IMAGE):$(TAG)
# Override from command line
# make build TAG=v1.2.3
| Syntax | Meaning |
|---|---|
VAR := value |
Simple assignment (evaluated once) |
VAR = value |
Recursive assignment (evaluated on use) |
$(VAR) |
Variable reference |
$(shell cmd) |
Run a shell command, capture output |
$@ |
Current target name |
$< |
First dependency |
$^ |
All dependencies |
@cmd |
Run cmd silently (don't print it) |
Gotcha: Makefile recipes MUST use tabs, not spaces. This is the most infamous design decision in Make's history. Feldman later said it was a mistake he couldn't fix because "there were already 10 users." Forty-nine years and billions of Makefiles later, we're still using tabs.
A Real DevOps Makefile¶
.PHONY: help test lint build push deploy clean
# Default target (first target is default)
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
IMAGE := myapp
TAG := $(shell git rev-parse --short HEAD)
REGISTRY := ghcr.io/myorg
lint: ## Run linters
ruff check app/
yamllint k8s/
test: ## Run tests
pytest tests/ --cov=app/ --cov-report=term-missing
build: lint ## Build Docker image
docker build -t $(REGISTRY)/$(IMAGE):$(TAG) .
push: build ## Push to registry
docker push $(REGISTRY)/$(IMAGE):$(TAG)
deploy: push ## Deploy to Kubernetes
kubectl set image deployment/$(IMAGE) $(IMAGE)=$(REGISTRY)/$(IMAGE):$(TAG)
kubectl rollout status deployment/$(IMAGE)
clean: ## Remove generated files
rm -rf __pycache__ .pytest_cache dist/ site/
docker image prune -f
make help
# help (this message)
# lint Run linters
# test Run tests
# build Build Docker image
# push Push to registry
# deploy Deploy to Kubernetes
# clean Remove generated files
The help target uses a clever grep to extract ## comments from targets — self-
documenting Makefiles.
Flashcard Check¶
Q1: Why is Make still used after 49 years?
It's installed everywhere, requires no dependencies, handles task dependencies naturally, and the interface is universal:
make target. No framework to learn.
Q2: What does .PHONY do?
Tells Make the target is an action, not a file. Without it,
make testdoes nothing if a file namedtestexists in the directory.
Q3: Makefile indentation — tabs or spaces?
Tabs. Only tabs. This is a 1976 design decision that can never be changed. Spaces in recipes cause "missing separator" errors.
Q4: make deploy has dependency build. What happens?
Make runs
buildfirst. Ifbuildfails,deploydoesn't run. Dependencies are resolved automatically as a DAG.
Cheat Sheet¶
Makefile Syntax¶
target: dependencies
command # MUST be tab-indented
command
.PHONY: target # Target is an action, not a file
Common Patterns¶
# Self-documenting help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
# Git-based versioning
TAG := $(shell git rev-parse --short HEAD)
# Run only if file changed
app.wasm: src/main.rs
cargo build --target wasm32-unknown-unknown --release
Takeaways¶
-
Make is the universal task runner. No installation, no config format to learn, installed on every Unix system.
make test,make build,make deploy. -
Dependencies are automatic.
deploy: build testmeans build and test must pass before deploy runs. Make figures out the order. -
.PHONYfor actions. DevOps targets are commands, not files. Always mark them. -
Tabs only. The most famous design mistake in computing. Use
.editorconfigto enforce tabs in Makefiles. -
Self-documenting with
##comments. Thehelptarget pattern is 3 lines and makes your Makefile usable by anyone.
Exercises¶
-
Write a basic Makefile. Create a
Makefilewith three phony targets:greet(prints "Hello, world"),shout(prints "HELLO, WORLD"), andallthat depends on both. Mark all targets as.PHONY. Runmake alland confirm both targets execute. Then create a file calledgreetin the same directory, remove the.PHONYdeclaration, and runmake greet— observe the "up to date" message. Restore.PHONYand confirm it runs again. -
Use variables and shell functions. Add a
TAGvariable set to$(shell date +%Y%m%d-%H%M%S)and anIMAGEvariable set tomyapp. Create abuildtarget that printsBuilding $(IMAGE):$(TAG). Runmake buildand confirm the timestamp appears. Then override from the command line:make build TAG=v1.0.0. Confirm the override takes effect. -
Create a self-documenting help target. Add
## Commentannotations to each target (e.g.,build: ## Build the project). Add ahelptarget that uses@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'. Makehelpthe first target (so it's the default). Runmakewith no arguments and confirm the help text appears. -
Build a dependency chain. Create targets
lint,test,build, anddeploywheredeploydepends onbuild,builddepends onlintandtest. Add an intentional failure inlint(e.g.,false). Runmake deployand confirm thatbuildanddeploynever execute. Fixlint, run again, and confirm the full chain completes. -
Use file-based targets for conditional rebuilds. Create a target
output.txtthat depends oninput.txtand runscp input.txt output.txt. Createinput.txtwith some content. Runmake output.txttwice — the second run should say "up to date." Modifyinput.txtand run again — it should rebuild. This demonstrates Make's original purpose: skip work when outputs are newer than inputs.
Related Lessons¶
- What Happens When You
git pushto CI — CI pipelines are Make for the cloud - What Happens When You
docker build— Make often wraps Docker builds - Why Everything Uses JSON Now — Make predates all modern config formats