Make was designed for compiling C programs in 1976. Nearly 50 years later, it’s still one of the most practical automation tools available—not for its original purpose, but as a universal task runner.

Why Make in 2026?

It’s already installed. Every Unix system has make. No npm install, no pip, no version managers.

It’s declarative. Define what you want, not how to get there (with dependencies handled automatically).

It’s documented. make help can list all your targets. The Makefile itself is documentation.

It handles dependencies. Run only what needs to run, skip what’s already done.

Basic Structure

 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
# Makefile

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

# Variables
PROJECT := myapp
VERSION := $(shell git describe --tags --always)
DOCKER_IMAGE := $(PROJECT):$(VERSION)

# Phony targets don't create files
.PHONY: help build test deploy clean

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

build: ## Build the application
	go build -ldflags "-X main.Version=$(VERSION)" -o bin/$(PROJECT) .

test: ## Run tests
	go test -v ./...

clean: ## Remove build artifacts
	rm -rf bin/ dist/

Now make help shows:

bchtuleeielslaptdnBRSRuehuimonlowdvtetethshbiteussialhpdepllapirctaitfiaocnts

Docker Workflows

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
.PHONY: docker-build docker-push docker-run

REGISTRY := ghcr.io/myorg
IMAGE := $(REGISTRY)/$(PROJECT):$(VERSION)
IMAGE_LATEST := $(REGISTRY)/$(PROJECT):latest

docker-build: ## Build Docker image
	docker build -t $(IMAGE) -t $(IMAGE_LATEST) \
		--build-arg VERSION=$(VERSION) .

docker-push: docker-build ## Push to registry
	docker push $(IMAGE)
	docker push $(IMAGE_LATEST)

docker-run: ## Run container locally
	docker run --rm -it -p 8080:8080 $(IMAGE)

docker-shell: ## Shell into container
	docker run --rm -it --entrypoint /bin/sh $(IMAGE)

Development Environment

 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
.PHONY: dev setup deps lint fmt

# Check for required tools
REQUIRED_BINS := go docker kubectl
$(foreach bin,$(REQUIRED_BINS),\
	$(if $(shell command -v $(bin) 2> /dev/null),,\
		$(error Please install $(bin))))

setup: ## Initial project setup
	@echo "Installing dependencies..."
	go mod download
	@echo "Installing dev tools..."
	go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
	@echo "Setting up git hooks..."
	cp scripts/pre-commit .git/hooks/
	chmod +x .git/hooks/pre-commit

deps: ## Update dependencies
	go mod tidy
	go mod verify

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

fmt: ## Format code
	go fmt ./...
	goimports -w .

dev: ## Start development server with hot reload
	air -c .air.toml

Database Operations

 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
.PHONY: db-up db-down db-reset db-migrate db-seed

DB_CONTAINER := postgres-dev
DB_NAME := myapp_dev
DB_USER := dev
DB_PASS := devpass

db-up: ## Start database container
	@docker ps -q -f name=$(DB_CONTAINER) | grep -q . || \
		docker run -d --name $(DB_CONTAINER) \
			-e POSTGRES_DB=$(DB_NAME) \
			-e POSTGRES_USER=$(DB_USER) \
			-e POSTGRES_PASSWORD=$(DB_PASS) \
			-p 5432:5432 \
			postgres:15
	@echo "Waiting for database..."
	@until docker exec $(DB_CONTAINER) pg_isready -U $(DB_USER); do sleep 1; done

db-down: ## Stop database container
	docker stop $(DB_CONTAINER) || true
	docker rm $(DB_CONTAINER) || true

db-reset: db-down db-up db-migrate db-seed ## Reset database completely

db-migrate: db-up ## Run migrations
	migrate -path ./migrations -database "postgres://$(DB_USER):$(DB_PASS)@localhost:5432/$(DB_NAME)?sslmode=disable" up

db-seed: ## Seed development data
	psql "postgres://$(DB_USER):$(DB_PASS)@localhost:5432/$(DB_NAME)" -f seeds/dev.sql

Deployment Targets

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.PHONY: deploy deploy-staging deploy-prod rollback

KUBECTL := kubectl
NAMESPACE_STAGING := myapp-staging
NAMESPACE_PROD := myapp-prod

deploy-staging: docker-push ## Deploy to staging
	$(KUBECTL) -n $(NAMESPACE_STAGING) set image deployment/$(PROJECT) \
		$(PROJECT)=$(IMAGE)
	$(KUBECTL) -n $(NAMESPACE_STAGING) rollout status deployment/$(PROJECT)

deploy-prod: ## Deploy to production (requires confirmation)
	@echo "Deploying $(IMAGE) to PRODUCTION"
	@read -p "Are you sure? [y/N] " confirm && [ "$$confirm" = "y" ]
	$(KUBECTL) -n $(NAMESPACE_PROD) set image deployment/$(PROJECT) \
		$(PROJECT)=$(IMAGE)
	$(KUBECTL) -n $(NAMESPACE_PROD) rollout status deployment/$(PROJECT)

rollback: ## Rollback last deployment
	$(KUBECTL) -n $(NAMESPACE) rollout undo deployment/$(PROJECT)

logs: ## Tail production logs
	$(KUBECTL) -n $(NAMESPACE_PROD) logs -f -l app=$(PROJECT) --tail=100

Conditional Logic

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# Detect OS
UNAME := $(shell uname)
ifeq ($(UNAME), Darwin)
	SED := gsed
	OPEN := open
else
	SED := sed
	OPEN := xdg-open
endif

# Environment-specific config
ENV ?= development
ifeq ($(ENV), production)
	LDFLAGS += -s -w
	BUILD_TAGS := production
else
	BUILD_TAGS := development
endif

build:
	go build -tags $(BUILD_TAGS) -ldflags "$(LDFLAGS)" -o bin/$(PROJECT) .

File Dependencies (Make’s Superpower)

Make tracks file modification times. Use this for efficient builds:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Only rebuild binary if source changed
bin/myapp: $(shell find . -name '*.go')
	go build -o $@ .

# Only regenerate if schema changed
api/openapi.gen.go: api/openapi.yaml
	oapi-codegen -generate types,server -package api $< > $@

# Only rebuild proto if .proto files changed
%.pb.go: %.proto
	protoc --go_out=. --go-grpc_out=. $<

# Aggregate target
generate: api/openapi.gen.go $(patsubst %.proto,%.pb.go,$(wildcard proto/*.proto))

Parallel Execution

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Run tests in parallel
.PHONY: test-all test-unit test-integration test-e2e

test-all: ## Run all tests in parallel
	$(MAKE) -j3 test-unit test-integration test-e2e

test-unit:
	go test -v -short ./...

test-integration:
	go test -v -run Integration ./...

test-e2e:
	./scripts/e2e-tests.sh

Run with make test-all and all three test suites run simultaneously.

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
.PHONY: safe-deploy

safe-deploy: ## Deploy with automatic rollback on failure
	@echo "Creating backup..."
	$(KUBECTL) -n $(NAMESPACE) get deployment $(PROJECT) -o yaml > .deploy-backup.yaml
	@echo "Deploying..."
	$(KUBECTL) -n $(NAMESPACE) set image deployment/$(PROJECT) $(PROJECT)=$(IMAGE) || \
		(echo "Deploy failed, rolling back..." && \
		 $(KUBECTL) apply -f .deploy-backup.yaml && \
		 exit 1)
	@echo "Verifying..."
	$(KUBECTL) -n $(NAMESPACE) rollout status deployment/$(PROJECT) --timeout=120s || \
		(echo "Rollout failed, rolling back..." && \
		 $(KUBECTL) apply -f .deploy-backup.yaml && \
		 exit 1)
	@rm -f .deploy-backup.yaml
	@echo "Deploy successful!"

Include Other Makefiles

Split complex configs:

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

# Override included variables
DOCKER_BUILD_ARGS += --no-cache

Real-World Example

Complete Makefile for a typical web service:

 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
46
47
48
49
50
51
52
53
54
55
56
57
.DEFAULT_GOAL := help
SHELL := /bin/bash

# Project
PROJECT := myservice
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')

# Docker
REGISTRY := ghcr.io/myorg
IMAGE := $(REGISTRY)/$(PROJECT)

# Kubernetes
NAMESPACE ?= default
CONTEXT ?= $(shell kubectl config current-context)

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

.PHONY: info
info: ## Show build info
	@echo "Project:  $(PROJECT)"
	@echo "Version:  $(VERSION)"
	@echo "Commit:   $(COMMIT)"
	@echo "Context:  $(CONTEXT)"

.PHONY: build test lint docker-build deploy
build: ## Build binary
	CGO_ENABLED=0 go build \
		-ldflags "-X main.Version=$(VERSION) -X main.Commit=$(COMMIT)" \
		-o bin/$(PROJECT) .

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

lint: ## Run linters
	golangci-lint run

docker-build: ## Build Docker image
	docker build \
		--build-arg VERSION=$(VERSION) \
		--build-arg COMMIT=$(COMMIT) \
		-t $(IMAGE):$(VERSION) \
		-t $(IMAGE):latest .

deploy: docker-build ## Build and deploy
	docker push $(IMAGE):$(VERSION)
	kubectl -n $(NAMESPACE) set image deployment/$(PROJECT) $(PROJECT)=$(IMAGE):$(VERSION)
	kubectl -n $(NAMESPACE) rollout status deployment/$(PROJECT)

.PHONY: clean
clean: ## Clean build artifacts
	rm -rf bin/ dist/ coverage.out

Make isn’t sexy. It doesn’t have a package registry or a mascot. But it’s been solving the “how do I run this project” problem since before most of us were born, and it’ll keep solving it long after the current crop of task runners are forgotten.

Put a Makefile in your project. Your future self—and your teammates—will thank you.