Make gets overlooked because people think it’s for compiling C code. In reality, it’s a universal task runner with one killer feature: it only runs what needs to run.

Basic Structure

1
2
target: dependencies
	command

That’s it. The target is what you’re building, dependencies are what it needs, and commands run to create it.

Important: Commands must be indented with a tab, not spaces.

Simple Task Runner

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

build:
	npm run build

test:
	npm test

deploy: build test
	rsync -avz dist/ server:/var/www/

clean:
	rm -rf dist/ node_modules/

.PHONY tells Make these aren’t real files—just task names.

Run with:

1
2
3
4
make build
make test
make deploy  # Runs build and test first
make clean

Variables

1
2
3
4
5
6
7
8
9
PROJECT = myapp
VERSION = 1.0.0
DOCKER_IMAGE = $(PROJECT):$(VERSION)

build:
	docker build -t $(DOCKER_IMAGE) .

push:
	docker push $(DOCKER_IMAGE)

Override from command line:

1
make build VERSION=2.0.0

Environment Variables

1
2
3
4
5
# Use env var with fallback
DATABASE_URL ?= postgres://localhost/dev

migrate:
	DATABASE_URL=$(DATABASE_URL) ./migrate.sh

The ?= only sets if not already defined.

Conditional Logic

1
2
3
4
5
6
7
8
ifdef CI
    FLAGS = --ci --coverage
else
    FLAGS = --watch
endif

test:
	npm test $(FLAGS)

Default Target

First target is default. Or be explicit:

1
2
3
4
5
6
7
.DEFAULT_GOAL := help

help:
	@echo "Available targets:"
	@echo "  build  - Build the project"
	@echo "  test   - Run tests"
	@echo "  deploy - Deploy to production"

The @ suppresses command echo.

Real File Dependencies

Make shines when tracking actual files:

1
2
3
4
5
dist/bundle.js: src/*.js package.json
	npm run build

deploy: dist/bundle.js
	rsync -avz dist/ server:/var/www/

If src/*.js haven’t changed since last build, make deploy skips the build step.

Pattern Rules

1
2
3
4
5
6
7
8
9
# Convert all .md files to .html
%.html: %.md
	pandoc $< -o $@

# Build all HTML files
SOURCES = $(wildcard *.md)
TARGETS = $(SOURCES:.md=.html)

all: $(TARGETS)

$< is the first dependency, $@ is the target.

Multi-line Commands

Each line runs in a separate shell. For multi-line:

1
2
3
4
5
deploy:
	@echo "Deploying..." && \
	cd dist && \
	rsync -avz . server:/var/www/ && \
	echo "Done"

Or use .ONESHELL:

1
2
3
4
5
6
.ONESHELL:
deploy:
	echo "Deploying..."
	cd dist
	rsync -avz . server:/var/www/
	echo "Done"

Error Handling

1
2
3
4
5
6
7
8
9
# Continue even if command fails
clean:
	-rm -rf dist/
	-rm -rf node_modules/

# Stop on first error (default)
build:
	npm install
	npm run build

The - prefix ignores errors.

Parallel Execution

1
2
3
4
5
6
7
8
9
.PHONY: all frontend backend

all: frontend backend

frontend:
	cd frontend && npm run build

backend:
	cd backend && go build
1
make -j2 all  # Run frontend and backend in parallel

Self-Documenting Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.PHONY: help
help: ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

build: ## Build the project
	npm run build

test: ## Run tests
	npm test

deploy: ## Deploy to production
	./deploy.sh

clean: ## Remove build artifacts
	rm -rf dist/
1
2
3
4
5
$ make help
  build           Build the project
  test            Run tests
  deploy          Deploy to production
  clean           Remove build artifacts

Include Other Makefiles

1
2
3
4
5
include config.mk
include docker.mk

# Or optional include (no error if missing)
-include local.mk

Real-World Example

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
.PHONY: all build test lint deploy clean help
.DEFAULT_GOAL := help

# Config
APP_NAME := myapp
VERSION := $(shell git describe --tags --always)
DOCKER_IMAGE := $(APP_NAME):$(VERSION)
DOCKER_REGISTRY := registry.example.com

# Go settings
GOCMD := go
GOBUILD := $(GOCMD) build
GOTEST := $(GOCMD) test

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

build: ## Build the binary
	$(GOBUILD) -ldflags "-X main.Version=$(VERSION)" -o bin/$(APP_NAME) .

test: ## Run tests
	$(GOTEST) -v ./...

lint: ## Run linter
	golangci-lint run

docker-build: ## Build Docker image
	docker build -t $(DOCKER_IMAGE) .

docker-push: docker-build ## Push Docker image
	docker tag $(DOCKER_IMAGE) $(DOCKER_REGISTRY)/$(DOCKER_IMAGE)
	docker push $(DOCKER_REGISTRY)/$(DOCKER_IMAGE)

deploy: docker-push ## Deploy to Kubernetes
	kubectl set image deployment/$(APP_NAME) $(APP_NAME)=$(DOCKER_REGISTRY)/$(DOCKER_IMAGE)

clean: ## Clean build artifacts
	rm -rf bin/
	docker rmi $(DOCKER_IMAGE) 2>/dev/null || true

dev: ## Run in development mode
	$(GOCMD) run . --dev

all: lint test build ## Run lint, test, and build

Why Make Over npm Scripts or Shell Scripts?

  • Dependency tracking: Only rebuilds what changed
  • Parallel execution: make -j4 runs independent tasks concurrently
  • Universal: Works on any Unix system, no runtime needed
  • Self-documenting: With the help pattern above
  • Composable: Include and layer Makefiles

For simple task running, Make does exactly what you need without the ceremony of more complex build tools.