Every project accumulates a collection of shell commands that developers run repeatedly: install dependencies, run tests, build assets, deploy to staging. These commands live in READMEs, Slack messages, and tribal memory — until someone new joins the team and spends half a day figuring out how to get the project running.
A Makefile solves this. It turns your project's command vocabulary into a single, self-documenting file that any developer can run from day one. And because Make ships with every Unix-based system (macOS and Linux included), there's nothing to install.
Why Makefiles for Web Projects
Most Make tutorials focus on compiling C code — that's not what we're doing here. For web development teams, a Makefile is a task runner that sits above your language-specific tools:
.PHONY: install build test deploy
install:
npm ci
composer install
build: install
npm run build
test:
npm run test
php artisan test
deploy: build test
@echo "Ready to deploy"
Run make deploy and it installs dependencies, builds assets, runs tests, and confirms everything is ready — in order, stopping at the first failure. No scripts to remember, no arguments to get wrong.
Make vs npm Scripts vs Shell Scripts
| Concern | npm scripts | Shell scripts | Makefile |
|---|---|---|---|
| Dependency chain | Manual | Manual | Built-in (target: prerequisite) |
| Runs only what changed | No | No | Yes (file-based targets) |
| Parallel execution | Limited | Manual | make -j4 |
| Cross-language | Node only | Yes | Yes |
| Discoverable | npm run |
Read the file | make help |
| Pre-installed | Needs Node | Yes | Yes (macOS/Linux) |
The dependency chain is the key advantage. When you write deploy: build test, Make ensures build and test complete before deploy runs. If test fails, deploy never executes. You get pipeline-like behaviour in a flat file.
Practical Makefile Patterns
The Self-Documenting Help Target
This pattern extracts comments from your Makefile and prints them as help text:
.DEFAULT_GOAL := help
help: ## Show available commands
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
install: ## Install all dependencies
npm ci
composer install --no-dev --optimize-autoloader
build: ## Build frontend assets for production
npm run build
test: ## Run the full test suite
npm run test && php artisan test
lint: ## Run linters
npm run lint
./vendor/bin/phpstan analyse
Now make (with no arguments) prints a formatted list of available commands. New team members can see everything the project supports immediately.
Environment-Aware Targets
Use environment variables to change behaviour without modifying the Makefile:
ENV ?= staging
APP_URL ?= https://$(ENV).example.com
.PHONY: config deploy
config: ## Generate environment-specific config
@echo "Configuring for $(ENV) at $(APP_URL)"
cp .env.$(ENV) .env
deploy: build test config ## Deploy to the target environment
@echo "Deploying to $(ENV)..."
Run make deploy for staging (the default), or ENV=production make deploy for production. The same Makefile, different behaviour.
Build Artefact Caching
Unlike phony targets, file-based targets let Make skip work that's already done:
node_modules: package-lock.json
npm ci
@touch node_modules
public/build/manifest.json: node_modules $(shell find resources/js -type f)
npm run build
vendor: composer.lock
composer install --no-dev --optimize-autoloader
@touch vendor
assets: public/build/manifest.json ## Build frontend assets (skips if unchanged)
deps: node_modules vendor ## Install all dependencies (skips if unchanged)
If package-lock.json hasn't changed since the last npm ci, Make skips the install entirely. On a large project, this saves minutes per run.
Parallel Task Execution
Make can run independent targets concurrently:
.PHONY: test-unit test-e2e test-all
test-unit:
npm run test:unit
test-e2e:
npm run test:e2e
test-all: test-unit test-e2e
Run make -j2 test-all and both test suites execute simultaneously. For CI environments, this can cut test time in half.
Database Operations
Common database workflows as repeatable targets:
.PHONY: db-fresh db-migrate db-seed db-reset
db-migrate: ## Run pending migrations
php artisan migrate
db-seed: ## Seed the database
php artisan db:seed
db-fresh: ## Drop all tables and re-run migrations + seeds
php artisan migrate:fresh --seed
db-reset: db-fresh db-seed ## Full database reset
Docker Integration
If your project uses Docker, a Makefile provides a clean interface to container commands:
COMPOSE = docker compose
APP = $(COMPOSE) exec app
.PHONY: up down shell logs
up: ## Start all containers
$(COMPOSE) up -d
down: ## Stop all containers
$(COMPOSE) down
shell: ## Open a shell in the app container
$(APP) sh
logs: ## Tail container logs
$(COMPOSE) logs -f
test: up ## Run tests inside the container
$(APP) php artisan test
deploy: up build ## Build and prepare for deployment
$(APP) php artisan optimize
Team members don't need to memorise Docker Compose syntax — make shell, make logs, make test all just work.
Using Makefiles with DeployHQ
DeployHQ's build pipeline runs commands before deploying your code to servers. If your project already has a Makefile, you can use it directly in your build configuration.
Build Pipeline Setup
In your DeployHQ project, navigate to Build Pipeline and add a single build command:
make build
This runs whatever your Makefile defines as the build target — install dependencies, compile assets, optimise autoloaders — all from one command. If you add a new build step later (say, generating an API client), you update the Makefile in your repository. The DeployHQ build configuration stays the same.
Pre-Deployment Validation
Add a predeploy target that runs checks before code ships:
.PHONY: predeploy
predeploy: lint test build ## Everything that must pass before deploying
@echo "All checks passed — ready to deploy"
Set make predeploy as your DeployHQ build command. If linting or tests fail, the build fails and nothing gets deployed to your servers.
Environment-Specific Builds
If you deploy to multiple environments (staging, production), use Make variables:
ENV ?= production
build:
ifeq ($(ENV),production)
npm run build -- --mode production
composer install --no-dev --optimize-autoloader
else
npm run build -- --mode development
composer install
endif
In DeployHQ, set the ENV variable per server so the same Makefile produces the right build for each environment.
Debugging Makefiles
Print Any Variable
This wildcard target prints the value of any Make variable:
print-%:
@echo '$* = $($*)'
Run make print-ENV or make print-APP_URL to inspect values without modifying the Makefile.
Dry Run Mode
Use make -n to see what commands would run without executing them:
$ make -n deploy
npm ci
npm run build
npm run test
echo "Deploying to staging..."
This is especially useful before running destructive targets like database resets.
Verbose Mode
Run make SHELL='sh -x' to print each command before execution, which helps trace failures in complex targets.
Common Pitfalls
Tabs, not spaces: Makefile commands must be indented with tabs. Most editors can be configured to insert tabs in Makefiles — add an .editorconfig entry:
[Makefile]
indent_style = tab
Shell per line: Each line in a Make recipe runs in a separate shell. If you need multi-line logic, use && or backslash continuations:
# Wrong — the cd doesn't persist
broken:
cd frontend
npm run build
# Correct
fixed:
cd frontend && npm run build
Escaping $: Use $$ for shell variables inside Makefiles, because Make interprets single $ as its own variable syntax:
list-files:
@for f in *.txt; do echo "$$f"; done
Getting Started
Create a Makefile in your project root with the targets your team runs most often. Start simple:
.DEFAULT_GOAL := help
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-15s %s\n", $$1, $$2}'
install: ## Install dependencies
npm ci
build: install ## Build for production
npm run build
test: ## Run tests
npm test
dev: install ## Start dev server
npm run dev
Commit it, and your entire team has a shared command vocabulary. No more how do I run the tests again?
messages.
Automate your deployments alongside your Makefile builds — sign up for DeployHQ and connect your build pipeline in minutes.
For questions, reach out at support@deployhq.com or find us on X/Twitter.