Makefiles Are Not Just for C

Every project accumulates a collection of commands. Build this, test that, deploy here, clean up there. Most people throw these into npm scripts, a shell script, or a README that nobody reads. I put them in a Makefile.

Not because I write C. Because Make is a task runner that is already installed on every machine.

The Basics

.PHONY: build test deploy clean

build:
	docker compose build

test:
	docker compose run --rm app npm test

deploy: build
	rsync -a --delete ./dist/ server:/opt/app/
	ssh server "cd /opt/app && docker compose up -d"

clean:
	docker compose down -v
	rm -rf dist node_modules

Now:

make build
make test
make deploy
make clean

.PHONY tells Make these targets are commands, not files. Without it, if you have a directory called build, Make would say “build is up to date” and do nothing.

Dependencies

The power of Make is in the dependency graph. deploy depends on build:

deploy: build
	rsync -a --delete ./dist/ server:/opt/app/

When you run make deploy, it runs build first. If build fails, deploy never runs. This is what npm scripts cannot do cleanly.

Variables

SERVER := myserver
REMOTE_DIR := /opt/app
IMAGE := myapp:latest

build:
	docker build -t $(IMAGE) .

push: build
	docker push $(IMAGE)

deploy: push
	ssh $(SERVER) "cd $(REMOTE_DIR) && docker pull $(IMAGE) && docker compose up -d"

Override from the command line:

make deploy SERVER=staging-server

Multi-Stage Builds With File Targets

Make tracks file timestamps. This is useful for caching:

node_modules: package.json package-lock.json
	npm ci
	touch node_modules

dist: node_modules $(shell find src -type f)
	npm run build

.PHONY: serve
serve: dist
	python3 -m http.server 8080 -d dist

node_modules only rebuilds if package.json or package-lock.json changed. dist only rebuilds if source files changed. Make compares file timestamps automatically.

Help Target

Add a self-documenting help target:

.DEFAULT_GOAL := help

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

build: ## Build the Docker image
	docker compose build

test: ## Run the test suite
	docker compose run --rm app npm test

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

clean: ## Remove all build artifacts
	rm -rf dist node_modules

Now make with no arguments prints:

  build           Build the Docker image
  test            Run the test suite
  deploy          Deploy to production
  clean           Remove all build artifacts

Environment-Specific Configs

ENV ?= development

ifeq ($(ENV),production)
  COMPOSE_FILE := docker-compose.yml -f docker-compose.prod.yml
else
  COMPOSE_FILE := docker-compose.yml
endif

up:
	docker compose $(addprefix -f ,$(COMPOSE_FILE)) up -d

# Usage:
# make up           → development
# make up ENV=production  → production

Parallel Execution

Make can run independent targets in parallel:

make -j4 lint test typecheck

As long as these targets do not depend on each other, Make runs all four simultaneously. This is free parallelism with no extra scripting.

A Real Project Makefile

.PHONY: help dev build test lint deploy clean
.DEFAULT_GOAL := help

IMAGE := myapp
TAG := $(shell git rev-parse --short HEAD)

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

dev: ## Start development server
	docker compose up -d
	docker compose logs -f app

build: ## Build production image
	docker build -t $(IMAGE):$(TAG) -t $(IMAGE):latest .

test: ## Run tests
	docker compose run --rm app npm test

lint: ## Run linter
	docker compose run --rm app npm run lint

deploy: build ## Deploy to production
	docker push $(IMAGE):$(TAG)
	ssh server "docker pull $(IMAGE):$(TAG) && docker compose up -d"
	@echo "Deployed $(TAG)"

clean: ## Clean up everything
	docker compose down -v
	docker image prune -f

The Makefile is not trendy. It does not have a package ecosystem or a plugin system. But it works everywhere, it handles dependencies, it supports parallelism, and it does not require installing anything. For project automation, that is hard to beat.

← all articles wleeaf.dev →