DevPlainly
Developer Productivity

Makefiles for Modern Projects: A Practical Tutorial

Use a Makefile as your project's command menu: targets, phony targets, variables, dependencies, a self-documenting help target and a real-world example.

· 6 min read

Contents
  1. Why use make in 2026?
  2. Your first Makefile
  3. Phony targets
  4. Variables
  5. Dependencies between targets
  6. Real file targets: only rebuild what changed
  7. A self-documenting help target
  8. A complete example for a full-stack project
  9. Common pitfalls
  10. Using make in CI
  11. Alternatives
  12. FAQ
  13. Do I need make for a Python or JavaScript project?
  14. What is the difference between = and :=?
  15. Can I pass arguments to a make target?
  16. Further reading

make was created in 1976 to compile C programs, and it is still one of the most useful tools in a modern project — not for compiling, but as a shared command menu. Instead of remembering docker compose -f compose.dev.yaml up --build or a five-flag test command, everyone on the team types make dev or make test.

This tutorial covers what you need to write clean, reliable Makefiles for Python, JavaScript or any other kind of project.

Why use make in 2026?

  • It is already installed on Linux and macOS (install via Xcode command-line tools on macOS; on Windows use WSL, Git Bash with make, or a package manager such as Chocolatey or Scoop).
  • It works with any language. Your Python API and your React frontend can share the same command interface.
  • It documents your workflow. The Makefile is the list of things you can do in the project.
  • CI and local development run the same commands, so "it passed locally" means something.

Your first Makefile

Create a file named Makefile (capital M, no extension):

hello:
	echo "Hello from make"

Run it:

make hello

The structure is always:

target: prerequisites
	recipe

The recipe line must start with a tab, not spaces. This is the most common Makefile error: missing separator. Configure your editor to keep real tabs in Makefiles.

By default make prints each command before running it. Prefix a line with @ to hide the command and show only its output:

hello:
	@echo "Hello from make"

Phony targets

make was designed around files: a target is normally the name of a file to build, and make skips it if the file already exists and is up to date. If your project has a folder called test, then make test will say "test is up to date" and do nothing.

Declare command-style targets as phony so make always runs them:

.PHONY: test lint dev

test:
	pytest -q

lint:
	ruff check .

dev:
	uvicorn main:app --reload

Variables

PYTHON ?= python3
PORT ?= 8000
SRC := src tests

lint:
	$(PYTHON) -m ruff check $(SRC)

run:
	$(PYTHON) -m uvicorn main:app --port $(PORT)
  • ?= sets a default that can be overridden from the command line or the environment: make run PORT=9000.
  • := evaluates the value immediately (the usual choice).
  • Reference variables with $(NAME).

To use a shell variable inside a recipe, double the dollar sign: $$HOME. A single $ is for make variables.

Dependencies between targets

Prerequisites run first, which lets you chain steps:

.PHONY: install lint test check

install:
	pip install -r requirements.txt

lint:
	ruff check .

test:
	pytest -q

check: lint test
	@echo "All checks passed"

make check runs lint, then test, then prints the message. If any step fails (non-zero exit code), make stops.

Real file targets: only rebuild what changed

make's original superpower still helps. This target reinstalls dependencies only when requirements.txt changes:

.venv/installed: requirements.txt
	python3 -m venv .venv
	.venv/bin/pip install -r requirements.txt
	touch .venv/installed

.PHONY: test
test: .venv/installed
	.venv/bin/pytest -q

The first make test creates the environment. Later runs skip straight to the tests — until you edit requirements.txt, which makes the marker file out of date.

A self-documenting help target

Add a comment with ## after each target and a help target that prints them:

.DEFAULT_GOAL := help

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

.PHONY: dev
dev: ## Start the development server
	docker compose up --build

.PHONY: test
test: ## Run the test suite
	pytest -q

Now running plain make prints a neat list of commands with descriptions — new teammates discover the workflow without reading docs.

A complete example for a full-stack project

For a repository with a Python API in api/ and a Next.js frontend in web/:

.DEFAULT_GOAL := help
SHELL := /bin/bash

.PHONY: help install dev-api dev-web test lint build clean

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

install: ## Install all dependencies
	cd api && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
	cd web && npm ci

dev-api: ## Run the API with auto-reload
	cd api && .venv/bin/uvicorn main:app --reload --port 8000

dev-web: ## Run the frontend dev server
	cd web && npm run dev

test: ## Run all tests
	cd api && .venv/bin/pytest -q

lint: ## Lint backend and frontend
	cd api && .venv/bin/ruff check .
	cd web && npm run lint

build: ## Production build of the frontend
	cd web && npm run build

clean: ## Remove build artifacts
	rm -rf web/.next api/.pytest_cache

Note that each recipe line runs in its own shell, so cd api on one line does not affect the next line. That is why each line starts with cd … &&.

Common pitfalls

ProblemCauseFix
missing separator. Stop.Recipe indented with spacesUse a tab character
Nothing to be done for 'test'A file or folder named test existsAdd the target to .PHONY
cd seems ignoredEach line runs in a new shellUse cd dir && command on one line
$HOME is emptymake interpreted $H as a make variableWrite $$HOME
Works on Linux, fails on macOSmacOS ships an older GNU make (3.81)Avoid newer features or install a recent make with Homebrew (gmake)

Using make in CI

Because the Makefile is the single source of truth, your CI configuration stays short:

# .github/workflows/ci.yml (excerpt)
steps:
  - uses: actions/checkout@v4
  - run: make install
  - run: make lint
  - run: make test

Alternatives

If you dislike make's quirks, just is a popular command runner with similar syntax but no tab or file-target surprises, and npm scripts or Python task runners work well within a single ecosystem. make's advantage is that it is everywhere already.

FAQ

Do I need make for a Python or JavaScript project?

No, but it gives you one consistent interface across tools and languages, which becomes more valuable as a project grows.

What is the difference between = and :=?

= is evaluated every time the variable is used (lazily); := is evaluated once when defined. Prefer := unless you specifically need lazy evaluation.

Can I pass arguments to a make target?

Pass variables: make test ARGS="-k login", then use pytest $(ARGS) in the recipe.

Further reading

Keep reading