Use a Makefile as Your Task Runner
08 Apr 2026You don’t need npm scripts, a Justfile, or a task-runner package to get make build and make deploy working the same way across every project. make is already on your machine, and it works for any language.
The minimum viable Makefile
.PHONY: build test deploy clean
build:
docker build -t myapp .
test:
go test ./...
deploy: build
docker push myapp
ssh prod "docker pull myapp && docker compose up -d"
clean:
rm -rf dist/ *.log
Run any target with make build, make test, make deploy. deploy: build means “run build first, then deploy” — that’s the dependency graph, and it’s the whole reason to use make over a flat scripts folder.
Why .PHONY matters
make was built to compile files, and by default it checks whether a target’s name matches an existing file with that name, skipping the recipe if it thinks the file is already up to date. If you have a directory called build/ and a target called build, make gets confused about which one you mean. .PHONY tells it: this target isn’t a file, always run it.
Variables so you’re not repeating yourself
IMAGE := myapp
REGISTRY := registry.example.com
build:
docker build -t $(REGISTRY)/$(IMAGE) .
push: build
docker push $(REGISTRY)/$(IMAGE)
:= evaluates once, immediately. = evaluates lazily, every time the variable is used — that difference rarely matters until it does, and when it does it’s a confusing bug. Default to :=.
Pass arguments in
make doesn’t take positional args the way a shell script does, but environment variables work fine:
deploy:
ssh prod "docker pull $(IMAGE):$(TAG)"
make deploy TAG=v2.1.0
A self-documenting help target
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
build: ## Build the Docker image
docker build -t myapp .
test: ## Run the test suite
go test ./...
make help now prints every target with its description, pulled straight from the ## comment after each target. This is the trick that makes a Makefile function as real documentation instead of something only the original author understands.
The gotcha that trips up everyone once: tabs, not spaces
Every recipe line under a target must be indented with a literal tab character. A space-indented line fails with:
Makefile:4: *** missing separator. Stop.
Most editors auto-convert tabs to spaces by default, which is exactly backwards for a Makefile. Either configure your editor to preserve tabs for Makefile/makefile filenames, or don’t hand-edit the indentation — copy an existing recipe line and edit its contents only.
When to reach for something else
make is enough for a single project with a handful of commands. If you need cross-platform Windows support without WSL, or you’re chaining tasks with real conditionals and JSON output, a dedicated runner like just is less arcane. But for the common case — build, test, deploy, clean, one entry point per project — make is already installed everywhere, has been stable for decades, and doesn’t add a dependency to your repo.
One convention worth adopting across every repo
Standardize on the same target names everywhere: build, test, lint, deploy, clean. When every project you touch answers to the same five commands, you stop needing to open each project’s README to remember how to run it.