The Problem
You’ve got scripts everywhere.
scripts/ setup.sh build.sh test.sh deploy.sh migrate-db.sh lint.sh format.shDevelopers don’t know what to run first. Dependencies? You have to read each script. Parallel tasks? Good luck with bash.
What you need is a simple task runner. Something everyone understands immediately.
Enter make. It’s been around since 1977. It’s on every Unix system. It works.
Basic Makefile
.PHONY: help build test lint format
help: @echo "Available tasks:" @echo " make build - Compile the project" @echo " make test - Run tests" @echo " make lint - Lint code" @echo " make format - Format code"
build: @echo "Building..." npm run build
test: @echo "Running tests..." npm test
lint: @echo "Linting..." eslint .
format: @echo "Formatting..." prettier --write .Now:
make build # Buildsmake test # Testsmake lint # Lintsmake format # Formatsmake help # Shows tasksThat’s it. Everyone knows what to do.
Task Dependencies
Tasks often depend on others. You need to lint before testing. Build before deploying.
.PHONY: help setup build test lint format deploy
setup: npm install
build: setup npm run build
test: lint npm test
lint: eslint .
format: prettier --write .
deploy: build test npm run deploy
help: @echo "Available tasks: setup build test lint format deploy"Now make deploy automatically:
- Runs
build(which runssetup) - Runs
test(which runslint) - Runs
deploy
No manual orchestration. No “remember to run X before Y.”
Variables and Conditionals
NODE_VERSION := 20REGISTRY := ghcr.ioIMAGE := my-app:$(NODE_VERSION)
.PHONY: docker-build docker-push
docker-build: docker build -t $(IMAGE) \ --build-arg NODE_VERSION=$(NODE_VERSION) \ .
docker-push: docker-build docker push $(REGISTRY)/$(IMAGE)
# Conditional: only push if PUSH env var is setifdef PUSH @echo "Pushing $(IMAGE)..."endifUsage:
make docker-build # Builds my-app:20make docker-push # Builds and pushesPUSH=1 make docker-push # Adds conditional loggingReal-World Example: Python Project
.PHONY: help venv install lint test format clean deploy
VENV := venvPYTHON := $(VENV)/bin/pythonPIP := $(VENV)/bin/pip
help: @echo "Available tasks:" @echo " make venv - Create virtual environment" @echo " make install - Install dependencies" @echo " make lint - Lint with flake8" @echo " make test - Run pytest" @echo " make format - Format with black" @echo " make clean - Remove artifacts" @echo " make deploy - Deploy to production"
venv: python3 -m venv $(VENV)
install: venv $(PIP) install -r requirements.txt
lint: install $(PYTHON) -m flake8 src/
test: lint $(PYTHON) -m pytest tests/
format: $(PYTHON) -m black src/ tests/
clean: rm -rf __pycache__ .pytest_cache .coverage find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
deploy: test $(PYTHON) scripts/deploy.pyDevelopers just run:
make venv install test # Setup + lint + testmake deploy # Deploys (only after test passes)make clean # Clears cacheNo guessing. No scattered bash scripts.
Parallelizing Tasks
Tasks can run in parallel with -j:
.PHONY: test lint format
test: npm test
lint: eslint .
format: prettier --write .
# This task depends on all three, but they can run parallelcheck: test lint format
help: @echo "make check - Run test, lint, and format in parallel"make -j check # Runs test, lint, format simultaneouslyOn a 4-core machine, you save tons of time.
Conditional Execution
.PHONY: build deploy
build: npm run build
deploy: build @if [ "$(BRANCH)" = "main" ]; then \ npm run deploy:prod; \ else \ npm run deploy:staging; \ fiUsage:
BRANCH=main make deploy # Deploys to prodBRANCH=feature make deploy # Deploys to stagingCommon Patterns
Database Migrations
migrate: npm run knex migrate:latest
migrate-rollback: npm run knex migrate:rollback
reset-db: migrate-rollback migrate # Rollback then re-runDocker + Compose
up: docker-compose up -d
down: docker-compose down
logs: docker-compose logs -f
shell: docker-compose exec app sh
restart: down upGit Hooks
setup-hooks: @mkdir -p .git/hooks @cp hooks/* .git/hooks/ @chmod +x .git/hooks/* @echo "Git hooks installed"Tips and Tricks
Silent Output (@ prefix)
lint: @echo "Linting..." # Prints eslint . # Prints command + output
vs.
lint: @echo "Linting..." # Prints @eslint . # Doesn't print the command, just outputHelp from Comments
help: ## Show this message @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ sort | \ awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
build: ## Compile the project npm run build
test: ## Run unit tests npm testNow make help shows:
build Compile the projecttest Run unit tests.PHONY Explained
.PHONY: build
build: npm run build.PHONY tells make “build is not a file, always run it.” Without this, if a directory called build/ exists, make thinks it’s already done.
Always use .PHONY for your tasks.
Gotchas
Tabs matter. Recipes in Makefiles must start with a tab character, not spaces. Your editor might auto-convert tabs to spaces. Disable that:
In VS Code, add to .vscode/settings.json:
{ "[makefile]": { "editor.insertSpaces": false }}Variable expansion timing. $(VAR) expands immediately. $$(VAR) expands at runtime. Use $$ for shell variables:
iterate: @for i in 1 2 3; do \ echo "Number $$i"; \ doneThe Payoff
One make command replaces ten bash scripts. New developers run make help, see all tasks, never wonder “what do I run first?”
It’s simple. It’s portable. It’s been around since before most developers were born.
If you’re not using make, start tomorrow.
Your repo will thank you. Your team will thank you.
And your 2 AM self will definitely thank you when you can run make deploy instead of hunting for a script.