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](https://www.deployhq.com/features) 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](https://www.deployhq.com) 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](https://www.deployhq.com) 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](https://www.deployhq.com) 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](https://www.deployhq.com), 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](https://www.deployhq.com/signup) and connect your build pipeline in minutes.

For questions, reach out at [support@deployhq.com](mailto:support@deployhq.com) or find us on [X/Twitter](https://x.com/deployhq).

