Makefiles are ancient. They’re also incredibly useful for modern development. Here’s how to use them as your project’s command center.

Why Make in 2026?

Every project has commands you run repeatedly:

  • Start development servers
  • Run tests
  • Build containers
  • Deploy to environments
  • Format and lint code

You could remember them all. Or document them in a README that gets stale. Or put them in a Makefile where they’re executable documentation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
.PHONY: dev test build deploy

dev:
	docker-compose up -d
	npm run dev

test:
	pytest -v

build:
	docker build -t myapp:latest .

deploy:
	kubectl apply -f k8s/

Now make dev starts everything. New team member? Run make help.

The Self-Documenting Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.DEFAULT_GOAL := help

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

.PHONY: dev
dev: ## Start development environment
	docker-compose up -d

.PHONY: test
test: ## Run test suite
	pytest -v --cov=src

.PHONY: lint
lint: ## Run linters
	ruff check src/
	mypy src/

.PHONY: build
build: ## Build production container
	docker build -t myapp:$$(git rev-parse --short HEAD) .

Running make with no arguments now prints:

dtlbeeiuvsnittldSRRBtuuuannirlttldeidsnpettrveoesrdlusuoicpttmeieonntceonnvtiarionnemrent

Variables and Defaults

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Defaults that can be overridden
IMAGE_NAME ?= myapp
IMAGE_TAG ?= $(shell git rev-parse --short HEAD)
REGISTRY ?= ghcr.io/myorg

# Derived values
FULL_IMAGE = $(REGISTRY)/$(IMAGE_NAME):$(IMAGE_TAG)

.PHONY: build
build: ## Build container image
	docker build -t $(FULL_IMAGE) .

.PHONY: push
push: build ## Build and push to registry
	docker push $(FULL_IMAGE)

Override at runtime:

1
2
make build IMAGE_TAG=v1.2.3
make push REGISTRY=docker.io/myuser

Environment-Aware Targets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
ENV ?= development

.PHONY: deploy
deploy: ## Deploy to environment (ENV=development|staging|production)
ifeq ($(ENV),production)
	@echo "Deploying to PRODUCTION - are you sure?"
	@read -p "Type 'yes' to continue: " confirm && [ "$$confirm" = "yes" ]
endif
	kubectl apply -k k8s/overlays/$(ENV)/

.PHONY: logs
logs: ## Tail logs for environment
	kubectl logs -f -l app=myapp --namespace=$(ENV)

Dependency Chains

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Build artifacts with dependencies
dist/app.js: src/*.ts package.json
	npm run build

# Only rebuild when sources change
.PHONY: build
build: dist/app.js

# Clean removes artifacts
.PHONY: clean
clean:
	rm -rf dist/ node_modules/ .pytest_cache/

# Fresh build
.PHONY: rebuild
rebuild: clean build

Grouped Targets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Composite targets for common workflows
.PHONY: ci
ci: lint test build ## Run full CI pipeline

.PHONY: setup
setup: ## Initial project setup
	python -m venv .venv
	.venv/bin/pip install -r requirements.txt
	.venv/bin/pip install -r requirements-dev.txt
	cp .env.example .env
	@echo "Setup complete. Run 'source .venv/bin/activate'"

.PHONY: reset
reset: clean setup ## Clean slate reset

Include Files for Organization

1
2
3
4
5
6
# Makefile
include make/docker.mk
include make/test.mk
include make/deploy.mk

.DEFAULT_GOAL := help
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# make/docker.mk
.PHONY: docker-build docker-push docker-run

docker-build:
	docker build -t $(IMAGE) .

docker-push: docker-build
	docker push $(IMAGE)

docker-run:
	docker run -it --rm -p 8080:8080 $(IMAGE)

Shell Commands and Scripting

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Multi-line commands (note the semicolons and backslashes)
.PHONY: check-tools
check-tools:
	@command -v docker >/dev/null 2>&1 || { echo "docker required"; exit 1; }
	@command -v kubectl >/dev/null 2>&1 || { echo "kubectl required"; exit 1; }
	@echo "All tools available"

# Capture command output
VERSION := $(shell cat VERSION)
GIT_SHA := $(shell git rev-parse --short HEAD)
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)

.PHONY: version
version:
	@echo "Version: $(VERSION)"
	@echo "Git SHA: $(GIT_SHA)"
	@echo "Built:   $(BUILD_TIME)"

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
.PHONY: safe-deploy
safe-deploy:
	# Stop on first error (default behavior)
	kubectl apply -f k8s/configmap.yaml
	kubectl apply -f k8s/deployment.yaml
	kubectl rollout status deployment/myapp

.PHONY: best-effort
best-effort:
	# Continue despite errors (- prefix)
	-kubectl delete configmap old-config
	-kubectl delete secret old-secret
	kubectl apply -f k8s/

Platform Detection

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
UNAME := $(shell uname)

ifeq ($(UNAME),Darwin)
    SED := gsed
    OPEN := open
else
    SED := sed
    OPEN := xdg-open
endif

.PHONY: docs
docs:
	mkdocs build
	$(OPEN) site/index.html

Parallel Execution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Run independent targets in parallel
.PHONY: ci-parallel
ci-parallel:
	$(MAKE) -j3 lint test build

# Or mark targets as parallelizable
.PHONY: lint-all
lint-all: lint-python lint-js lint-yaml

lint-python:
	ruff check .

lint-js:
	eslint src/

lint-yaml:
	yamllint .

Run with: make -j4 lint-all

Common Patterns

Database Tasks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.PHONY: db-migrate db-rollback db-seed db-reset

db-migrate:
	alembic upgrade head

db-rollback:
	alembic downgrade -1

db-seed:
	python scripts/seed_data.py

db-reset: ## Reset database (DANGEROUS)
	@read -p "This will DELETE all data. Continue? [y/N] " confirm && [ "$$confirm" = "y" ]
	alembic downgrade base
	alembic upgrade head
	$(MAKE) db-seed

Secret Management

1
2
3
4
5
6
7
8
.PHONY: secrets-encrypt secrets-decrypt

secrets-encrypt:
	sops --encrypt --in-place secrets/$(ENV).yaml

secrets-decrypt:
	sops --decrypt secrets/$(ENV).yaml > .secrets.yaml
	@echo "Decrypted to .secrets.yaml (git-ignored)"

Local vs CI Detection

1
2
3
4
5
6
7
ifdef CI
    DOCKER_OPTS := --quiet
    TEST_OPTS := --no-header
else
    DOCKER_OPTS :=
    TEST_OPTS := -v
endif

The Principles

  1. Make is for humans: Optimize for discoverability
  2. Self-documenting: make help should explain everything
  3. Composable: Small targets that chain together
  4. Override-friendly: Sensible defaults, easy to customize
  5. Cross-platform aware: Handle macOS/Linux differences

The best automation is the kind your team actually uses. Make makes it easy.