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.