Makefile Task Runner Setup for Small Projects
Open the README of almost any small project and you will find a block of seven or eight shell commands under a heading like "Getting started". Half of them are out of date.

Open the README of almost any small project and you will find a block of seven or eight shell commands under a heading like "Getting started". Half of them are out of date. The person who wrote them left months ago, and the new hire spends their first afternoon guessing which flags still matter.
A Makefile task runner fixes that with one boring file that sits in the repository root. Type make test or make dev and the right thing happens, on every laptop and in CI. I have set this up on a dozen small codebases, and the pattern that survives is short, plain and slightly unfashionable. Here is how to build one people keep using.
Why a Makefile Task Runner Beats a README Full of Commands
Documentation describes what someone should run. A Makefile is what they actually run. That difference matters because commands that are executed every day get fixed the moment they break, while commands that live in prose quietly rot.
Make is also already there. It ships with every Linux distribution and with the Xcode command line tools on macOS, so nobody installs anything. Compare that with task runners that need their own package, their own version pin and their own config syntax. For a team of two to ten people, fewer moving parts wins.
It is also discoverable. A new developer can type make help and see every supported task with a one line explanation. That single command replaces a page of onboarding notes.
The Five Targets Almost Every Project Needs
Resist the urge to wrap every command you have ever typed. Start with five targets and add more only when someone asks twice for the same thing.
- setup installs dependencies and creates any local config file from a template.
- dev starts the app with hot reload or whatever local server you use.
- test runs the full test suite exactly the way CI runs it.
- lint runs formatters and static checks, ideally in check mode rather than rewrite mode.
- clean removes build output, caches and temporary databases.
On one Python service I maintain, those five targets cover roughly 90 percent of what anyone types in a week. The remaining tasks, such as database migrations or seeding test data, earned their place later because people kept asking in chat how to run them.
Writing Targets That Do Not Surprise Anyone
Make was built to compile C files, so a few of its defaults trip up anyone using it as a task runner. Three habits remove most of the surprises.
Declare phony targets
If a folder called test ever appears in the repository, Make will decide the test target is already up to date and skip it. Listing your task names on a .PHONY line tells Make they are actions, not files. Put that line at the top so it is impossible to miss.
Use one shell per recipe when you need it
By default each line of a recipe runs in a fresh shell, so changing directory on one line has no effect on the next. Either keep related commands on a single line or enable the .ONESHELL setting. I prefer single lines, because they behave the same on older versions of Make that some CI images still carry.
Indent with tabs, and tell your editor
Recipes must start with a tab character. Spaces produce the famous "missing separator" error. Add a rule to your editor config file so every contributor gets tabs in Makefiles automatically. That one line saves more frustration than any other tip here.
A Self Documenting Help Target
The trick that makes a Makefile feel friendly is a help target that reads comments from the file itself. Write a short description after two hash marks on the same line as each target, then have the help target print every line that matches that pattern. Make help the default goal so typing a bare make shows the menu instead of running something unexpected.
The result looks like this when a new teammate runs it:
| Command | What it does |
|---|---|
| make setup | Install dependencies and copy the example env file |
| make dev | Run the app locally on port 8000 |
| make test | Run unit and integration tests like CI |
| make lint | Check formatting and types without changing files |
| make clean | Delete caches, build output and the local test database |
The Advice I Ignore: Do Not Put Everything in Make
You will read that Make should orchestrate your entire workflow, from Docker builds to deployments to release notes. I disagree, at least for small teams.
Make is a thin layer. Once a target grows past about ten lines, or needs loops, error handling and conditionals, it becomes hard to read and harder to debug. Move that logic into a script in a scripts folder and have the target call it with a single line. The Makefile stays a readable index of tasks, and the real logic lives in a language with proper tooling.
Deployments are the other exception. A deploy that can be triggered by a stray make in the wrong terminal is a risk I would rather not carry. Keep production deploys in CI, behind a review step, and leave the Makefile for things that are safe to run twenty times a day.
Making CI and Laptops Run the Same Commands
The biggest payoff arrives when your CI configuration calls the same targets developers use. Instead of copying the test command into a pipeline file, the pipeline step simply runs make test. When someone adds a new flag, both places pick it up at once.
This closes one of the most common gaps I see, the slow drift between how code runs locally and how it runs on the server. If that problem sounds familiar, our piece on how a local setup drifts away from production covers the wider causes. Shared Makefile targets will not fix every difference, but they remove the cheapest one.
The same targets fit neatly into commit checks too. A lint target that finishes in under five seconds is a good candidate for the hooks described in our guide to pre-commit hooks small teams keep using.
Windows, Containers and Other Friction Points
Make is not installed on Windows by default, and that is the most common objection I hear. In practice most developers on Windows already work inside WSL or a dev container, where Make is one package install away. If a teammate refuses both, a short PowerShell script that mirrors the five core targets is an honest fallback, though you now maintain two files.
When the project runs inside Docker, have each target call the container rather than the host tools. For example, the test target runs the test command through your compose file. Developers then need only Docker and Make on their machine, which shrinks setup from an afternoon to about ten minutes.
FAQ
Is Make too old for modern projects?
Its age is an advantage here. The syntax for simple task targets has not changed in decades, it runs everywhere, and it adds no dependency to your project.
Should I use a newer task runner instead?
If your team already loves one, keep it. Switch to Make only when the current tool adds install steps or config that nobody understands. The pattern matters more than the program.
How many targets is too many?
Once the help output scrolls off a laptop screen, around twenty targets, prune it. Group rarely used tasks behind scripts and keep the menu focused on daily work.
Start With One File This Week
Pick the five commands your team runs most, wrap each in a target with a two hash description, add the help target as the default, and point CI at the same names. Then delete the stale command block from the README and replace it with one line: run make. For more practical tooling ideas, browse the dev tools section.
More in Dev Tools
Dev Tools
How to Set Up direnv for Per Project Environment Variables
You switch from one project to another and the API still points at the wrong database, because an environment variable from the...
Dev Tools
Local HTTPS Development With mkcert in Under Ten Minutes
Your app works on plain http://localhost:3000, then breaks the moment it touches a secure cookie, a service worker or an OAuth...
Dev Tools
Git Pre-Commit Hooks That Small Teams Actually Keep Using
A teammate pushes a commit with a stray console.log , a 40 MB video file and an AWS key in a config file.