Makefile:4: *** missing separator. Stop.
Nearly everyone's first Makefile dies here. The cause is almost never line 4's logic — it's that your editor inserted spaces where Make demands a literal tab. Recipe lines must begin with a tab character, full stop. Configure your editor to keep tabs in Makefile, or you'll fight this on every new file.
Here's a Makefile that avoids the common traps:
.PHONY: test lint deploy
test:
pnpm test
lint:
pnpm lint
deploy:
cd infra && terraform apply -auto-approve
.PHONY matters more than it looks. Without it, make test checks for a file named test; if one ever exists (a test/ directory counts on some setups), Make decides the target is "up to date" and runs nothing. Declaring the target phony says "this is a command, not a file."
The gotcha that wastes an afternoon
Each recipe line runs in its own shell. This does not work:
release:
cd dist
./upload.sh # runs in the ORIGINAL directory, not dist/
The cd applies to a subshell that exits at the end of the line. The next line starts back where you began. Chain them, or set .ONESHELL::
release:
cd dist && ./upload.sh
The same rule bites with shell variables. $HOME in a recipe is read by Make as a variable and expands to empty; you need $$HOME to pass a literal $ through to the shell.
Make vs just vs npm scripts
| Make | just | npm scripts | |
|---|---|---|---|
| Preinstalled | Yes, on macOS/Linux | No | With Node |
| Tab-sensitive syntax | Yes | No | No (JSON) |
| Passing arguments | Awkward (make run ARGS=…) | First-class (just run foo) | Awkward |
| Tracks file dependencies | Yes (its original job) | No | No |
| Cross-platform | Windows needs extra setup | Yes | Yes |
If you never use Make's file-timestamp dependency tracking — and most task-runner Makefiles don't — just gives you the same discoverable just <task> interface without the tab landmine or the subshell surprise. Make wins when you genuinely want "rebuild output.css only if input.scss changed," which is what it was built for.
FAQ
Why does cd in one recipe line not affect the next?
Because Make runs each line in a separate shell process by default. State — working directory, exported variables — dies with that process. Use && to keep commands in one shell, or put .ONESHELL: at the top to run the whole recipe in a single shell.
How do I pass an argument to a target?
Make has no clean syntax for it. The usual workaround is a variable: make deploy ENV=prod, read inside the recipe as $(ENV). If you pass arguments constantly, that friction is a real reason to look at just.
Do I still need .PHONY if no matching file exists today?
Yes. It documents intent and protects you from the day someone adds a build/ directory or a test file. It also skips an unnecessary stat call. Declaring it costs one line.
A good Makefile stays a thin index of commands people can discover by opening the file. Once a recipe sprouts loops and conditionals, move that logic into a real script and have the recipe call it. The Makefile's job is to make the commands findable, and it does that best when it stays boring.
Cover photo by Stanislav Kondratiev on Pexels.
