Portal | Level: L1: Foundations | Topics: Make & Build Systems, Bash / Shell Scripting | Domain: DevOps & Tooling
Make & Build Systems — Primer¶
Why This Matters¶
Every production system has a build step. Make is the oldest surviving tool for this job — released in 1976 — and it remains the de facto task runner in thousands of open-source projects, infrastructure repos, and platform engineering teams.
Modern DevOps teams use Makefiles as the universal entry point: make test, make deploy, make lint. It works everywhere without installing a language runtime or plugin ecosystem. When you SSH into a production server, Make is already there. Understanding Make also gives you the vocabulary to evaluate alternatives like just, Taskfile, and invoke.
Core Concepts¶
1. Targets, Prerequisites, and Recipes¶
A Makefile is a collection of rules. Each rule has three parts:
- Target: the thing being built (a file, or a phony name like
test) - Prerequisites: files or other targets that must exist/be up-to-date before the recipe runs
- Recipe: shell commands that produce the target — each line runs in its own shell
# Build a Go binary — only rebuilds if source files changed
bin/myapp: cmd/main.go pkg/handler.go pkg/config.go
go build -o bin/myapp ./cmd/main.go
# Run tests — depends on the binary existing
test: bin/myapp
go test ./...
Make compares the modification time of the target against its prerequisites. If any prerequisite is newer than the target, the recipe runs. This is the entire engine: timestamps drive rebuilds.
2. Phony Targets¶
Most DevOps Makefiles have targets that are not files: test, lint, deploy, clean. Without .PHONY, Make will look for a file named test and skip the recipe if it exists.
.PHONY: test lint deploy clean help
test:
pytest --tb=short -q
lint:
ruff check .
shellcheck scripts/*.sh
deploy:
helm upgrade --install myapp charts/myapp -f values-prod.yaml
clean:
rm -rf build/ dist/ .pytest_cache/
Always declare phony targets. The failure mode is silent: Make finds a file named test (maybe from a test framework output) and says "nothing to do." You stare at passing CI for 10 minutes before you realize your tests never ran.
3. Variables¶
Make has four variable assignment operators, and confusing them is a top-5 Makefile bug:
# = (recursively expanded) — value is re-evaluated every time the variable is used
CC = gcc
CFLAGS = $(WARNINGS) -O2
WARNINGS = -Wall -Wextra
# When $(CFLAGS) is used, it expands $(WARNINGS) at that moment
# := (simply expanded) — value is evaluated once at assignment time
TIMESTAMP := $(shell date +%s)
# $(TIMESTAMP) always returns the same value, even if the Makefile takes 10 seconds to process
# ?= (conditional) — only set if not already defined (environment or command line)
DOCKER_REGISTRY ?= ghcr.io/myorg
# Lets users override: DOCKER_REGISTRY=ecr.us-east-1.amazonaws.com make push
# += (append) — adds to existing value
LDFLAGS += -s -w
LDFLAGS += -X main.version=$(VERSION)
The critical difference: = is lazy (re-evaluated on each use), := is eager (evaluated once). Using = when you meant := can cause infinite recursion or surprising behavior when variables reference each other.
# DANGER: infinite recursion with =
# PATH = $(PATH):/usr/local/go/bin # <-- this expands PATH, which contains $(PATH), which...
# FIX: use :=
PATH := $(PATH):/usr/local/go/bin
4. Automatic Variables¶
These are set automatically inside each recipe. You will use $@ and $< constantly:
| Variable | Meaning | Example (for rule bin/app: main.go lib.go) |
|---|---|---|
$@ |
Target name | bin/app |
$< |
First prerequisite | main.go |
$^ |
All prerequisites (deduped) | main.go lib.go |
$? |
Prerequisites newer than target | (whichever changed) |
$* |
Stem of pattern match | (see pattern rules) |
$(@D) |
Directory part of target | bin |
$(@F) |
File part of target | app |
# Compile any .c to .o — $< is the .c file, $@ is the .o file
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Build Docker images — $* captures the stem from %
images/%: docker/%/Dockerfile
docker build -t myorg/$*:$(TAG) -f $< docker/$*/
5. Pattern Rules¶
Pattern rules use % as a wildcard. They define how to build a category of targets:
# Any .pdf from a .md file
%.pdf: %.md
pandoc $< -o $@
# Any Docker image from its Dockerfile
docker-build-%: docker/%/Dockerfile
docker build -t registry.example.com/$*:$(GIT_SHA) -f $< docker/$*/
docker push registry.example.com/$*:$(GIT_SHA)
The % in the target matches a string (the stem), and $* expands to it in the recipe. Pattern rules are how you avoid writing 50 nearly-identical rules.
6. Include Directives¶
Split large Makefiles into focused modules:
# Main Makefile
include mk/docker.mk
include mk/terraform.mk
include mk/test.mk
include mk/deploy.mk
# Optional include (does not error if file is missing)
-include .env.mk
-include local.mk
This is critical for monorepos where each team owns their build targets but shares a common entry point. The -include variant (with the dash) silently skips missing files — useful for optional local overrides.
7. Conditional Directives¶
Control which parts of the Makefile are active:
# Detect OS
UNAME_S := $(shell uname -s)
ifeq ($(UNAME_S),Linux)
SED := sed -i
else ifeq ($(UNAME_S),Darwin)
SED := sed -i ''
endif
# Check if a command exists
HAS_DOCKER := $(shell command -v docker 2>/dev/null)
ifdef HAS_DOCKER
BUILD_CMD := docker build .
else
BUILD_CMD := podman build .
endif
# Environment-specific behavior
ifeq ($(ENV),production)
DEPLOY_FLAGS := --atomic --timeout 300s
else
DEPLOY_FLAGS := --dry-run=client
endif
# Check if variable is empty
ifndef AWS_REGION
$(error AWS_REGION is not set)
endif
8. Functions¶
Make has built-in functions for text manipulation and shell interaction:
# wildcard — glob files at parse time
SOURCES := $(wildcard src/*.go cmd/*.go)
# patsubst — pattern substitution
OBJECTS := $(patsubst %.go,%.test,$(SOURCES))
# Turns src/main.go into src/main.test
# shell — run a shell command and capture output
GIT_SHA := $(shell git rev-parse --short HEAD)
BRANCH := $(shell git branch --show-current)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
# foreach — iterate over a list
SERVICES := api worker scheduler
DOCKER_TARGETS := $(foreach svc,$(SERVICES),docker-build-$(svc))
# call — invoke a user-defined function (a variable used as a template)
define build_image
docker build -t $(1):$(TAG) -f docker/$(1)/Dockerfile .
docker push $(1):$(TAG)
endef
push-api:
$(call build_image,api)
push-worker:
$(call build_image,worker)
# filter / filter-out — filter lists
GO_FILES := $(filter %.go,$(ALL_FILES))
NON_TEST := $(filter-out %_test.go,$(GO_FILES))
# addprefix / addsuffix
DEPLOY_TARGETS := $(addprefix deploy-,$(SERVICES))
# produces: deploy-api deploy-worker deploy-scheduler
# strip / subst / word / words / sort — text manipulation
CLEAN_INPUT := $(strip $(USER_INPUT) )
DASHED := $(subst /,-,$(MODULE_PATH))
9. Order-Only Prerequisites¶
Sometimes you need a directory to exist before writing a file into it, but you do not want the target to rebuild just because the directory's timestamp changed:
# The | separates order-only prerequisites
build/%.o: src/%.c | build/
$(CC) -c $< -o $@
build/:
mkdir -p build/
Without |, every time a new .o file is written to build/, the directory mtime updates and all other .o files would rebuild.
10. Recursive vs Non-Recursive Make¶
Recursive: each subdirectory has its own Makefile, and the top-level calls $(MAKE) -C subdir. Non-recursive: a single Makefile includes fragments from subdirectories via include.
Non-recursive Make sees the entire dependency graph and parallelizes better. Recursive Make is easier to reason about per-directory but can miss cross-directory dependencies (see Peter Miller's "Recursive Make Considered Harmful"). For DevOps task-runner Makefiles, recursive is fine. For real build systems with file dependencies, non-recursive is usually better.
11. .DEFAULT_GOAL and .SUFFIXES¶
.DEFAULT_GOAL := help # What runs when you type just "make"
.SUFFIXES: # Clear built-in suffix rules (speeds up Make)
Make for DevOps¶
Makefile as Task Runner¶
The most common use of Make in DevOps is not building software — it is providing a consistent interface to repo operations. Every engineer runs the same commands regardless of what is underneath:
.DEFAULT_GOAL := help
.PHONY: help test lint build deploy clean
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
test: lint ## Run all tests
pytest --tb=short -q
go test ./...
lint: ## Lint all code
ruff check .
golangci-lint run
shellcheck scripts/*.sh
hadolint Dockerfile
build: ## Build all artifacts
docker build -t myapp:$(shell git rev-parse --short HEAD) .
deploy: ## Deploy to current kubectl context
helm upgrade --install myapp charts/myapp \
--set image.tag=$(shell git rev-parse --short HEAD) \
-f values-$(ENV).yaml
clean: ## Remove build artifacts
rm -rf build/ dist/ .pytest_cache/ __pycache__/
docker image prune -f
The help target is the killer feature. Self-documenting Makefiles using ## comments after the target name give you make help for free. Every DevOps repo should have this.
Make for Docker Builds¶
REGISTRY ?= ghcr.io/myorg
IMAGE := $(REGISTRY)/myapp
TAG := $(shell git rev-parse --short HEAD)
docker-build: ## Build Docker image
docker build --build-arg GIT_SHA=$(TAG) \
-t $(IMAGE):$(TAG) -t $(IMAGE):latest .
docker-push: docker-build ## Push image to registry
docker push $(IMAGE):$(TAG)
docker-scan: docker-build ## Scan image for vulnerabilities
trivy image --severity HIGH,CRITICAL $(IMAGE):$(TAG)
Make for Terraform/Ansible Workflows¶
TF_DIR := devops/terraform/modules
ENV ?= dev
tf-plan: ## Plan Terraform changes
cd $(TF_DIR)/$(MODULE) && terraform init -backend-config=backends/$(ENV).hcl
cd $(TF_DIR)/$(MODULE) && terraform plan -var-file=vars/$(ENV).tfvars -out=plan.tfplan
tf-apply: ## Apply Terraform plan
cd $(TF_DIR)/$(MODULE) && terraform apply plan.tfplan
ansible-bootstrap: ## Bootstrap new nodes
cd devops/ansible && ansible-playbook -i inventory/$(ENV) playbooks/bootstrap.yml --diff
Environment Variable Integration¶
Make passes its variables to recipe subshells and reads from the environment:
# ?= lets environment override the Makefile default
AWS_REGION ?= us-east-1
KUBECONFIG ?= $(HOME)/.kube/config
# Export makes the variable available to all recipe subshells
export AWS_REGION
export KUBECONFIG
# Command-line variables override everything (except override directive)
# Usage: make deploy ENV=staging AWS_REGION=eu-west-1
deploy:
@echo "Deploying to $(ENV) in $(AWS_REGION)"
kubectl --kubeconfig=$(KUBECONFIG) apply -f manifests/
Variable precedence (highest to lowest):
1. Command line: make FOO=bar
2. Makefile assignment (unless ?=)
3. Environment variables
4. Make's defaults
Use ?= for variables that should be overridable from the environment. Use := for variables that should be computed once and not change.
Comparison with Alternatives¶
| Feature | Make | just | Taskfile | invoke | npm scripts |
|---|---|---|---|---|---|
| Language | Custom DSL | Custom DSL | YAML | Python | JSON |
| Dependency tracking | File timestamps | None | File/task | None | None |
| Available everywhere | Yes (POSIX) | Install required | Install required | Python required | Node required |
| Shell per line | Yes (footgun) | No | No | No | No |
| Tab-sensitive | Yes (footgun) | No | No | No | No |
| Variables/functions | Extensive | Basic | Basic | Full Python | Limited |
| Parallel execution | make -j N |
Limited | --parallel |
Limited | npm-run-all |
| Self-documenting | With help pattern |
Built-in (--list) |
Built-in | Docstrings | npm run |
| Cross-platform | macOS/Linux/WSL | macOS/Linux/Windows | macOS/Linux/Windows | macOS/Linux/Windows | macOS/Linux/Windows |
Make when you want zero dependencies, file-based dependency tracking, or the repo already uses it. just when you want cleaner syntax without Make's footguns. Taskfile when you prefer YAML and Go tooling. invoke when your team is Python-native. npm scripts when the tasks are simple and you are already in Node.
Quick Reference¶
Anatomy of a Rule¶
Essential Command-Line Flags¶
make # Build default target
make target # Build specific target
make -n # Dry run — print commands without executing
make -j4 # Parallel execution with 4 jobs
make -j$(nproc) # Parallel with all CPU cores
make -k # Keep going after errors
make -B # Unconditionally rebuild all targets
make -d # Debug output (verbose)
make --trace # Print each target as it executes
make -p # Print the internal database (all rules and variables)
make -f other.mk # Use a different Makefile
make -C subdir # Change to directory before reading Makefile
make VAR=value # Override a variable
make -e # Environment variables override Makefile variables
make --warn-undefined-variables # Warn on use of undefined variables
Variable Quick Reference¶
VAR = value # Recursive (lazy) — re-evaluated on each use
VAR := value # Simple (eager) — evaluated once at assignment
VAR ?= value # Conditional — set only if not already set
VAR += value # Append — adds to existing value
override VAR = x # Override — cannot be changed by command line
export VAR # Export to subshell environment
unexport VAR # Do not export to subshell
Automatic Variables Cheat Sheet¶
$@ Target filename $< First prerequisite
$^ All prerequisites $? Prerequisites newer than target
$* Pattern stem (% match) $(@D)/$(@F) Directory/filename of target
Common Functions (One-Line Reference)¶
$(wildcard) $(patsubst) $(shell) $(foreach) $(call) $(filter) $(filter-out) $(sort) $(addprefix) $(addsuffix) $(if) $(error) $(warning) $(info) — see Core Concepts section 8 for examples.
Wiki Navigation¶
Prerequisites¶
- Linux Ops (Topic Pack, L0)
Related Content¶
- Advanced Bash for Ops (Topic Pack, L1) — Bash / Shell Scripting
- Bash Exercises (Quest Ladder) (CLI) (Exercise Set, L0) — Bash / Shell Scripting
- Bash Flashcards (CLI) (flashcard_deck, L1) — Bash / Shell Scripting
- Cron & Job Scheduling (Topic Pack, L1) — Bash / Shell Scripting
- Environment Variables (Topic Pack, L1) — Bash / Shell Scripting
- Fleet Operations at Scale (Topic Pack, L2) — Bash / Shell Scripting
- LPIC / LFCS Exam Preparation (Topic Pack, L2) — Bash / Shell Scripting
- Linux Ops (Topic Pack, L0) — Bash / Shell Scripting
- Linux Ops Drills (Drill, L0) — Bash / Shell Scripting
- Linux Text Processing (Topic Pack, L1) — Bash / Shell Scripting