DevOps & Tools

Should You Commit Your Makefile?

Makefiles have been around since 1976. They predate Git, GitHub, Docker, Node.js, and most of the tools you use every day. Originally designed to compile C p...

Should You Commit Your Makefile?

Makefiles have been around since 1976. They predate Git, GitHub, Docker, Node.js, and most of the tools you use every day. Originally designed to compile C programs, Make has quietly become one of the most popular task runners in modern software development — even in projects that have nothing to do with C.

If you have a Makefile in your project root and you're wondering whether it belongs in version control: yes, commit it.

A Makefile is not generated output. It's not machine-specific configuration. It's not a secret. It's the file that tells every developer on your project how to build, test, and run things. It belongs in your repository for the same reasons your package.json or Dockerfile does. (For the full framework on deciding what to commit, see The Developer's Guide to What Belongs in Your Git Repository.)

What a Makefile actually contains

A Makefile is a plain text file that defines targets — named commands that run one or more shell instructions. Each target is a task: build the project, run tests, start the dev server, deploy, clean up artifacts.

Here's the basic anatomy:

target-name:
	shell-command-here
	another-command

That's it. You type make target-name, and Make runs the commands underneath. The indentation must be a real tab character (not spaces) — this is Make's one famous quirk, and it has tripped up developers for decades.

A real-world Makefile might look like this:

install:
	npm ci

dev:
	npm run dev

build:
	npm run build

test:
	npm run test

lint:
	npm run lint

deploy:
	npm run build && npx wrangler deploy

clean:
	rm -rf node_modules .next dist

Each target is a simple, memorable alias for a longer command. Instead of remembering npx wrangler deploy or whatever your specific deployment incantation is, you just run make deploy.

Why Makefiles are popular in non-C projects

If you come from the JavaScript or Python world, you might wonder why anyone uses Make when npm run scripts and shell scripts already exist. The answer is pragmatic: Make is a universal interface.

It's already installed. Make ships with macOS and virtually every Linux distribution. There's nothing to install — no runtime dependency, no package manager. It just works.

It's language-agnostic. A Python project, a Go service, a Next.js app, and a Terraform infrastructure repo can all use the same tool for task running. If you work across multiple projects in different languages, make build and make test work in all of them.

It's self-documenting. When a new developer clones your repo, they can open the Makefile and immediately see every available command. No digging through package.json scripts, no reading a README that might be outdated.

It handles multi-step workflows. Make supports dependencies between targets. You can say "before deploying, run the build, and before building, run the linter." Make figures out the execution order.

deploy: build
	npx wrangler deploy

build: lint
	npm run build

lint:
	npm run lint

Running make deploy now automatically runs lint, then build, then deploy — in the right order, every time.

A practical Makefile for a modern web project

Here's a complete, realistic Makefile for a Next.js or similar web project. This is the kind of file you'd commit to your repo:

.PHONY: help install dev build test lint format deploy clean db-migrate db-seed

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

install: ## Install dependencies
	pnpm install

dev: ## Start development server
	pnpm run dev

build: ## Build for production
	pnpm run build

test: ## Run test suite
	pnpm run test

lint: ## Run linter
	pnpm run lint

format: ## Format code with Prettier
	pnpm run format

deploy: build ## Build and deploy to production
	pnpm run deploy

clean: ## Remove build artifacts and dependencies
	rm -rf node_modules .next dist .turbo

db-migrate: ## Run database migrations
	pnpm run db:migrate

db-seed: ## Seed the database
	pnpm run db:seed

Type make help and you get a nicely formatted list of every available command with its description. That help target is a well-known Make pattern — the ## comment after each target name becomes its documentation.

Best practices

Use .PHONY declarations

By default, Make checks whether a target name matches a file on disk. If you have a directory called build/ and a target called build, Make might skip the target because it thinks the file already exists. Declaring targets as .PHONY tells Make they're commands, not files:

.PHONY: build test clean deploy

Declare all your task targets as phony. The only time you wouldn't is if you're using Make for actual file-based build rules (like compiling .c files into .o files), which is rare in modern web projects.

Add a help target

The self-documenting help pattern shown above is worth adopting in every project. It turns your Makefile into its own reference card. Some teams make help the default target (the first target in the file) so that typing just make with no arguments prints the available commands.

Use variables for repeated values

If the same value appears in multiple targets, pull it into a variable at the top of the file:

NODE_BIN = ./node_modules/.bin
DOCKER_IMAGE = myapp:latest

build:
	$(NODE_BIN)/next build

docker-build:
	docker build -t $(DOCKER_IMAGE) .

This keeps your Makefile DRY and makes updates easier.

Keep targets focused

Each target should do one thing. If a target is running ten commands, it probably should be split into multiple targets with dependencies between them.

Handling secrets in Makefiles

The one thing you should never do is hardcode secrets — API keys, tokens, passwords — directly in your Makefile. Since the Makefile is committed to version control, any secret written into it is visible to everyone with repo access (and in your Git history forever).

Instead, reference environment variables:

deploy:
	CLOUDFLARE_API_TOKEN=$(CLOUDFLARE_API_TOKEN) npx wrangler deploy

Or load a .env file:

include .env
export

deploy:
	npx wrangler deploy

The include .env directive loads variables from your .env file, and export makes them available to sub-processes. Since your .env file is in .gitignore (as it should be — see our .env files guide if you need help with that), your secrets stay out of version control while your Makefile stays committed.

Add a - before include to prevent Make from erroring if the file doesn't exist:

-include .env
export

This way the Makefile still works in CI environments where secrets come from environment variables rather than a .env file.

What about .gitignore?

Nothing to add. A Makefile is a single plain text file with no generated companions. There are no lock files, no build artifacts, no local overrides to exclude. You commit the Makefile and you're done.

If your Makefile creates build output (like a dist/ or build/ directory), those output directories should be in .gitignore — but that's about the output, not the Makefile itself.

The bottom line

Commit your Makefile. It defines how your project is built, tested, and deployed. It's the universal entry point for anyone who clones your repo — regardless of what language or framework you're using. It contains no secrets (if you've written it correctly), it's not machine-specific, and it's not auto-generated.

For a solo founder or small team, a well-written Makefile is one of the highest-leverage files in your repository. Five minutes of setup saves hours of "how do I run this again?" questions — from teammates and from your future self.

Related reading

Ready to build?

Go from idea to launched product in a week with AI-assisted development.