Makefile for Developers — Automate Tasks Without Bash Spaghetti
Make is a 1977 build tool that turns out to be a great project task runner. One Makefile, make test, done — no bash scripts to maintain, no dependencies to install.
You know how it goes. A project starts clean. Then someone adds a run.sh. Then build.sh. Then a deploy.sh that sources an .env and calls the first two in a specific order, and suddenly there are six shell files nobody wants to touch and a README that says “see the scripts folder.”
Make fixes that. One Makefile in the project root, make test, done.
This isn’t about C build systems. Make predates Linux and was originally designed for dependency-based compilation — but its core mechanic (named targets that run shell commands) turns it into a perfectly serviceable task runner for any stack. Node, Python, Go, Rust, Docker, whatever you’re building.
How Make actually works
A Makefile is a list of targets. Each target has a name, optional dependencies, and a shell command block:
.PHONY: build test lint clean
build:
npm run build
test:
npm test
Two things that trip up everyone on first contact:
- Indentation must be a real tab character, not spaces. Every editor that auto-converts tabs will silently break your Makefile until you configure it otherwise. This has been true since 1977 and Make will never forgive you for using spaces.
- By default, Make thinks target names are file names. If a file called
buildexists in your project root,make builddoes nothing because Make thinks the target is already “built.” The fix is.PHONY.
Declare every target that isn’t a real filename as .PHONY. In practice, task-runner Makefiles declare every target because none of them produce files. Your .PHONY line ends up looking like the first line of the template below.
Variables and command-line overrides
Make has its own variable syntax — looks like shell but behaves differently:
DOCKER_IMAGE = myapp
TAG = latest
build:
docker build -t $(DOCKER_IMAGE):$(TAG) .
Override from the command line: make build TAG=v1.2.3. No file editing needed for versioned builds or environment-specific deploys. Shell environment variables are also available automatically — $(HOME), $(PATH), whatever’s in your environment when you run make.
A ready-to-use Makefile template
Copy this, delete what doesn’t apply, adjust commands for your stack:
.PHONY: install build test lint clean run docker-up docker-down help
# --- Config -------------------------------------------------------------------
DOCKER_COMPOSE = docker compose
APP_NAME = myapp
# --- Default target -----------------------------------------------------------
help:
@echo "Available targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
# --- Dev ----------------------------------------------------------------------
install: ## Install dependencies
npm ci
run: ## Start the dev server
npm run dev
build: ## Build for production
npm run build
# --- Quality ------------------------------------------------------------------
lint: ## Run the linter
npm run lint
test: ## Run the test suite
npm test
test-watch: ## Run tests in watch mode
npm run test:watch
# --- Docker -------------------------------------------------------------------
docker-up: ## Start services via docker compose
$(DOCKER_COMPOSE) up -d
docker-down: ## Stop and remove containers
$(DOCKER_COMPOSE) down
docker-logs: ## Tail container logs
$(DOCKER_COMPOSE) logs -f
# --- Cleanup ------------------------------------------------------------------
clean: ## Remove build artifacts and caches
rm -rf dist node_modules/.cache .next
の help target uses a grep + awk pattern to pull inline ## comments into formatted documentation. Run make help and you get a sorted list of every target with its description — no separate docs to maintain. This is the most-stolen snippet in Makefile history for good reason.
Chaining targets for CI
Make handles dependencies natively. List targets as dependencies to run them in order:
ci: lint test build ## Full CI check (lint -> test -> build)
make ci runs lint, then test, then build. If any step exits non-zero, Make stops. This is correct CI behavior — fail loudly, don’t silently paper over the broken step.
Suppressing echo and writing multiline commands
By default, Make prints each command before running it. Prefix with @ to suppress:
setup:
@echo "Setting up project..."
@cp .env.example .env
@npm ci
@echo "Done."
For commands that span lines, chain with && — it stops on failure, unlike semicolons which keep going regardless:
migrate:
npm run db:migrate && \
npm run db:seed && \
echo "Migration complete"
When Make is the wrong tool
Make ships on macOS (via Xcode Command Line Tools) and every Linux distro. No install step, no version conflicts, zero friction for most dev teams.
Where it falls short:
- 窓 — WSL works fine, but native Windows doesn’t have make without Chocolatey, Scoop, or the GnuWin32 port. If your team is Windows-native, just is a close drop-in designed specifically for this gap.
- Complex logic — Make isn’t a programming language. Conditionals and loops exist but are genuinely ugly. If your build logic needs real branching, write a proper script.
- Cross-platform shell commands —
rm -rf,cp, and other Unix standbys don’t exist on native Windows. Task (Go-based) handles this with cross-platform command support built in.
For most backend and fullstack teams on Mac or Linux, Make is the pragmatic default. It’s boring in the best way — nothing to install, nothing to update, nothing that breaks in a dependency update.
Keeping your Makefile clean
As a Makefile grows across multiple contributors and merges, indentation and spacing drift. Since Make is whitespace-sensitive, a stray space instead of a tab silently breaks a target with no helpful error message. IO Tools’ Makefile formatter normalizes indentation and cleans up whitespace without touching the logic — useful as a pre-commit sanity check.
あなたも好きかもしれません
恵 スコアボードが到着しました!
スコアボード ゲームを追跡する楽しい方法です。すべてのデータはブラウザに保存されます。さらに多くの機能がまもなく登場します!
