Skip to content

Makefile Reference — targets, .PHONY, automatic variables, pattern rules, and functions

GNU Make is ubiquitous in backend and DevOps repos as a task runner. The mental model: a target depends on prerequisites; if any prerequisite is newer than the target, Make rebuilds. For software projects that aren’t C/C++, the dependency tracking is mostly irrelevant — you use Make as a documented command runner with tab-completion and self-describing help targets.

⚠️ Recipe lines MUST use a real tab character, not spaces. Most editor “show whitespace” settings help. If you get Makefile:N: *** missing separator. Stop. — that’s spaces where a tab should be.

1. Anatomy of a Makefile

Targets, prerequisites, recipes, variables, and .PHONY
# Basic structure:
# target: prerequisites
# [TAB] recipe

# Variables (= lazy, := immediate, ?= default-if-unset):
APP_NAME   := myapp
BUILD_DIR  := ./build
GO_FLAGS   ?= -v
VERSION    := $(shell git describe --tags --always --dirty)

# .PHONY: tell Make these aren't files (prevents conflicts with same-named files):
.PHONY: build test clean lint docker help

# Default target (runs when you just type `make`):
.DEFAULT_GOAL := help

build:  ## Build the application binary
	go build $(GO_FLAGS) -ldflags "-X main.Version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/main.go

test:   ## Run all tests
	go test ./... -race -cover -coverprofile=coverage.out

lint:   ## Run linter
	golangci-lint run ./...

clean:  ## Remove build artifacts
	rm -rf $(BUILD_DIR)
	go clean -testcache

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

2. Variables, Functions & Conditionals

Automatic variables ($@, $<, $^), built-in functions, and conditional blocks
# Automatic variables (only available inside recipes):
# $@  = current target name
# $<  = first prerequisite
# $^  = all prerequisites (space-separated, no duplicates)
# $*  = stem of pattern rule (% match)
# $?  = prerequisites newer than target

# Pattern rule example:
%.o: %.c
	gcc -c $< -o $@    # $< = the .c file, $@ = the .o file

# Built-in functions:
SOURCES  := $(wildcard src/*.go)              # glob
OBJECTS  := $(patsubst src/%.go,obj/%.o,$(SOURCES)) # replace pattern
UPPER    := $(shell echo "hello" | tr a-z A-Z)      # shell command
FILES    := $(filter %.go, $(SOURCES))        # filter by pattern
COUNT    := $(words $(SOURCES))               # count words
FIRST    := $(firstword $(SOURCES))           # first item

# String functions:
MSG     := hello world
TRIMMED := $(strip   $(MSG))    # trim whitespace
UPPER   := $(subst world,Deno,$(MSG))    # "hello Deno"

# Conditional blocks:
OS := $(shell uname -s)

ifeq ($(OS),Darwin)
  OPEN_CMD := open
else
  OPEN_CMD := xdg-open
endif

ifdef VERBOSE
  Q :=
else
  Q := @     # @ silences the command echo
endif

build:
	$(Q)go build ./...   # silent unless VERBOSE=1

3. Real-World Patterns for Software Projects

Docker builds, version tagging, multi-target chains, and parallel execution
# VERSION from git (semver-compatible):
VERSION  := $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
GIT_SHA  := $(shell git rev-parse --short HEAD)
REGISTRY := ghcr.io/myorg
IMAGE    := $(REGISTRY)/myapp:$(VERSION)

docker-build:  ## Build Docker image
	docker build \
		--build-arg VERSION=$(VERSION) \
		--build-arg GIT_SHA=$(GIT_SHA) \
		-t $(IMAGE) \
		-t $(REGISTRY)/myapp:latest \
		.

docker-push: docker-build  ## Push to registry (builds first)
	docker push $(IMAGE)
	docker push $(REGISTRY)/myapp:latest

# Multi-environment deploy:
NAMESPACE ?= staging

deploy:  ## Deploy to NAMESPACE (default: staging). Use NAMESPACE=production for prod.
	kubectl set image deployment/myapp myapp=$(IMAGE) -n $(NAMESPACE)
	kubectl rollout status deployment/myapp -n $(NAMESPACE)

# Chain targets with dependencies:
release: lint test docker-push deploy  ## Full release pipeline

# Parallel execution:
test-parallel:
	$(MAKE) -j4 test-unit test-integration test-e2e test-lint

# Include other Makefiles (split large Makefiles):
include ./scripts/docker.mk
include ./scripts/k8s.mk

# Pass variables to sub-makes:
all:
	$(MAKE) -C subdir VAR=value

4. DevOps & CI Patterns

Generate files, check prerequisites, env file loading, and GitHub Actions integration
# Check required tools before running:
REQUIRED_BINS := docker kubectl helm jq
$(foreach bin,$(REQUIRED_BINS),\
    $(if $(shell command -v $(bin) 2>/dev/null),,\
        $(error "Required binary not found: $(bin)")))

# Load .env file if it exists:
-include .env          # leading - = ignore if file missing
export                 # export all variables to child processes

# Generate files (e.g. from templates):
config/app.yaml: config/app.yaml.tpl .env
	envsubst < $< > $@   # $< = template, $@ = output

# Force rebuild of generated file:
.PHONY: config/app.yaml   # mark as phony to always regenerate

# Timestamp-based guard (run only if source changed):
.build/deps-installed: go.mod go.sum
	go mod download
	mkdir -p .build && touch $@   # update sentinel file timestamp

install: .build/deps-installed   # deps only re-run if go.mod changed

# GitHub Actions integration:
ci: lint test build  ## Run full CI suite locally (matches GitHub Actions)

# Run in Docker for reproducible builds:
docker-run:  ## Run dev server in Docker
	docker run --rm -it \
		-v $(PWD):/app \
		-p 8080:8080 \
		--env-file .env \
		golang:1.22 \
		sh -c "cd /app && make dev"

5. Python, Node.js & Polyglot Project Makefiles

Makefiles for non-C projects: Python venv management, Node.js scripts, and monorepos
# Python project (manage venv, pip-compile, formatting):
PYTHON    := python3
VENV      := .venv
ACTIVATE  := . $(VENV)/bin/activate

$(VENV)/bin/activate: requirements.in
	$(PYTHON) -m venv $(VENV)
	$(ACTIVATE) && pip install pip-tools
	$(ACTIVATE) && pip-compile requirements.in
	$(ACTIVATE) && pip install -r requirements.txt
	touch $(VENV)/bin/activate   # mark as fresh

install: $(VENV)/bin/activate  ## Install Python deps (only if requirements.in changed)

run: install   ## Run the app
	$(ACTIVATE) && python main.py

test: install   ## Run tests
	$(ACTIVATE) && pytest tests/ -v --cov=src

fmt:   ## Format code
	$(ACTIVATE) && black . && isort .

# Node.js project:
node_modules: package.json package-lock.json
	npm ci
	touch node_modules   # sentinel

install: node_modules  ## Install Node deps

build: node_modules   ## Build
	npm run build

dev:   ## Start dev server
	npm run dev

# Monorepo: run make in subdirectories:
SERVICES := api worker scheduler

$(SERVICES):
	$(MAKE) -C services/$@ $(TARGET)

build-all: $(SERVICES)  ## Build all services. TARGET=test to run tests.
# Usage: make build-all
# Usage: make build-all TARGET=test

Track Go, Python, and toolchain releases at ReleaseRun. Related: Go Modules Reference | GitHub Actions Reference | Docker Compose Reference

🔍 Free tool: Go Module Health Checker — if your Makefile automates Go builds, check your module dependencies for EOL status and known CVEs.

Founded

2023 in London, UK

Contact

hello@releaserun.com