$linuxjunkies
>

Write a Makefile for Anything (Not Just C)

Learn how to write a Makefile for any project—targets, prerequisites, .PHONY, variables, pattern rules, and the classic gotchas explained with real examples.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 1, 2026

Before you start

  • Basic comfort with the Linux terminal and shell commands
  • GNU Make 4.x installed (make --version to check)
  • A text editor configured to insert literal tabs in Makefiles

Make is older than most of the people reading this, yet it quietly drives builds, deployments, data pipelines, and doc generation across the entire industry. The reason is simple: Make expresses a dependency graph and only reruns work that is out of date. That property is useful far beyond compiling C code. This guide teaches you the mechanics of make so you can wire together any project—Python, Node, Rust, Markdown, Terraform, whatever—with a clean, portable Makefile.

Anatomy of a Rule

Every Makefile is a collection of rules. A rule has three parts:

target: prerequisites
	recipe
  • target – the file you want to build, or a phony action name.
  • prerequisites – files (or other targets) that must be up to date first.
  • recipe – one or more shell commands, each prefixed with a real Tab character (not spaces—this is Make's most famous gotcha).

When you run make target, Make checks whether the target file is older than any of its prerequisites. If it is—or if the target does not exist—the recipe runs. Otherwise, Make prints Nothing to be done and exits. That timestamp-based skip is the entire value proposition.

A Minimal Real-World Example

Here is a Makefile that converts a Markdown file to HTML using pandoc, then bundles a Python virtual environment and runs tests. It has nothing to do with C.

VENV     := .venv
PYTHON   := $(VENV)/bin/python
SOURCES  := $(wildcard src/*.md)
HTML     := $(patsubst src/%.md, dist/%.html, $(SOURCES))

all: html test

html: $(HTML)

dist/%.html: src/%.md
	mkdir -p dist
	pandoc -o $@ $<

$(PYTHON): requirements.txt
	python3 -m venv $(VENV)
	$(VENV)/bin/pip install -q -r requirements.txt
	touch $(PYTHON)

test: $(PYTHON)
	$(PYTHON) -m pytest tests/

.PHONY: all html test clean

clean:
	rm -rf dist $(VENV)

Work through each concept that example uses—that is the rest of this guide.

Variables

Variables avoid repetition and make a Makefile easy to override from the command line.

Defining and expanding

# Lazy (recursive) — re-evaluated every time it is used
CC = gcc

# Immediate (simple) — evaluated once at definition time
OBJDIR := build

Prefer := for anything that calls a shell function like $(wildcard ...) or $(shell ...). Recursive variables that invoke shell commands repeatedly cause subtle bugs and slow builds.

Automatic variables (the useful three)

VariableExpands to
$@The target name
$<The first prerequisite
$^All prerequisites (deduplicated)

These only work inside a recipe line. You will use $@ and $< constantly in pattern rules.

Overriding from the CLI

make test PYTHON=python3.12

Any variable set on the command line overrides the Makefile definition, which is handy for CI pipelines that need to swap in different tools.

Targets and Prerequisites

Prerequisites can be other targets, not just files. Make resolves the dependency graph recursively before running any recipe.

deploy: build lint
	scp -r dist/ user@server:/var/www/

build: $(HTML)
lint: $(PYTHON)
	$(PYTHON) -m ruff src/

Running make deploy will walk the tree: it builds HTML, creates the venv if needed, runs the linter, and only then deploys. If the HTML files are already up to date, that step is skipped automatically.

.PHONY Targets

By default Make assumes every target is a file name. If a file called clean or test exists in your project directory, Make will wrongly conclude the target is up to date and do nothing. Declaring targets as .PHONY tells Make to always run their recipes regardless of filesystem state.

.PHONY: clean test lint deploy all

As a rule of thumb: if a target does not produce a file with the exact same name as the target, mark it .PHONY. Forgetting this is a classic gotcha, especially for targets like test, docs, or format.

Pattern Rules

Instead of writing one rule per file, a pattern rule uses % as a wildcard that matches the same string in both target and prerequisite.

# Compile every .ts file in src/ into a .js file in dist/
dist/%.js: src/%.ts
	mkdir -p dist
	npx tsc --outDir dist $<

Combine with $(wildcard) to discover source files and $(patsubst) to derive target names:

TS_SRC  := $(wildcard src/*.ts)
JS_OUT  := $(patsubst src/%.ts, dist/%.js, $(TS_SRC))

js: $(JS_OUT)

Running make js builds only the .js files whose corresponding .ts sources have changed since the last build.

Common Gotchas

Tabs, not spaces

Recipe lines must start with a literal tab character. Most editors default to converting tabs to spaces. In Vim, :set noexpandtab before editing a Makefile. In VS Code, the status bar shows the indent mode; click it and choose Indent Using Tabs for Makefile files, or add this to settings.json:

# .editorconfig — works across editors
[Makefile]
indent_style = tab

Each recipe line runs in its own shell

Variables set in one line are not visible in the next. To chain commands, join them with && or use a backslash continuation:

deploy:
	cd infrastructure && terraform apply -auto-approve

Silent and error-suppressed lines

Prefix a recipe line with @ to suppress printing the command itself (useful for echo). Prefix with - to continue even if the command fails:

clean:
	@echo "Cleaning up…"
	-rm -rf dist/

Make choosing the wrong default target

Make runs the first target in the file by default. Conventionally name it all and put it at the top—or explicitly set:

.DEFAULT_GOAL := all

Installing Make

Debian / Ubuntu

sudo apt install make

Fedora / RHEL / Rocky

sudo dnf install make

Arch Linux

sudo pacman -S make

On most systems make is already present or arrives as part of a build-essentials meta-package. Check with make --version; you want GNU Make 4.x on a modern system.

Verification

After writing your Makefile, run a dry-run to see what would execute without actually running anything:

make -n all

Check the dependency graph for a specific target:

make -nd test 2>&1 | grep -E 'Must|older|newer|remake'

Force a full rebuild regardless of timestamps:

make -B all

Troubleshooting

  • "missing separator" — A recipe line uses spaces instead of a tab. Open the file in a hex viewer (cat -A Makefile | grep -n '^ ') to spot offending lines.
  • Target always rebuilds — The target file name does not match what the recipe actually creates, so the file never exists as far as Make is concerned. Check for typos or add .PHONY if appropriate.
  • Circular dependency error — Target A depends on B which depends on A. Draw the graph on paper; one of those dependencies is wrong.
  • Nothing to be done (unexpectedly) — A file named exactly like your target exists in the working directory. Either delete the file, or add the target to .PHONY.
  • Environment variables not visible — Make does export its variables to child shells, but variables defined inside a recipe do not propagate between lines. Join the lines with &&.
tested on:Ubuntu 24.04Fedora 40Arch rollingDebian 12

Frequently asked questions

Can I use spaces instead of tabs in recipes?
No. GNU Make requires a literal tab character to start each recipe line. This is Make's most notorious gotcha—configure your editor to preserve tabs in Makefiles.
Does Make work well for non-file targets like running tests or deploying?
Yes, but you must declare them .PHONY. Without that declaration, if a file with the same name exists, Make skips the recipe thinking the target is already up to date.
How do I pass arguments or environment variables to a make target?
Set them on the command line: make deploy ENV=production. Command-line variable assignments override Makefile definitions and are visible to all recipes in that run.
Why does my shell variable set in one recipe line disappear on the next line?
Each recipe line runs in its own separate shell process. Join dependent commands with && on a single line, or use backslash line continuations to keep them in one shell.
Is there a difference between GNU Make and BSD Make?
Yes—syntax for some advanced features like pattern rules and certain functions differs. This guide targets GNU Make, which is standard on Linux. macOS ships BSD Make; install GNU Make via Homebrew if you need cross-platform consistency.

Related guides