Makefile for Deployments and Dev Workflows

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

Bash
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

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

Bash
.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:

Bash
  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

Bash
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:

Bash
ENV=staging
[email protected]
DEPLOY_PATH=/var/www/staging
Code language: Bash (bash)

Means your Makefile can do this:

makefile

Bash
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

Bash
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

Bash
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

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