Makefiles for Web Developers: A Practical Guide

Devops & Infrastructure, Git, Tips & Tricks, and Tutorials

Makefiles for Web Developers: A Practical Guide

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

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.