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
sudoprivileges. - 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:
SHELLand.SHELLFLAGSrun recipes with Bash and stop on errors, unset variables and failed pipes..DELETE_ON_ERRORremoves a half-written target file if its recipe fails, so the next run does not treat it as up to date.--warn-undefined-variablescatches typos in variable names, and--no-builtin-rulesdisables 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.PHONYand 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, socd diron one line does not affect the next. Chain them instead:cd dir && command. - Seeing what Make is doing:
make -n targetprints commands without running them, andmake --debug=b targetexplains 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).
