Every side project I've ever shipped has a Makefile. Not because I'm nostalgic or some kind of Unix purist — because after ten years of trying every alternative, Make is still the one that works without surprising me.
I've used npm scripts, rake, just, task, bazel, pants, and whatever the cool kids are using this month. They all have merit. But I keep coming back to Make, and I want to explain why — not to convince you to switch, but because understanding why Make works will make you better at whatever tool you choose.
The Tab Thing
Let's get the elephant out of the room. Yes, Makefiles require tabs for recipe indentation. It's the most complained-about design decision in the history of build tools. Stuart Feldman, who created Make at Bell Labs in 1976, later said he'd fix it if he could, but by the time he realized it was a problem, he already had a dozen users and didn't want to break compatibility.
I used to hate this. Now I see it as a feature, not a bug — it forces you to be explicit about what's a dependency and what's an action. The tab is Make saying "this line runs, it doesn't declare." And honestly, if your editor isn't configured to handle this by now, that's an editor problem, not a Make problem.
# A Makefile for my Ghost blog
.PHONY: dev build deploy backup
dev:
docker compose up
build:
docker compose build
deploy: build
ssh vps "cd /var/www/ghost && docker compose pull && docker compose up -d"
backup:
ssh vps "cd /var/www/ghost && docker compose exec ghost /bin/bash -c 'sqlite3 content/data/ghost.db .dump'" \
> backup/ghost-$$(date +%Y%m%d).sql
Look at that. Four commands, zero dependencies beyond what's already on the server. No Node, no Python, no gem. Just Make and bash. That's the whole point.
Why Make Outlasted Everything Else
Make was first used at Bell Labs in 1975. That's over fifty years ago. GNU Make 4.4.1, the current stable release, is still actively maintained. The reason Make survives isn't inertia — it's that it solves a fundamental problem really well: only rebuild what changed.
This sounds simple until you've wasted fifteen minutes rebuilding an entire Docker image because you changed one line in a config file and your build script couldn't tell what depended on what.
# Make understands dependencies natively
site: content/*.md templates/*.html
python build.py
content/%.html: content/%.md
pandoc -f markdown -t html $< -o $@
That second rule is a pattern rule — it tells Make how to turn any .md file into the corresponding .html file. If only about.md changed, only about.html gets rebuilt. Try doing that in an npm script without reaching for a separate tool.
The Alternatives I've Tried
I'm not dogmatic about this. I've genuinely tried the alternatives:
- npm scripts — fine if your project is already Node. But they're just strings in a JSON file. No dependency tracking, no parallelism, no pattern rules. The moment you need more than
&&-chained commands, you're writing shell scripts in your package.json and pretending it's a build system. - just — a modern Make-like tool with better syntax. I like it! But now I need to install just on every machine, every CI runner, every Docker image. Make is already there.
- Taskfile — YAML-based, more readable than Make. Same problem: it's not pre-installed on anything.
- Bazel/pants — incredible for monorepos at Google scale. Absolute overkill for side projects. I spent more time configuring Bazel than I saved on builds.
The pattern is always the same: I start with something "simpler," add features until it's as complex as Make, and then wish I'd just used Make from the start.
Where Make Actually Shines
1. Self-documenting Projects
Run make with no arguments in any of my projects and you get a list of available targets. Every new developer on the team knows exactly what they can do without reading a README that's probably outdated.
.DEFAULT_GOAL := help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
setup: ## Install dependencies
pip install -r requirements.txt
npm install
test: ## Run the test suite
pytest tests/ -v
lint: ## Check code style
ruff check . && eslint .
This pattern — where make help shows every target with its description — is so useful that I copy it into every project. It's the first thing I add, before any code.
2. Docker Compose Orchestration
I run my Ghost blog, my monitoring stack, and half a dozen other services on a single VPS. Each has a Makefile that wraps the Docker Compose commands I actually use. Because docker compose up is six syllables, and make up is two.
# Ghost blog Makefile (simplified)
.PHONY: up down logs backup update
up:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f ghost
backup: ## Dump the database
mkdir -p backups
docker compose exec db mysqldump -u root -p"$$MYSQL_ROOT_PASSWORD" ghost \
| gzip > backups/ghost-$$(date +%Y%m%d).sql.gz
update: ## Pull latest image and restart
docker compose pull && docker compose up -d
Notice how backup and update do real, multi-step operations? That's where Make beats raw docker compose commands. I don't have to remember the exact mysqldump incantation — it's saved in the Makefile, and I can run it from any directory.
3. Cross-language Projects
My side projects are a mess of languages — Python for ML scripts, JavaScript for the frontend, shell scripts for deployment, and now some Rust for performance-critical bits. Each language has its own build tool. Make is the only one that can orchestrate all of them without bias.
# A polyglot project Makefile
.PHONY: all frontend backend deploy
all: frontend backend
frontend:
npm run build
backend:
cargo build --release
deploy: all
./scripts/deploy.sh
No package manager wars. No "but we're a Python project, why are we using npm?" debates. Make doesn't care what language your code is written in. It cares about what depends on what and what needs to be built.
When I Don't Use Make
I'm not going to pretend Make is perfect for everything:
- Frontend-only projects — if everything is JavaScript and the build is just webpack/vite, npm scripts are fine. Adding a Makefile would be ceremony.
- CI pipelines — GitHub Actions, GitLab CI, etc. are their own thing. I'll reference Make targets from CI, but I won't try to replace CI with Make.
- Windows development — Make on Windows is a pain. If your team is all on Windows, use something else. I develop on Linux and deploy to Linux, so this isn't an issue for me.
The key is: Make earns its place when you have heterogeneous dependencies and need a universal orchestrator. If you're working in a single ecosystem, its native tool is probably fine.
The Boring Technology Argument
There's a broader principle here that I keep coming back to: boring technology is underrated. Make is boring. It's been boring for fifty years. And it's still here while five generations of "improved" build tools have come and gone.
I wrote about this when I talked about self-hosting my infrastructure — I run Ghost (not the latest JavaScript framework for static sites), I use Docker Compose (not Kubernetes), and I manage it with Makefiles (not a CI/CD platform). Each choice is boring. Each choice works. Each choice I can debug at 3am when something breaks without reading six pages of documentation.
The same principle applies to my camera gear. My Sony A7R II is a decade-old camera that I bought second-hand. It doesn't have built-in GPS (I geotag with a phone track log), it doesn't have the latest autofocus, and it's not the highest-resolution option anymore. But it takes beautiful photos and I know it inside out. The best tool is the one you understand deeply.
Starting Your Makefile
If I've convinced you to give Make a try, here's how I structure every new Makefile:
# The structure I use for every project
.PHONY: help install test lint run clean
help: ## Show available targets (this is always first)
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
install: ## Set up the project
pip install -r requirements.txt
test: ## Run tests
pytest -v
lint: ## Check code quality
ruff check .
run: ## Start the development server
python app.py
clean: ## Remove build artifacts
rm -rf __pycache__ .pytest_cache *.egg-info
That's it. Five targets, five things every project needs. From there I add targets as the project grows. The Makefile becomes a living document of how to work with the project.
And if you ever find yourself typing the same command more than twice, add it to the Makefile. That's the whole workflow. No ceremony, no plugins, no ecosystem. Just targets and recipes, like it's 1976.
Because some things are boring for a reason: they work.