GNU Make was built to compile C programs, but it is just as useful as a task runner: a single Makefile in the root of a project gives every developer and CI job the same short commands (make test, make deploy) instead of long shell incantations kept in a README. In this tutorial you will write a Makefile for a small Python project on Ubuntu 24.04 that creates a virtual environment only when needed, runs tests and a linter, wraps Docker Compose and deploys to a server with rsync.

Prerequisites

To follow this tutorial you need:

  • A machine or server running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Basic familiarity with the shell.
  • Optional: Docker Engine with the Compose plugin for Step 6, and SSH access to a remote server for Step 7.

Step 1 - Installing make and preparing a sample project

Ubuntu 24.04 ships GNU Make 4.3 in the make package. Install it together with Python's virtual environment module, which the sample project uses:

sudo apt update
sudo apt install make python3-venv rsync

Check the installed version:

make --version
GNU Make 4.3
Built for x86_64-pc-linux-gnu
...

Create a small project to automate. It contains one module, one test and a list of development dependencies:

mkdir -p ~/myapp/tests && cd ~/myapp
cat > app.py <<'EOF'
def add(a, b):
    return a + b
EOF
cat > tests/test_app.py <<'EOF'
from app import add


def test_add():
    assert add(2, 3) == 5
EOF
printf 'pytest\nruff\n' > requirements-dev.txt

Step 2 - Writing your first target

A Makefile is a list of rules. Each rule has a target, optional prerequisites after the colon, and a recipe: the shell commands that produce the target. Create the file:

nano Makefile

Add a first rule:

hello:
	echo "Hello from make"

The recipe line must start with a tab character, not spaces. This is the most common Makefile error. Run the target:

make hello
echo "Hello from make"
Hello from make

Make prints each command before running it. Prefix a recipe line with @ to hide the echo. If you indented with spaces, you will see this instead:

Makefile:2: *** missing separator.  Stop.

Fix it by replacing the leading spaces with a tab. To check, run cat -A Makefile: tabs appear as ^I.

Step 3 - Setting safe defaults and variables

By default, Make runs each recipe line in a separate /bin/sh and ignores pipe failures. Replace the contents of Makefile with a header that makes recipes stricter, then define the variables the rest of the file will use:

SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
.DELETE_ON_ERROR:
MAKEFLAGS += --warn-undefined-variables --no-builtin-rules

VENV   := .venv
PYTHON := $(VENV)/bin/python
PIP    := $(VENV)/bin/pip

What each line does:

  • SHELL and .SHELLFLAGS run recipes with Bash and stop on errors, unset variables and failed pipes.
  • .DELETE_ON_ERROR removes a half-written target file if its recipe fails, so the next run does not treat it as up to date.
  • --warn-undefined-variables catches typos in variable names, and --no-builtin-rules disables C compilation rules you do not need.
  • := assigns a value once, when the line is read. Use ?= for values the user may override from the environment or the command line (you will do this in Step 7).

Step 4 - Building the virtual environment only when needed

Make's real strength is dependency tracking: a file target is rebuilt only when it does not exist or when one of its prerequisites is newer. Use that to create the virtual environment only when requirements-dev.txt changes. Append to Makefile:

$(VENV)/bin/activate: requirements-dev.txt
	python3 -m venv $(VENV)
	$(PIP) install --upgrade pip
	$(PIP) install -r requirements-dev.txt
	touch $@

.PHONY: venv
venv: $(VENV)/bin/activate

$@ is an automatic variable that expands to the target name, and touch $@ updates its timestamp so it is newer than the requirements file. venv is a convenient alias; it is listed under .PHONY because it does not correspond to a real file.

Run it twice:

make venv
make venv

The first run creates .venv and installs pytest and ruff. The second returns immediately:

make: Nothing to be done for 'venv'.

Edit requirements-dev.txt (or run touch requirements-dev.txt) and make venv will reinstall.

Step 5 - Adding test, lint and clean targets

Now add the everyday tasks. Each depends on the virtual environment, so a fresh clone works with a single command. Append:

.PHONY: test
test: venv ## Run the test suite
	$(PYTHON) -m pytest -q

.PHONY: lint
lint: venv ## Check code style with ruff
	$(VENV)/bin/ruff check .

.PHONY: check
check: lint test ## Run lint and tests

.PHONY: clean
clean: ## Remove the virtualenv and caches
	rm -rf $(VENV) .pytest_cache .ruff_cache
	find . -type d -name __pycache__ -prune -exec rm -rf {} +

Declaring targets as .PHONY matters: without it, a file or directory called test in the project would make make test report "up to date" and skip your tests.

Run the combined target:

make check
.venv/bin/ruff check .
All checks passed!
.venv/bin/python -m pytest -q
.                                                                        [100%]
1 passed in 0.01s

If any command fails, Make stops and exits with a non-zero status, which is exactly what CI pipelines need.

Step 6 - Wrapping Docker Compose commands

If the project runs with Docker Compose, put the commands behind short targets so nobody has to remember flags. Append:

COMPOSE := docker compose

.PHONY: up
up: ## Start the stack in the background
	$(COMPOSE) up -d --build

.PHONY: down
down: ## Stop the stack
	$(COMPOSE) down

.PHONY: logs
logs: ## Follow container logs
	$(COMPOSE) logs -f

These targets need a compose.yaml in the project; if you do not use Docker, skip this step.

Step 7 - Adding a deploy target with overridable variables

A deploy target is a good example of variables the caller should be able to change. Using ?= lets you set them per environment on the command line or from environment variables. Append:

DEPLOY_USER ?= deploy
DEPLOY_HOST ?=
DEPLOY_PATH ?= /srv/myapp

.PHONY: deploy
deploy: check ## Test, then rsync the project to DEPLOY_HOST
	@test -n "$(DEPLOY_HOST)" || { echo "Set DEPLOY_HOST, e.g. make deploy DEPLOY_HOST=your_server_ip"; exit 1; }
	rsync -az --delete --exclude '.venv' --exclude '.git' ./ $(DEPLOY_USER)@$(DEPLOY_HOST):$(DEPLOY_PATH)/

Because deploy depends on check, a failing test or lint error blocks the deployment. Try it without a host first:

make deploy
Set DEPLOY_HOST, e.g. make deploy DEPLOY_HOST=your_server_ip

Make then reports the recipe error and exits with status 2. Now deploy for real, replacing your_server_ip with your server's address. The remote user must exist and own DEPLOY_PATH:

make deploy DEPLOY_HOST=your_server_ip

Use make -n deploy DEPLOY_HOST=your_server_ip (dry run) to print the commands without executing them.

Step 8 - Making the Makefile self-documenting

The ## comment after each target is not decoration: a help target can extract those comments into a menu. Add this near the top of Makefile, right after the variables, so it becomes the default goal:

.DEFAULT_GOAL := help

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

Inside a recipe, $$ produces a literal $ for awk. Run make with no arguments:

make
  help       Show this help
  test       Run the test suite
  lint       Check code style with ruff
  check      Run lint and tests
  clean      Remove the virtualenv and caches
  up         Start the stack in the background
  down       Stop the stack
  logs       Follow container logs
  deploy     Test, then rsync the project to DEPLOY_HOST

Troubleshooting

  • missing separator. Stop.: a recipe line is indented with spaces. Replace them with a tab.
  • make: 'test' is up to date.: the target is missing from .PHONY and a file or directory with that name exists.
  • A variable is empty inside a recipe: shell variables need $$VAR; $(VAR) refers to a Make variable. Also remember each recipe line runs in its own shell, so cd dir on one line does not affect the next. Chain them instead: cd dir && command.
  • Seeing what Make is doing: make -n target prints commands without running them, and make --debug=b target explains why each target is rebuilt.

Conclusion

You now have a Makefile that installs dependencies only when they change, runs tests and linting, wraps Docker Compose, refuses to deploy broken code and documents itself with make help. As next steps, call make check from your CI pipeline so local and CI runs are identical, run independent targets in parallel with make -j4, and split large Makefiles with include directives per area (for example infra.mk for Terraform tasks).