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:
| Command | What It Does |
|---|---|
install -d $(PREFIX)/bin | Creates the directory if it doesn't exist (like mkdir -p) |
install -m 755 file dest | Copies 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=address | A 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 configurationProject 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:
| Command | What It Does |
|---|---|
git describe --tags --always --dirty | Gets 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=VALUE | Passes a variable to the Dockerfile during build |
docker run --detach | Runs a container in the background |
--publish 8080:8080 | Maps port 8080 on your machine to port 8080 in the container |
2>/dev/null || true | Hides 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 detailsProject 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:
| Command | What It Does |
|---|---|
pandoc --standalone | Produces a complete document (with <html> tags, etc.) rather than a fragment |
--table-of-contents | Automatically generates a table of contents from headings |
--number-sections | Adds numbers to headings (1.1, 1.2, etc.) |
--pdf-engine=xelatex | Uses the XeLaTeX engine for PDF generation (supports Unicode fonts) |
--self-contained | Embeds images and CSS directly into the HTML file |
python3 -m http.server 8000 | Starts 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 builtProject 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:
| Command | What 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.txt | Installs Python packages listed in the requirements file |
python -m pytest | Runs the Python test framework |
python -m flake8 | Runs a Python style checker |
python -m mypy | Runs a Python type checker |
shellcheck | Checks shell scripts for common bugs and bad practices |
bash -n script.sh | Checks 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 overviewProject 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:
-
##comments — Each target's line ends with## description. This is just a comment to Make, but our help command reads it. -
grep -E '^[a-zA-Z_-]+:.*## '— Finds all lines that look liketargetname: ... ## description -
awk 'BEGIN {FS = ":.*## "}; {printf ...}'— Splits each line at:.*##(everything between the target name and the description), then formats it as a two-column table. -
\033[36mand\033[0m— These are terminal color codes.\033[36msets the text color to cyan, and\033[0mresets it. This makes the target names colored in the terminal. -
$(MAKEFILE_LIST)— A special Make variable containing the name of the current Makefile. -
$$1and$$2— In a Makefile recipe,$$produces a single$for the shell. So$$1is awk's$1(first field = target name) and$$2is 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 ofmakein 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
VERSIONvariable
Dependencies
- Use
-MMD -MPfor automatic header dependency tracking - Use
-include $(DEPS)to read generated dependency files - Test with
make -j4to verify parallel safety
Output
- Build into a separate
build/directory - Support
BUILD=debugandBUILD=releasemodes - Use order-only prerequisites (
|) for directories
Usability
- Provide
make cleanto remove all generated files - Provide
make infoto show configuration - Provide
make helpwith the self-documenting pattern - Validate configuration with
$(error ...)for bad values
Testing
- Always run
make -nto verify commands before building - Test
make clean && make(full rebuild) - Test
make -j$(nproc)(parallel build) - Test
maketwice in a row (second run should do nothing)
Summary
You've now seen complete Makefiles for five different real-world scenarios:
- C Application — The full development lifecycle: compile, test, install, package
- Docker Workflow — Build, tag, run, push, and clean containers
- Documentation Pipeline — Multi-format document generation with Pandoc
- Polyglot Project — Managing C, Python, and shell code in one build
- 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
helppattern 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.
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 →