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:
b c h t u l e e i e l s l a p t d n B R S R u e h u i m o n l o w d v t e t e t h s h b i t e u s s i a l h p d e p l l a p i r c t a i t f i a o c n t s
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 -e 2e
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.