Skip to content

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 test does nothing if a file named test exists 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 build first. If build fails, deploy doesn'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

  1. Make is the universal task runner. No installation, no config format to learn, installed on every Unix system. make test, make build, make deploy.

  2. Dependencies are automatic. deploy: build test means build and test must pass before deploy runs. Make figures out the order.

  3. .PHONY for actions. DevOps targets are commands, not files. Always mark them.

  4. Tabs only. The most famous design mistake in computing. Use .editorconfig to enforce tabs in Makefiles.

  5. Self-documenting with ## comments. The help target pattern is 3 lines and makes your Makefile usable by anyone.


Exercises

  1. Write a basic Makefile. Create a Makefile with three phony targets: greet (prints "Hello, world"), shout (prints "HELLO, WORLD"), and all that depends on both. Mark all targets as .PHONY. Run make all and confirm both targets execute. Then create a file called greet in the same directory, remove the .PHONY declaration, and run make greet — observe the "up to date" message. Restore .PHONY and confirm it runs again.

  2. Use variables and shell functions. Add a TAG variable set to $(shell date +%Y%m%d-%H%M%S) and an IMAGE variable set to myapp. Create a build target that prints Building $(IMAGE):$(TAG). Run make build and confirm the timestamp appears. Then override from the command line: make build TAG=v1.0.0. Confirm the override takes effect.

  3. Create a self-documenting help target. Add ## Comment annotations to each target (e.g., build: ## Build the project). Add a help target that uses @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'. Make help the first target (so it's the default). Run make with no arguments and confirm the help text appears.

  4. Build a dependency chain. Create targets lint, test, build, and deploy where deploy depends on build, build depends on lint and test. Add an intentional failure in lint (e.g., false). Run make deploy and confirm that build and deploy never execute. Fix lint, run again, and confirm the full chain completes.

  5. Use file-based targets for conditional rebuilds. Create a target output.txt that depends on input.txt and runs cp input.txt output.txt. Create input.txt with some content. Run make output.txt twice — the second run should say "up to date." Modify input.txt and run again — it should rebuild. This demonstrates Make's original purpose: skip work when outputs are newer than inputs.


  • What Happens When You git push to 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