Makefiles Made My Dev Life Easier

· 8 min read · #devops#architecture

I work on projects that have multiple services. A .NET API, an Angular frontend, a Python worker, PostgreSQL, Valkey, maybe a proxy or an identity server. Getting all of that running locally involves a lot of commands. Docker compose up, wait for containers, run database migrations, seed test data, start the frontend dev server. I used to type all of this by hand every time. Then I started putting it in a Makefile and everything got simpler.

If you are not familiar with Makefiles, here is the short version. A Makefile is a plain text file that maps short names to shell commands. You define a "target" (a name), and the commands that should run when you type make <target>. That is it. No special language to learn. No package to install (it comes with most systems already).

The Problem

Take a project like ReserveFlow, a booking system I built with .NET, Angular, PostgreSQL, Valkey, and Keycloak. To get it running from scratch, you need to:

  1. Stop any running containers
  2. Remove old volumes so you start clean
  3. Build and start all Docker services
  4. Wait for PostgreSQL and Keycloak to be healthy
  5. Run EF Core database migrations
  6. Seed demo data for two tenants

That is six steps. If you are a new developer on the project, you need to know all of these in the right order. If you skip the migration step, the API crashes. If you forget to seed data, the app loads but everything is empty.

Now compare that to:

make fresh

One command. Done.

What a Makefile Looks Like

Here is a simplified version of the ReserveFlow Makefile:

.PHONY: up down clean logs migrate seed-data fresh test

up: ## Start all services
	docker compose up -d --build

down: ## Stop all services
	docker compose down

clean: ## Stop and remove volumes
	docker compose down -v

migrate: ## Apply database migrations
	cd server && dotnet ef database update \
		--project src/ReserveFlow.Infrastructure \
		--startup-project src/ReserveFlow.Api

seed-data: ## Seed demo data for both tenants
	docker exec -i rf-postgres psql -U reserveflow -d reserveflow < scripts/seed-data.sql

fresh: clean up migrate seed-data ## Nuke everything, start fresh

test: ## Run integration tests
	cd server && dotnet test --logger "console;verbosity=detailed"

Each line with ## is a target. The part after ## is a description. The indented lines below are the actual shell commands that run.

The fresh target is interesting because it chains other targets: clean up migrate seed-data. Make runs them in order, left to right. If any step fails, it stops. You get a complete reset in one command.

Real Examples from My Projects

Starting Just the Infrastructure

On the Adaptive Web Filter project, I often need to run the .NET API locally (outside Docker) for debugging, but I still need PostgreSQL and Valkey running in containers. Instead of remembering which specific containers to start, I have:

infra: ## Start only Postgres + Valkey (for local dev)
	docker compose up -d postgres valkey
	@echo "Waiting for healthy containers..."
	@until docker inspect --format='{{.State.Health.Status}}' awf-postgres 2>/dev/null | grep -q healthy; do sleep 1; done
	@until docker inspect --format='{{.State.Health.Status}}' awf-valkey 2>/dev/null | grep -q healthy; do sleep 1; done
	@echo "Postgres and Valkey are healthy."

This starts just the two databases, waits for their health checks to pass, and tells you when they are ready. Then I can start the API with dotnet run locally and get full debugger support.

I also have a target that chains this with the API startup:

api-ready: infra migrate ## Start infra, migrate, then run the API
	cd server && dotnet run --project src/AdaptiveWebFilter.Api

One command to go from nothing to a running API with a healthy database.

Seeding Test Data

Both projects need test data to be useful during development. The Adaptive Web Filter seeds LLM providers, model configurations, cost models, and pushes test URLs to the processing queue. That is a lot of SQL inserts and Redis commands. Nobody wants to type those by hand.

make seed-data

The target handles all of it. It inserts the provider records, creates two model configurations with their system prompts, adds cost models, notifies the worker to reload, and pushes three test URLs to the queue. At the end it prints a summary of what it seeded and tells you what to run next.

Pre-Commit Testing

On the Adaptive Web Filter, the make test target is the pre-commit gate. It runs the .NET test suite, the Angular linter, and validates that the Docker Compose configuration builds and starts correctly.

make test

This calls a shell script (scripts/test.sh) that runs everything in sequence and fails fast if any step breaks. The pre-commit hook calls this same target, so you cannot commit code that does not pass the full gate. Running it manually before committing saves you the surprise of a blocked commit.

Self-Documenting Help

One pattern I use on every project now is a help target that automatically lists all available commands:

help: ## Show this help
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-18s\033[0m %s\n", $$1, $$2}'

When you type make help (or just make, if you set .DEFAULT_GOAL := help), it scans the Makefile for any target that has a ## comment and prints a formatted list. The output looks like:

up                 Start all services
down               Stop all services
clean              Stop and remove volumes
fresh              Nuke everything, start fresh
migrate            Apply database migrations
seed-data          Seed demo data
test               Run integration tests
help               Show this help

This means the Makefile documents itself. A new developer can type make and immediately see every available command with a description. No need to read a setup guide or ask someone.

Patterns Worth Adopting

Chain targets for common workflows. The fresh: clean up migrate seed-data pattern is the most useful thing I have in my Makefiles. It turns a multi-step process into one command. Think about what a new developer needs to run on day one and make that a single target.

Separate infrastructure from application. Having both make infra (just databases) and make up (everything) gives you flexibility. Sometimes you want to run the API locally for debugging. Sometimes you want the full stack in Docker. Both should be one command.

Use .PHONY for everything. If you do not mark a target as .PHONY, Make will look for a file with that name and skip the target if the file exists. A file called test or build in your repo will silently break your Makefile. Just add all your targets to .PHONY at the top.

.PHONY: up down clean fresh migrate test help

Keep complex logic in shell scripts. If a target needs more than a few lines of commands, put the logic in a script and call it from the Makefile. The Makefile stays readable and the script gets proper error handling and variables.

test: ## Run pre-commit gate
	bash scripts/test.sh

Common Mistakes

Spaces instead of tabs. Makefiles require tabs for indentation, not spaces. If you use spaces, you get a cryptic "missing separator" error. Most editors can be configured to insert tabs in Makefiles. If you hit this, check your editor settings before anything else.

Not adding .PHONY. I mentioned this above but it is worth repeating because it causes the most confusing bugs. Your make test command works fine, then someone adds a test/ directory and suddenly the target never runs.

Too much logic in the Makefile. Makefiles are good at running commands and chaining targets. They are bad at conditionals, loops, and string manipulation. If you find yourself writing complex bash inside a Makefile target, move it to a shell script.

Why Not Just Use Shell Scripts?

You could do all of this with shell scripts. bash scripts/fresh.sh instead of make fresh. It would work. But there are a few reasons I prefer Makefiles.

make fresh is shorter than bash scripts/fresh.sh. It sounds minor, but when you type it dozens of times a day, it adds up.

Make handles dependencies between targets. If fresh depends on clean, up, migrate, and seed-data, Make runs them in order and stops on failure. With shell scripts you would need to chain them yourself.

The self-documenting help pattern does not have a clean equivalent in shell scripts. With a Makefile, make alone tells you everything the project can do.

And Makefiles are universal. Every developer has seen one. Every CI system can run one. You do not need to install anything. It just works.

Getting Started

If you do not have a Makefile in your project yet, start small. Pick the three commands you type most often and give them short names. For most projects that is something like make up, make test, and make fresh. Add more as you find yourself repeating commands.

The goal is not to automate everything. It is to make the common stuff easy to remember and fast to run. Once you start doing that, you will wonder why you ever typed docker compose up -d --build && cd server && dotnet ef database update --project src/... by hand.

Alvin Almodal

Alvin Almodal

Cloud & Data Engineering Consultant. Your partner for cloud-native builds and data pipelines.