How to Use Makefiles to Automate Project Tasks With Examples
Use a Makefile to automate project tasks: targets, phony rules, variables, dependencies and a help target, with the tab and shell gotchas explained.

Every project accumulates a handful of commands that people forget: how to run the tests, build the image, lint the code. A Makefile gives each a short name and keeps them in the repository. It is the oldest tool for this job, available nearly everywhere, and the alternatives in just and Taskfile trade some availability for a nicer syntax. The examples assume GNU Make 4.4.
A working Makefile
.DEFAULT_GOAL := help
IMAGE ?= myapp
TAG ?= $(shell git rev-parse --short HEAD)
.PHONY: help build test lint clean
help: ## List the available targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk -F':.*?## ' '{printf "%-10s %s\n", $$1, $$2}'
build: ## Build the container image
docker build -t $(IMAGE):$(TAG) .
test: ## Run the test suite
python -m pytest -q
lint: ## Run the linters
shellcheck scripts/*.sh
clean: ## Remove build output
rm -rf dist .pytest_cache
The recipe lines begin with a tab. The code block is marked as plain text because the format has no highlighter here. .DEFAULT_GOAL makes make alone print the help. ?= sets a variable only if it is not already set, so make build IMAGE=other overrides it. A leading @ hides the command echo.
Run it
make
make test
make build TAG=devhelp List the available targets
build Build the container image
test Run the test suite
lint Run the linters
clean Remove build output
Make runs recipes from the top, stops on the first failing line and returns its status, which suits CI. Use make -n to print the commands without running them. make -j4 builds independent targets in parallel.
Dependencies and phony targets
dist/app.tar.gz: $(wildcard src/*) Makefile
mkdir -p dist
tar -czf $@ src
deploy: dist/app.tar.gz
scp dist/app.tar.gz host:/srv/
A target that is a file is rebuilt only when a dependency is newer, which is the original purpose of make. $@ is the target name. Task targets with no file, like test, need .PHONY; without it, a file named test in the directory makes make test report "up to date" and do nothing.
Shell gotchas
Every recipe line runs in a fresh /bin/sh, so cd subdir on one line has no effect on the next. Join them: cd subdir && ./run.sh. Or add .ONESHELL: to run the whole recipe in one shell. Shell variables need a doubled dollar, $$HOME, since make expands single ones itself. Set SHELL := /bin/bash and .SHELLFLAGS := -eu -o pipefail -c if recipes use Bash features and should fail on errors in pipelines. These details are the main reason the make basics are worth learning before depending on the tool.
Targets, prerequisites and recipes
A rule has three parts. The target is a name, usually a file the rule produces. The prerequisites are what it depends on, and the recipe is the list of shell commands, indented with a tab, that build it. Make compares modification times: if the target is missing or older than a prerequisite, it runs the recipe. That mechanism is why Make suits builds, where recompiling only what changed saves time. For automation tasks that produce no file, such as test or deploy, the target name is not a file, and Make would skip it if a file with that name appeared. Declaring such targets with .PHONY tells Make to run them every time.
Automatic variables shorten recipes: $@ is the target, $< the first prerequisite and $^ all of them. Pattern rules with % describe how to build a family of files from another family.
Variables and where values come from
Variables set with = are expanded each time they are used, and := expands once at assignment, which is usually what you want for values computed with $(shell ...). The conditional assignment ?= sets a value only if none exists, so users can override defaults from the environment or the command line: make deploy ENV=staging beats a default in the file. Command line values override the file unless you use override. Keep configuration in variables at the top of the Makefile and document them in the help target, so users know what they can change.
A help target that prints the available targets with descriptions is cheap and valuable. A common trick greps the Makefile for comments marked with a double hash and prints them in aligned columns.
Recipe details that cause confusion
Each line of a recipe runs in its own shell, so cd on one line does not affect the next. Join commands with && and a backslash, or set .ONESHELL so the whole recipe runs in one shell, in GNU Make 3.82 and later. A dollar sign for the shell must be doubled, $$VAR, because Make expands single dollars itself. Make echoes each command before running it, and an @ prefix suppresses the echo. A non-zero exit status stops the build, and a leading minus ignores errors for that line. Setting SHELL := bash and .SHELLFLAGS := -eu -o pipefail -c makes recipes fail on errors inside pipelines and on unset variables, which the default sh -c does not.
Tabs are required before recipe lines. An editor that converts tabs to spaces gives the cryptic "missing separator" error; set the editor to use real tabs for this file type, or change the recipe prefix with .RECIPEPREFIX.
When to graduate to something else
Make is everywhere and costs nothing to install, which makes it a good default for small projects. Its limits show as the file grows: the syntax is dense, error messages are terse, argument passing is awkward and portability between GNU and BSD Make requires care. Use a variable for the Make program itself, call $(MAKE) for recursive invocations and avoid GNU-only features if the project must build on other systems. When recipes need arguments, conditionals and readable help, a task runner like just or Task is a better fit, as discussed in the neighbouring post. Keep the real logic in scripts, let the Makefile name the entry points, and document the targets in the README. A Makefile that fits on a screen and calls well-tested scripts will serve a project for years.
Include a few habitual targets in every project: help, test, lint, build and clean. Contributors then find the same entry points in every repository, and CI can call the same names. Keep clean conservative: remove only build outputs by name, never with a glob that could match source files, and never run a recursive removal on a variable that might be empty. A recipe such as rm -rf $(BUILD_DIR)/ with an unset variable removes the root of the file system on a careless day, so guard the variable with a default or a check.
Where this does not apply
A Makefile is awkward for tasks with arguments, and its quoting rules and tabs trip up newcomers. macOS ships an older GNU Make 3.81 that lacks features such as .ONESHELL; install a current one with Homebrew as gmake. Windows needs extra setup. When tasks rarely depend on files, a plain scripts/ directory or a task runner is clearer. See the GNU Make manual and the Makefile and Docker scripts post.
The phony targets section of the manual explains why the declaration matters, and community style guides suggest strict shell flags in every Makefile that runs scripts.


