At some point every project grows a collection of shell scripts. deploy.sh, restart.sh, build-prod.sh, setup-dev.sh. They accumulate. You forget what half of them do. You forget which ones need arguments. Someone else on the project has no idea where to start.
A Makefile solves this. One file, one interface, every command in one place. Type make and get a list of everything available. Type make deploy and it runs. No documentation required.
What a Makefile Actually Is
Make is a build automation tool that has shipped with Unix systems since 1976. It was designed for compiling C code but works just as well for running any sequence of shell commands. A Makefile is a plain text file named Makefile (capital M, no extension) that lives in your project root.
The basic structure is a target, followed by a colon, followed by optional dependencies, followed by indented commands:
makefile
target: dependency
command to run
Code language: Bash (bash)The indentation must be a real tab character, not spaces. That is the one syntax rule that catches everyone the first time.
The Simplest Useful Makefile
makefile
.PHONY: up down restart logs
up:
docker compose up -d
down:
docker compose down
restart:
docker compose down && docker compose up -d
logs:
docker compose logs -f
Code language: Bash (bash)Run make up, make down, make logs. No more typing docker compose every time. The .PHONY declaration at the top tells Make that these targets are not filenames — they are just named commands. Without it, if you ever happen to have a file called up or logs in the same directory, Make would see that the file exists and decide there is nothing to do.
Self-Documenting Help Target
The first target in a Makefile is the default — running make with no arguments runs it. Use that slot for a help target that prints every available command:
makefile
.DEFAULT_GOAL := help
.PHONY: help up down restart deploy
help: ## Show available commands
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / \
{printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
up: ## Start all containers
docker compose up -d
down: ## Stop all containers
docker compose down
deploy: ## Deploy to active environment
./scripts/deploy.sh
Code language: Bash (bash)The ## comment after each target is what the awk command parses and prints. Output looks like:
help Show available commands
up Start all containers
down Stop all containers
deploy Deploy to active environment
Code language: Bash (bash)Every new target you add with a ## comment appears automatically. No separate documentation to maintain.
Reading from .env
This is where Makefiles become genuinely useful for multi-environment setups. Add these two lines at the top of your Makefile:
makefile
include .env
export
Code language: Bash (bash)Every variable in your .env file is now available as a Make variable and also exported into the environment of every command that runs. A .env like this:
ENV=staging
[email protected]
DEPLOY_PATH=/var/www/staging
Code language: Bash (bash)Means your Makefile can do this:
makefile
deploy: ## Deploy to $(ENV)
@echo "Deploying to $(ENV)..."
rsync -avz --delete ./dist/ $(DEPLOY_HOST):$(DEPLOY_PATH)
ssh $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && docker compose up -d --build"
Code language: Bash (bash)Change DEPLOY_HOST and DEPLOY_PATH, run make deploy, and it deploys to the production server and path instead. Same command, different behaviour, driven entirely by the environment file you already maintain.
Environment-Conditional Logic
For more complex branching you can use Make’s ifeq blocks at the top level:
makefile
include .env
export
ifeq ($(ENV),prod)
COMPOSE_FILE := docker-compose.prod.yml
DEPLOY_HOST := [email protected]
DEPLOY_PATH := /var/www/production
else
COMPOSE_FILE := docker-compose.staging.yml
DEPLOY_HOST := [email protected]
DEPLOY_PATH := /var/www/staging
endif
Code language: Bash (bash)Now $(COMPOSE_FILE), $(DEPLOY_HOST), and $(DEPLOY_PATH) resolve to the right values for whichever environment .env specifies, and every target uses them without any conditional logic inside the targets themselves.
Guards: Failing Fast on Missing Variables
If a required variable is not set, you want to know immediately — not halfway through a deployment. Add a guard target:
makefile
guard-%:
@[ -n "$($(*))" ] || (echo "ERROR: $* is not set"; exit 1)
deploy: guard-ENV guard-DEPLOY_HOST ## Deploy to active environment
@echo "Deploying to $(ENV) at $(DEPLOY_HOST)..."
Code language: Bash (bash)The guard-% pattern matches any target that starts with guard-. Running make deploy first checks that ENV and DEPLOY_HOST are both set. If either is empty, it prints an error and exits before any deployment command runs.
A Real-World Example
Here is a condensed version of a Makefile for a Docker-based WordPress deployment:
makefile
include .env
export
ifeq ($(ENV),prod)
COMPOSE_FILE := docker-compose.prod.yml
DEPLOY_HOST := $(PROD_HOST)
DEPLOY_PATH := /var/www/production
else
COMPOSE_FILE := docker-compose.staging.yml
DEPLOY_HOST := $(STAGING_HOST)
DEPLOY_PATH := /var/www/staging
endif
.DEFAULT_GOAL := help
.PHONY: help up down restart deploy pull logs shell db-backup guard-%
help: ## Show available commands
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / \
{printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
up: ## Start containers
docker compose -f $(COMPOSE_FILE) up -d
down: ## Stop containers
docker compose -f $(COMPOSE_FILE) down
restart: down up ## Restart containers
pull: ## Pull latest images
docker compose -f $(COMPOSE_FILE) pull
deploy: guard-ENV guard-DEPLOY_HOST ## Sync files and restart on $(ENV)
rsync -avz --delete --exclude='.env' \
./ $(DEPLOY_HOST):$(DEPLOY_PATH)
ssh $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && make up"
logs: ## Tail container logs
docker compose -f $(COMPOSE_FILE) logs -f
shell: ## Open shell in app container
docker compose -f $(COMPOSE_FILE) exec app sh
db-backup: ## Dump database to ./backups
@mkdir -p ./backups
docker compose -f $(COMPOSE_FILE) exec db \
mysqldump -u$(DB_USER) -p$(DB_PASS) $(DB_NAME) \
> ./backups/$(DB_NAME)-$(shell date +%Y%m%d-%H%M%S).sql
@echo "Backup saved to ./backups/"
guard-%:
@[ -n "$($(*))" ] || (echo "ERROR: $* is not set"; exit 1)
Code language: Bash (bash)make deploy on staging, change one line in .env, make deploy on production. make db-backup dumps the database with a timestamped filename. make shell drops you into the container. All of it is visible from make help without reading a single line of documentation.
The @ Prefix
One thing worth knowing: Make prints every command before running it by default. Prefix a command with @ to suppress that. Most targets benefit from @echo messages replacing the raw command output, especially in CI environments where clean output matters.
Why This Beats Shell Scripts
A folder of shell scripts has no standard interface. make help always works the same way regardless of the project. Dependencies between targets are explicit — restart: down up means restart always runs down then up in order, and you can see that at a glance. Variables flow through consistently. And because Makefiles are text files in your repo, they version-control alongside everything else.
The full GNU Make documentation covers everything from pattern rules to parallel execution if you want to go deeper. For most deployment and ops workflows, the patterns above are all you need.