Every few months I see someone post about their new project scaffolding tool – some npm package that wraps common tasks, or a Justfile, or a Taskfile. I’ve tried most of them. I keep coming back to Make.
Not because Make is perfect. It has real problems I’ll get to. But because it’s already installed everywhere, has zero runtime dependencies, and everyone who’s worked on a non-trivial software project has a reasonable chance of knowing how it works.
Make as a task runner, not a build tool
Most people learn Make in the context of C/C++ compilation, where it tracks file timestamps to decide what to recompile. That’s powerful, but it’s not why I use it. I use it as a simple command runner – a documented, aliased interface to the shell commands I’d otherwise have to type or remember from a README.
The GNU Make manual is comprehensive if you want to go deep, but you don’t need most of it for this use case. You need targets, variables, and .PHONY.
Here’s a Makefile I use for a typical web project (Node backend, some Docker):
.PHONY: install dev build test lint docker-up docker-down clean
NODE_ENV ?= development
install:
npm ci
dev:
NODE_ENV=$(NODE_ENV) npm run dev
build:
NODE_ENV=production npm run build
test:
npm test
lint:
npm run lint
docker-up:
docker compose up -d
docker-down:
docker compose down
clean:
rm -rf node_modules dist .next
That’s it. Now anyone cloning this repo runs make install, make dev, make build without having to know which npm scripts exist or what flags they take. The Makefile is a discoverable interface. Run make with no arguments and you get a list of available targets (if you add a default help target – I’ll come back to that).
A few patterns I find useful
Adding a help target that parses comments keeps the interface self-documenting:
help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
dev: ## Start local dev server
npm run dev
build: ## Production build
NODE_ENV=production npm run build
Now make help prints a formatted list. Small thing, but it means I don’t have to open the Makefile or a README to remember what’s available.
Variables with defaults are handy for environment-sensitive commands:
PORT ?= 3000
HOST ?= localhost
serve:
python3 -m http.server $(PORT) --bind $(HOST)
PORT=8080 make serve overrides the default. This is simpler than writing a shell script with argument parsing.
The honest downsides
Make’s syntax is genuinely bad. Tabs-not-spaces is an infuriating requirement that has broken countless Makefiles when someone’s editor converted them. The implicit rules system is arcane. String manipulation is painful compared to any real scripting language. And the way Make interprets targets as filenames means you have to explicitly mark non-file targets with .PHONY, which is easy to forget.
Cross-platform is also a real limitation. On macOS, the default make is an old BSD version; you often need gmake for certain syntax. On Windows, you’re installing WSL or a compatibility layer. If your project needs to run on Windows machines without WSL, Makefile as task runner is probably the wrong call.
For complex pipelines – conditional logic, loops, file-generating tasks – I’ll reach for a shell script or a proper build tool. Make is not the answer to everything.
Another issue: recursive Make is a mess. If you have a monorepo and try to call $(MAKE) -C subdir from a top-level Makefile to run sub-project tasks, things get complicated fast. Dependency tracking between subdirectories doesn’t work the way you’d want. For monorepos I’d look at something else – Nx, Turborepo, or just writing explicit shell scripts. Make shines in simpler structures.
But for the common case – a list of 10-20 tasks that wrap CLI commands, used by a team comfortable with Linux – it’s hard to beat the combination of zero setup cost and universal availability. I switched away from it once for a Taskfile experiment and switched back six months later. The extra expressiveness wasn’t worth the “wait, what tool does this project use again?” friction.