Why I Started Using Makefiles for Every Project (Even When It Feels Wrong)

I resisted Makefiles for years. They felt archaic — a build tool from 1976, designed for compiling C programs, full of weird syntax rules about tabs versus spaces. Every time I saw one in a JavaScript project I rolled my eyes a little. We have package.json scripts. We have npm run. Why drag a fossil into a modern codebase?

Then I joined a team where every repo had a Makefile, and within two weeks I understood. It was not about building C. It was about having a single, predictable entry point for every project, regardless of what language or framework it used.

The Real Problem Make Solves

Every project accumulates a pile of commands you are supposed to remember. Start the dev server. Run the tests. Lint the code. Build the Docker image. Deploy to staging. Migrate the database. Each command is slightly different across projects because someone named things differently in package.json, or because this project uses docker compose and that one uses docker-compose, or because the Python project uses poetry and the other one uses pipenv.

A Makefile gives every project the same interface. make dev starts the dev server. make test runs the tests. make build builds the container. You don’t need to remember whether it’s npm run dev or npm start or npm run serve. You don’t need to know that the database migration command is alembic upgrade head in this project and python manage.py migrate in that one. You just type make migrate and it works.

This matters more the more projects you touch. If you work in a monorepo with three services in different languages, or if you switch between work projects and personal projects, or if you come back to a project after six months and need to remember how to start it — the Makefile is there. make dev. Done.

The Targets I Use Everywhere

After a year of this habit, the targets have settled into a pattern. Here are the ones that earn their keep, with examples for a typical Node.js project:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.PHONY: dev test build lint clean deploy

dev:
npm run dev

test:
npm test

lint:
npx eslint .
npx prettier --check .

build:
docker build -t my-app .

clean:
rm -rf node_modules dist

deploy:
./scripts/deploy.sh

The .PHONY line is Make-speak for “these are not real files.” Without it, if you happened to have a directory called dev, Make would see that dev already exists and skip the target. .PHONY tells Make to always run the command.

For a Python project, the same pattern:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.PHONY: dev test lint build clean migrate

dev:
poetry run uvicorn app.main:app --reload

test:
poetry run pytest -v

lint:
poetry run ruff check .
poetry run mypy .

build:
docker build -t my-api .

clean:
find . -type d -name __pycache__ -exec rm -rf {} +
rm -rf .pytest_cache .mypy_cache

migrate:
poetry run alembic upgrade head

The point is not that these are complex. The point is that they are the same across projects. make lint always lints. make test always tests. The implementation differs — the interface does not.

The One Weird Trick: Self-Documenting Makefiles

Here is something I wish I had learned sooner. You can make a Makefile self-documenting with a tiny bit of shell magic. Add this target at the top:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.PHONY: help

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

dev: ## Start development server
npm run dev

test: ## Run all tests
npm test

lint: ## Lint and format check
npx eslint . && npx prettier --check .

Now make (or make help) prints every target with its description. You get a mini-CLI with built-in documentation, for free. This single trick made Makefiles go from “annoying legacy tool” to “why would I not use this” in my head.

When Makefiles Get in the Way

I am not going to pretend Make is perfect. The tab-versus-space thing is genuinely annoying and has wasted hours of my life. If your editor converts tabs to spaces, your Makefile silently breaks with an inscrutable error message. The solution: configure your editor to use real tabs for Makefiles, or add a .editorconfig rule.

Make also struggles with complex build pipelines. If you have a multi-step build with caching, incremental compilation, and conditional logic, you probably want a dedicated build tool rather than a Makefile that grows to 300 lines of shell script. But for the 90% case — start, test, lint, build, clean, deploy — Make hits a sweet spot that nothing else quite matches.

Make does not replace package.json scripts or Python tooling. It wraps them. Think of the Makefile as the outermost interface layer — the thing a new team member types into first, before they need to know anything about the project internals. That is the real value.

If you have not tried it, add a four-target Makefile to your current project right now: dev, test, lint, build. It takes two minutes. See if it changes how the project feels to enter.


Why I Started Using Makefiles for Every Project (Even When It Feels Wrong)
https://toongs.org/blog/2026/05/29/21-makefiles-for-everything/
Author
Jain Chen
Posted on
May 29, 2026
Licensed under