IAnswerable

Use a Makefile as Your Task Runner

08 Apr 2026

You 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.