How to Use Makefile for Build Automation on RHEL 7
The Makefile has been a cornerstone of software build automation since the 1970s, and it remains one of the most versatile and universally available tools on any Linux system. On RHEL 7, GNU Make is installed by default as part of the base system or the Development Tools package group. While modern build systems like CMake, Gradle, or Bazel have grown in popularity, Makefiles continue to provide something those tools often lack: simplicity, zero dependencies, and the ability to orchestrate any shell command without framework lock-in. Whether you are compiling C programs, managing Python project tasks, building Docker images, or automating deployment steps, a well-structured Makefile brings consistency and self-documentation to your workflows. This guide covers everything from basic syntax to advanced patterns including variables, automatic variables, pattern rules, and self-documenting help targets.
Prerequisites
- RHEL 7 with GNU Make installed
- Basic familiarity with the shell
gccavailable if you want to follow the C compilation examples
# Verify Make is installed
make --version
# Install if missing (usually part of Development Tools)
sudo yum install -y make
sudo yum groupinstall -y "Development Tools"
Step 1: Understanding Makefile Syntax — Targets, Prerequisites, and Recipes
A Makefile is composed of rules. Each rule has three parts: a target (what to build), zero or more prerequisites (what must exist or be built first), and a recipe (the shell commands that produce the target). The recipe must be indented with a hard tab character — spaces will cause a fatal error.
# Basic Makefile structure
# target: prerequisites
# [TAB]recipe
# Example: compile a C program
myapp: main.o utils.o
gcc -o myapp main.o utils.o
main.o: main.c
gcc -c main.c -o main.o
utils.o: utils.c
gcc -c utils.c -o utils.o
clean:
rm -f *.o myapp
When you run make, it reads the first target in the Makefile (the default goal) and checks whether the target is older than its prerequisites. If so, it executes the recipe. This timestamp-based dependency resolution means Make only rebuilds what has changed — a critical feature for large projects with hundreds of source files.
Step 2: .PHONY Targets
A problem arises when a target name matches a file in the current directory. If a file called clean exists, make clean would find the file, determine it has no prerequisites, conclude it is up-to-date, and do nothing. The .PHONY special target tells Make that listed targets are always out-of-date and should always run their recipes, regardless of any matching files.
.PHONY: all clean test build install deploy help
all: build
clean:
rm -rf dist/ build/ *.pyc __pycache__
test:
python -m pytest tests/ -v
build:
python setup.py bdist_wheel
install:
pip install -e .
deploy:
ansible-playbook -i inventory/production site.yml
Declaring commonly used task names like all, clean, test, build, install, and deploy as phony is a best practice and ensures predictable behavior across all environments.
Step 3: Variables — =, :=, and ?=
Make supports several variable assignment operators, each with different evaluation semantics. Understanding the differences prevents subtle bugs in complex Makefiles.
# Recursively expanded variable (= )
# The value is re-evaluated every time the variable is referenced.
# Can cause infinite loops if the variable references itself.
CC = gcc
CFLAGS = -Wall -O2
# Simply expanded variable ( := )
# Evaluated once, at the point of definition.
# Safer for variables that include shell commands or other variables.
BUILD_DATE := $(shell date +%Y-%m-%d)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
# Conditional assignment ( ?= )
# Only sets the variable if it is not already defined.
# Useful for providing defaults that can be overridden from the command line.
ENVIRONMENT ?= development
LOG_LEVEL ?= info
REGISTRY ?= quay.io/myorg
# Immediate evaluation example
VERSION := 1.2.3
IMAGE_TAG := $(REGISTRY)/myapp:$(VERSION)
Override variables at the command line:
make build ENVIRONMENT=production LOG_LEVEL=debug
make deploy REGISTRY=harbor.internal.example.com
Step 4: Automatic Variables
Make provides a set of built-in automatic variables that refer to parts of the current rule. These are essential for writing generic, reusable rules.
# $@ — The target name
# $< — The first prerequisite
# $^ — All prerequisites (space-separated, deduplicated)
# $* — The stem of a pattern rule (the % part)
# $? — All prerequisites newer than the target
# Example showing automatic variables in action:
OBJECTS = main.o utils.o config.o
myapp: $(OBJECTS)
$(CC) $(CFLAGS) -o $@ $^
@echo "Built $@ from $^"
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
@echo "Compiled $< into $@"
# The @ prefix on a recipe line suppresses echoing that line.
# Use it to keep output clean for informational echo statements.
Step 5: Pattern Rules
Pattern rules use the % wildcard to match multiple targets with a single rule, eliminating repetitive target definitions. The % in the target matches any non-empty string (the stem), and the same % in the prerequisites is replaced with the same stem.
# Generic pattern rule: compile any .c file to .o
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Convert any .md file to .html using pandoc
%.html: %.md
pandoc -s $< -o $@
# Minify any .js file (producing .min.js)
%.min.js: %.js
terser $< -o $@
# Run any Python script named test_*.py
test_%: test_%.py
python $<
Step 6: The include Directive
Large projects often split Makefile logic across multiple files. The include directive inserts the contents of another file at that point in the Makefile, similar to #include in C or source in bash.
# Include environment-specific variables
include config/$(ENVIRONMENT).mk
# Include auto-generated dependency files created by gcc -MM
-include $(OBJECTS:.o=.d)
# Generate dependency files automatically
%.d: %.c
$(CC) -MM $ $@
The hyphen before -include tells Make to silently ignore missing include files rather than raising an error — useful for dependency files that are generated during the build.
Step 7: A Self-Documenting Makefile with a help Target
One of the best practices for Makefiles used by development teams is a help target that prints a summary of all available targets and their descriptions. The most popular pattern uses ## comment annotations and awk to extract them:
# Self-documenting Makefile — add ## comments after target definitions
.DEFAULT_GOAL := help
.PHONY: help build test clean deploy lint docker-build docker-push
help: ## Show this help message
@awk 'BEGIN {FS = ":.*##"; printf "nUsage:n make 33[36m33[0mnnTargets:n"}
/^[a-zA-Z_-]+:.*?##/ { printf " 33[36m%-20s33[0m %sn", $$1, $$2 }' $(MAKEFILE_LIST)
build: ## Build the application binary
@echo "Building $(APP_NAME) version $(VERSION)..."
go build -ldflags="-X main.Version=$(VERSION) -X main.BuildDate=$(BUILD_DATE)"
-o dist/$(APP_NAME) ./cmd/
test: ## Run unit tests
go test ./... -v -cover -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
lint: ## Run linters
golangci-lint run ./...
clean: ## Remove build artifacts
rm -rf dist/ coverage.out coverage.html
docker-build: ## Build the Docker image
docker build -t $(IMAGE_TAG) --build-arg VERSION=$(VERSION) .
docker-push: ## Push the Docker image to the registry
docker push $(IMAGE_TAG)
deploy: ## Deploy to the target environment (ENVIRONMENT=production)
@echo "Deploying to $(ENVIRONMENT)..."
ansible-playbook -i inventory/$(ENVIRONMENT)
-e "app_version=$(VERSION)"
playbooks/deploy.yml
Running make help (or simply make, since it is the default goal) prints a color-coded list of all targets with their descriptions.
Step 8: Practical Multi-Language Example
# Complete practical Makefile for a Python web project on RHEL 7
APP_NAME := mywebapp
VERSION := $(shell cat VERSION 2>/dev/null || echo "0.0.1")
PYTHON := python3
PIP := pip3
VENV_DIR := .venv
ACTIVATE := source $(VENV_DIR)/bin/activate
REGISTRY ?= quay.io/myorg
IMAGE_TAG := $(REGISTRY)/$(APP_NAME):$(VERSION)
.DEFAULT_GOAL := help
.PHONY: help venv install test lint format clean build docker-build docker-push deploy
help: ## Show this help
@awk 'BEGIN {FS=":.*##"} /^[a-zA-Z_-]+:.*?##/{printf " %-18s %sn",$$1,$$2}'
$(MAKEFILE_LIST)
venv: ## Create virtual environment
$(PYTHON) -m venv $(VENV_DIR)
install: venv ## Install dependencies
$(ACTIVATE) && $(PIP) install -r requirements.txt
test: ## Run test suite
$(ACTIVATE) && pytest tests/ -v --tb=short
lint: ## Lint code with flake8
$(ACTIVATE) && flake8 $(APP_NAME)/ tests/
format: ## Format code with black
$(ACTIVATE) && black $(APP_NAME)/ tests/
clean: ## Remove artifacts and virtualenv
rm -rf $(VENV_DIR) dist/ build/ *.egg-info __pycache__ .pytest_cache
build: install ## Build distributable package
$(ACTIVATE) && python setup.py sdist bdist_wheel
docker-build: ## Build Docker image
docker build -t $(IMAGE_TAG) .
docker-push: docker-build ## Build and push Docker image
docker push $(IMAGE_TAG)
deploy: ## Deploy with Ansible (set ENVIRONMENT=production)
ansible-playbook -i inventory/$(ENVIRONMENT)
-e "app_version=$(VERSION)" playbooks/deploy.yml
Conclusion
GNU Make on RHEL 7 is a powerful, zero-dependency task runner that has stood the test of time for good reason. By mastering the distinction between =, :=, and ?= variable assignment, leveraging automatic variables like $@, $<, and $^, and using pattern rules to eliminate repetition, you can write Makefiles that are both concise and maintainable. The self-documenting help target pattern makes Makefiles discoverable for new team members. Whether you are compiling C code, packaging Python wheels, building container images, or orchestrating Ansible deployments, a well-designed Makefile provides a consistent, documented interface to your project’s automation — one that works identically on any RHEL 7 machine with no additional tooling required.