Real-World Makefiles

Summary: in this tutorial, you will learn complete, production-ready makefiles for five real-world projects — a c application, a docker workflow, a documentation pipeline, a polyglot project, and a self-documenting build system.

Real-World Makefiles

You've learned all the pieces — variables, pattern rules, functions, conditionals, multi-directory organization, and debugging. Now it's time to see how they come together in complete, real-world Makefiles that you can use as templates for your own projects.

What you'll find in this tutorial:

  • Project 1: C Application — A complete build system for a C project with tests, installation, and packaging
  • Project 2: Docker Workflow — Build, tag, push, and manage Docker containers
  • Project 3: Documentation Pipeline — Convert Markdown to PDF, HTML, and EPUB using Pandoc
  • Project 4: Polyglot Project — A project that combines C, Python, and shell scripts
  • Project 5: Self-Documenting Makefile — A Makefile that generates its own help text
  • Best practices checklist at the end

How to use this tutorial: You don't need to read everything in order. Jump to the project type that matches what you're building, study the Makefile, and adapt it. Each project is self-contained with its own explanation.

Project 1: C Application

A production-ready Makefile for a C project with separate source, include, and test directories.

Project Structure

myapp/
├── Makefile
├── include/
│   ├── utils.h
│   └── config.h
├── src/
│   ├── main.c
│   ├── utils.c
│   └── config.c
└── tests/
    ├── test_utils.c
    └── test_config.c

The Makefile

# ============================================================
# Project: myapp
# Description: A production C application
# ============================================================
 
# --- Safety Settings ---
.DELETE_ON_ERROR:
.SUFFIXES:
 
# --- Configuration ---
CC       := gcc
CFLAGS   := -Wall -Wextra -Wpedantic -std=c11 -MMD -MP
LDFLAGS  :=
LDLIBS   :=
 
PREFIX   ?= /usr/local
BUILD    ?= release
VERSION  := 1.0.0
 
# --- Build Mode ---
ifeq ($(BUILD),debug)
    CFLAGS  += -g -O0 -DDEBUG -fsanitize=address
    LDFLAGS += -fsanitize=address
    BUILDDIR := build/debug
else ifeq ($(BUILD),release)
    CFLAGS  += -O2 -DNDEBUG
    LDFLAGS += -s
    BUILDDIR := build/release
else
    $(error Unknown BUILD mode '$(BUILD)'. Use 'debug' or 'release')
endif
 
CFLAGS += -I include -DVERSION=\"$(VERSION)\"
 
# --- Files ---
SRCS      := $(wildcard src/*.c)
OBJS      := $(patsubst src/%.c,$(BUILDDIR)/%.o,$(SRCS))
DEPS      := $(OBJS:.o=.d)
TARGET    := $(BUILDDIR)/myapp
 
TEST_SRCS := $(wildcard tests/*.c)
TEST_OBJS := $(patsubst tests/%.c,$(BUILDDIR)/tests/%.o,$(TEST_SRCS))
TEST_DEPS := $(TEST_OBJS:.o=.d)
TEST_BIN  := $(BUILDDIR)/test_runner
 
# --- Targets ---
.DEFAULT_GOAL := all
.PHONY: all test clean install uninstall dist info
 
all: $(TARGET)
	@echo "[OK] Built $(TARGET) ($(BUILD) mode, v$(VERSION))"
 
# --- Build Rules ---
$(TARGET): $(OBJS) | $(BUILDDIR)
	$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
 
$(BUILDDIR)/%.o: src/%.c | $(BUILDDIR)
	$(CC) $(CFLAGS) -c $< -o $@
 
$(BUILDDIR)/tests/%.o: tests/%.c | $(BUILDDIR)/tests
	$(CC) $(CFLAGS) -c $< -o $@
 
# --- Directories ---
$(BUILDDIR):
	@mkdir -p $@
 
$(BUILDDIR)/tests:
	@mkdir -p $@
 
# --- Testing ---
test: $(TEST_BIN)
	@echo "Running tests..."
	@./$(TEST_BIN) && echo "[OK] All tests passed"
 
$(TEST_BIN): $(TEST_OBJS) $(filter-out $(BUILDDIR)/main.o,$(OBJS)) | $(BUILDDIR)/tests
	$(CC) $(LDFLAGS) $^ $(LDLIBS) -o $@
 
# --- Installation ---
install: $(TARGET)
	install -d $(DESTDIR)$(PREFIX)/bin
	install -m 755 $(TARGET) $(DESTDIR)$(PREFIX)/bin/myapp
 
uninstall:
	rm -f $(DESTDIR)$(PREFIX)/bin/myapp
 
# --- Distribution ---
dist:
	@mkdir -p dist
	tar -czf dist/myapp-$(VERSION).tar.gz \
		--transform 's,^,myapp-$(VERSION)/,' \
		Makefile src/ include/ tests/ README.md
	@echo "Created dist/myapp-$(VERSION).tar.gz"
 
# --- Cleanup ---
clean:
	rm -rf build dist
 
# --- Info ---
info:
	@echo "=== Build Configuration ==="
	@echo "Version:    $(VERSION)"
	@echo "Build mode: $(BUILD)"
	@echo "Compiler:   $(CC)"
	@echo "CFLAGS:     $(CFLAGS)"
	@echo "LDFLAGS:    $(LDFLAGS)"
	@echo "LDLIBS:     $(LDLIBS)"
	@echo "Prefix:     $(PREFIX)"
	@echo "Target:     $(TARGET)"
	@echo "Sources:    $(SRCS)"
	@echo "Tests:      $(TEST_SRCS)"
 
# --- Auto Dependencies ---
-include $(DEPS) $(TEST_DEPS)

Commands explained:

CommandWhat It Does
install -d $(PREFIX)/binCreates the directory if it doesn't exist (like mkdir -p)
install -m 755 file destCopies the file and sets permissions to "owner can do anything, others can read and execute"
tar -czf file.tar.gz ...Creates a compressed archive (.tar.gz). -c = create, -z = gzip compress, -f = output file name
--transform 's,^,prefix/,'Adds a directory prefix inside the archive so files extract into a folder
-fsanitize=addressA compiler/linker flag that adds runtime checks for memory bugs (buffer overflows, use-after-free). Invaluable for debugging.
-DVERSION=\"1.0.0\"Defines a C preprocessor macro so your code can use VERSION as a string

Usage

make                  # Release build
make BUILD=debug      # Debug build with sanitizers
make test             # Run tests
make install          # Install to /usr/local/bin
make PREFIX=$HOME/.local install  # Install to home directory
make dist             # Create release tarball
make clean            # Remove all build artifacts
make info             # Show configuration

Project 2: Docker Workflow

A Makefile for building and managing Docker containers. No C compilation here — Make is used purely as a task runner.

Don't know Docker? Docker is a tool that packages your application and its dependencies into a "container" — a lightweight, portable box that runs the same way everywhere. Think of it like a shipping container for software. You don't need to know Docker to understand the Makefile patterns here.

# ============================================================
# Docker Workflow Makefile
# ============================================================
 
# --- Configuration ---
APP_NAME    := mywebapp
REGISTRY    := docker.io/myuser
VERSION     := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_HASH    := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_DATE  := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
 
# Full image name
IMAGE       := $(REGISTRY)/$(APP_NAME)
TAG         := $(VERSION)
 
.PHONY: build push run stop logs clean test info
 
# --- Build the Docker image ---
build:
	docker build \
		--build-arg VERSION=$(VERSION) \
		--build-arg GIT_HASH=$(GIT_HASH) \
		--build-arg BUILD_DATE=$(BUILD_DATE) \
		--tag $(IMAGE):$(TAG) \
		--tag $(IMAGE):latest \
		.
	@echo "[OK] Built $(IMAGE):$(TAG)"
 
# --- Push to container registry ---
push: build
	docker push $(IMAGE):$(TAG)
	docker push $(IMAGE):latest
	@echo "[OK] Pushed $(IMAGE):$(TAG)"
 
# --- Run locally ---
run:
	docker run \
		--detach \
		--name $(APP_NAME) \
		--publish 8080:8080 \
		--env-file .env \
		$(IMAGE):$(TAG)
	@echo "[OK] Running at http://localhost:8080"
 
# --- Stop the container ---
stop:
	docker stop $(APP_NAME) 2>/dev/null || true
	docker rm $(APP_NAME) 2>/dev/null || true
 
# --- View logs ---
logs:
	docker logs --follow $(APP_NAME)
 
# --- Run tests in a container ---
test:
	docker run --rm $(IMAGE):$(TAG) npm test
 
# --- Clean up images ---
clean: stop
	docker rmi $(IMAGE):$(TAG) 2>/dev/null || true
	docker rmi $(IMAGE):latest 2>/dev/null || true
	@echo "[OK] Cleaned up Docker images"
 
# --- Show configuration ---
info:
	@echo "App:       $(APP_NAME)"
	@echo "Image:     $(IMAGE)"
	@echo "Tag:       $(TAG)"
	@echo "Git Hash:  $(GIT_HASH)"
	@echo "Date:      $(BUILD_DATE)"

Commands explained:

CommandWhat It Does
git describe --tags --always --dirtyGets the latest git tag, or commit hash if no tags exist; --dirty appends -dirty if there are uncommitted changes
docker build --tag name:tag .Builds a Docker image from the Dockerfile in the current directory
--build-arg KEY=VALUEPasses a variable to the Dockerfile during build
docker run --detachRuns a container in the background
--publish 8080:8080Maps port 8080 on your machine to port 8080 in the container
2>/dev/null || trueHides errors and prevents Make from stopping if the command fails

Usage

make build            # Build the Docker image
make run              # Run the container locally
make logs             # Follow container logs
make stop             # Stop and remove the container
make push             # Push to Docker registry
make test             # Run tests in a container
make clean            # Remove everything
make info             # Show image details

Project 3: Documentation Pipeline

A Makefile that converts Markdown files to PDF, HTML, and EPUB using Pandoc.

Don't know Pandoc? Pandoc is a command-line tool that converts documents between formats — Markdown to PDF, HTML to EPUB, and dozens more. It's like a universal document translator.

# ============================================================
# Documentation Pipeline
# ============================================================
 
# --- Configuration ---
PANDOC   := pandoc
SRCDIR   := docs
OUTDIR   := output
STYLE    := style/custom.css
 
# --- File Discovery ---
MD_FILES := $(wildcard $(SRCDIR)/*.md)
 
# Generate output paths for each format
PDF_FILES  := $(patsubst $(SRCDIR)/%.md,$(OUTDIR)/pdf/%.pdf,$(MD_FILES))
HTML_FILES := $(patsubst $(SRCDIR)/%.md,$(OUTDIR)/html/%.html,$(MD_FILES))
EPUB_FILES := $(patsubst $(SRCDIR)/%.md,$(OUTDIR)/epub/%.epub,$(MD_FILES))
 
# --- Pandoc Options ---
PANDOC_COMMON := --standalone --table-of-contents --number-sections
PANDOC_PDF    := $(PANDOC_COMMON) --pdf-engine=xelatex -V geometry:margin=1in
PANDOC_HTML   := $(PANDOC_COMMON) --css=$(STYLE) --self-contained
PANDOC_EPUB   := $(PANDOC_COMMON) --css=$(STYLE)
 
# --- Targets ---
.DELETE_ON_ERROR:
.PHONY: all pdf html epub clean serve info
 
all: pdf html epub
	@echo "[OK] All documents built"
 
pdf:  $(PDF_FILES)
html: $(HTML_FILES)
epub: $(EPUB_FILES)
 
# --- Build Rules ---
$(OUTDIR)/pdf/%.pdf: $(SRCDIR)/%.md | $(OUTDIR)/pdf
	@echo "Building PDF: $<"
	$(PANDOC) $(PANDOC_PDF) $< -o $@
 
$(OUTDIR)/html/%.html: $(SRCDIR)/%.md $(STYLE) | $(OUTDIR)/html
	@echo "Building HTML: $<"
	$(PANDOC) $(PANDOC_HTML) $< -o $@
 
$(OUTDIR)/epub/%.epub: $(SRCDIR)/%.md | $(OUTDIR)/epub
	@echo "Building EPUB: $<"
	$(PANDOC) $(PANDOC_EPUB) $< -o $@
 
# --- Directories ---
$(OUTDIR)/pdf $(OUTDIR)/html $(OUTDIR)/epub:
	@mkdir -p $@
 
# --- Preview ---
serve: html
	@echo "Serving docs at http://localhost:8000"
	cd $(OUTDIR)/html && python3 -m http.server 8000
 
# --- Cleanup ---
clean:
	rm -rf $(OUTDIR)
 
# --- Info ---
info:
	@echo "Source files:"
	@$(foreach f,$(MD_FILES),echo "  $(f)";)
	@echo ""
	@echo "Output files:"
	@echo "  PDF:  $(PDF_FILES)"
	@echo "  HTML: $(HTML_FILES)"
	@echo "  EPUB: $(EPUB_FILES)"

Commands explained:

CommandWhat It Does
pandoc --standaloneProduces a complete document (with <html> tags, etc.) rather than a fragment
--table-of-contentsAutomatically generates a table of contents from headings
--number-sectionsAdds numbers to headings (1.1, 1.2, etc.)
--pdf-engine=xelatexUses the XeLaTeX engine for PDF generation (supports Unicode fonts)
--self-containedEmbeds images and CSS directly into the HTML file
python3 -m http.server 8000Starts a simple web server on port 8000 to preview the files

Usage

make              # Build PDF, HTML, and EPUB for all docs
make pdf          # Build only PDFs
make html         # Build only HTML
make epub         # Build only EPUBs
make serve        # Build HTML and start a preview server
make clean        # Remove all output
make info         # Show what will be built

Project 4: Polyglot Project

A Makefile for a project that combines C (for a native library), Python (for an API), and shell scripts (for deployment):

# ============================================================
# Polyglot Project: C library + Python API + Shell scripts
# ============================================================
 
.DELETE_ON_ERROR:
.SUFFIXES:
 
# --- Languages and Tools ---
CC       := gcc
CFLAGS   := -Wall -Wextra -fPIC -MMD -MP
PYTHON   := python3
PIP      := pip3
SHELL_LINT := shellcheck
 
BUILD    := build
 
# --- C Library ---
LIB_SRCS := $(wildcard lib/src/*.c)
LIB_OBJS := $(patsubst lib/src/%.c,$(BUILD)/lib/%.o,$(LIB_SRCS))
LIB_DEPS := $(LIB_OBJS:.o=.d)
LIB_SO   := $(BUILD)/libmylib.so
 
# --- Python ---
PY_SRCS  := $(wildcard api/*.py)
PY_TESTS := $(wildcard api/tests/*.py)
 
# --- Shell Scripts ---
SCRIPTS  := $(wildcard scripts/*.sh)
 
# --- Stamp files for non-file targets ---
STAMPS   := .stamps
 
# ============================
# Targets
# ============================
.PHONY: all test lint clean info
 
all: $(LIB_SO) $(STAMPS)/pip-install
	@echo "[OK] Everything built"
 
# --- C Library ---
$(LIB_SO): $(LIB_OBJS) | $(BUILD)
	$(CC) -shared $^ -o $@
 
$(BUILD)/lib/%.o: lib/src/%.c | $(BUILD)/lib
	$(CC) $(CFLAGS) -c $< -o $@
 
# --- Python Dependencies ---
$(STAMPS)/pip-install: api/requirements.txt | $(STAMPS)
	$(PIP) install -r $<
	@touch $@
 
# --- Testing ---
test: test-c test-python test-scripts
 
test-c: $(LIB_SO)
	@echo "Running C tests..."
	cd lib/tests && $(CC) -L../../$(BUILD) -lmylib test_*.c -o test_runner && ./test_runner
 
test-python: $(STAMPS)/pip-install
	@echo "Running Python tests..."
	$(PYTHON) -m pytest api/tests/ -v
 
test-scripts:
	@echo "Running shell script tests..."
	@for script in $(SCRIPTS); do \
		bash -n "$$script" && echo "  [OK] $$script" || echo "  [FAIL] $$script"; \
	done
 
# --- Linting ---
lint: lint-python lint-scripts
 
lint-python: $(STAMPS)/pip-install
	$(PYTHON) -m flake8 api/
	$(PYTHON) -m mypy api/
 
lint-scripts:
	$(SHELL_LINT) $(SCRIPTS)
 
# --- Directories ---
$(BUILD) $(BUILD)/lib $(STAMPS):
	@mkdir -p $@
 
# --- Cleanup ---
clean:
	rm -rf $(BUILD) $(STAMPS)
	find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
 
# --- Info ---
info:
	@echo "=== C Library ==="
	@echo "  Sources: $(LIB_SRCS)"
	@echo "  Output:  $(LIB_SO)"
	@echo ""
	@echo "=== Python API ==="
	@echo "  Sources: $(PY_SRCS)"
	@echo "  Tests:   $(PY_TESTS)"
	@echo ""
	@echo "=== Shell Scripts ==="
	@echo "  Scripts: $(SCRIPTS)"
 
-include $(LIB_DEPS)

Commands explained:

CommandWhat It Does
-fPIC"Position Independent Code" — required when building shared libraries (.so files)
$(CC) -shared $^ -o $@Links object files into a shared library (.so) instead of an executable
pip install -r requirements.txtInstalls Python packages listed in the requirements file
python -m pytestRuns the Python test framework
python -m flake8Runs a Python style checker
python -m mypyRuns a Python type checker
shellcheckChecks shell scripts for common bugs and bad practices
bash -n script.shChecks a script for syntax errors without running it
find . -name __pycache__ -exec rm -rf {} +Finds and removes Python cache directories

Usage

make              # Build everything
make test         # Run all tests (C, Python, shell)
make test-python  # Run only Python tests
make lint         # Lint Python and shell code
make clean        # Remove everything
make info         # Show project overview

Project 5: Self-Documenting Makefile

This pattern makes your Makefile generate its own help text from comments:

# ============================================================
# Self-Documenting Makefile
# ============================================================
 
.DEFAULT_GOAL := help
 
# --- Configuration ---
CC     := gcc
CFLAGS := -Wall -Wextra -O2
BUILD  := build
TARGET := $(BUILD)/myapp
 
SRCS := $(wildcard src/*.c)
OBJS := $(patsubst src/%.c,$(BUILD)/%.o,$(SRCS))
 
# --- Targets ---
.PHONY: all build test clean install lint format help
 
all: build test ## Build and run tests
 
build: $(TARGET) ## Compile the application
 
test: $(TARGET) ## Run the test suite
	@echo "Running tests..."
	@./$(TARGET) --test
 
clean: ## Remove all build artifacts
	rm -rf $(BUILD)
 
install: $(TARGET) ## Install to /usr/local/bin
	install -d /usr/local/bin
	install -m 755 $(TARGET) /usr/local/bin/
 
lint: ## Run the linter on source files
	cppcheck --enable=all src/
 
format: ## Format source code with clang-format
	clang-format -i src/*.c include/*.h
 
# --- Build Rules ---
$(TARGET): $(OBJS) | $(BUILD)
	$(CC) $(CFLAGS) $^ -o $@
 
$(BUILD)/%.o: src/%.c | $(BUILD)
	$(CC) $(CFLAGS) -c $< -o $@
 
$(BUILD):
	@mkdir -p $@
 
# --- Help ---
help: ## Show this help message
	@echo "Usage: make [target]"
	@echo ""
	@echo "Targets:"
	@grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2}'

How does the self-documenting help work?

The magic is in the help target. Let's break it down:

  1. ## comments — Each target's line ends with ## description. This is just a comment to Make, but our help command reads it.

  2. grep -E '^[a-zA-Z_-]+:.*## ' — Finds all lines that look like targetname: ... ## description

  3. awk 'BEGIN {FS = ":.*## "}; {printf ...}' — Splits each line at :.*## (everything between the target name and the description), then formats it as a two-column table.

  4. \033[36m and \033[0m — These are terminal color codes. \033[36m sets the text color to cyan, and \033[0m resets it. This makes the target names colored in the terminal.

  5. $(MAKEFILE_LIST) — A special Make variable containing the name of the current Makefile.

  6. $$1 and $$2 — In a Makefile recipe, $$ produces a single $ for the shell. So $$1 is awk's $1 (first field = target name) and $$2 is awk's $2 (second field = description).

What It Looks Like

$ make help
Usage: make [target]

Targets:
  all              Build and run tests
  build            Compile the application
  test             Run the test suite
  clean            Remove all build artifacts
  install          Install to /usr/local/bin
  lint             Run the linter on source files
  format           Format source code with clang-format
  help             Show this help message

Best Practices Checklist

Use this checklist when writing any new Makefile:

Safety

  • Include .DELETE_ON_ERROR: to prevent corrupt partial files
  • Include .SUFFIXES: to clear built-in rules
  • Mark non-file targets with .PHONY:
  • Use $(MAKE) instead of make in recipes

Variables

  • Use := (simple) assignment by default
  • Use ?= for values users should be able to override
  • Follow standard naming conventions (CC, CFLAGS, PREFIX, etc.)
  • Include a VERSION variable

Dependencies

  • Use -MMD -MP for automatic header dependency tracking
  • Use -include $(DEPS) to read generated dependency files
  • Test with make -j4 to verify parallel safety

Output

  • Build into a separate build/ directory
  • Support BUILD=debug and BUILD=release modes
  • Use order-only prerequisites (|) for directories

Usability

  • Provide make clean to remove all generated files
  • Provide make info to show configuration
  • Provide make help with the self-documenting pattern
  • Validate configuration with $(error ...) for bad values

Testing

  • Always run make -n to verify commands before building
  • Test make clean && make (full rebuild)
  • Test make -j$(nproc) (parallel build)
  • Test make twice in a row (second run should do nothing)

Summary

You've now seen complete Makefiles for five different real-world scenarios:

  1. C Application — The full development lifecycle: compile, test, install, package
  2. Docker Workflow — Build, tag, run, push, and clean containers
  3. Documentation Pipeline — Multi-format document generation with Pandoc
  4. Polyglot Project — Managing C, Python, and shell code in one build
  5. Self-Documenting — Generating help text from ## comments

Key takeaways:

  • Make isn't just for C — it's a general-purpose task runner
  • Stamp files (.stamps/) track non-file actions
  • The self-documenting help pattern should be in every Makefile
  • Start with the best practices checklist for every new project
  • Copy these templates and adapt them — don't start from scratch

Congratulations! You've completed the entire Makefile tutorial series. You now have the knowledge to write professional Makefiles for any type of project.

Was this page helpful?
SR

Written by the ShellRAG Team

The ShellRAG editorial team writes practical, beginner-friendly Makefile tutorials with tested code examples and real-world use cases. Every article is technically reviewed for accuracy and updated regularly.

Learn more about us →