GNU Make is one of the oldest and most reliable build automation tools in software development, yet it remains deeply relevant in modern DevOps workflows. A Makefile acts as a self-documenting command runner that codifies every build, test, lint, and deploy action in a single file that any developer or CI system can invoke with a single command. In this tutorial you will install Make on RHEL 8, learn Makefile syntax from fundamentals to advanced patterns, and build a practical Makefile that works for Go, Python, and Node.js projects with full CI/CD integration.
Prerequisites
- RHEL 8 system with sudo privileges
- Basic shell scripting knowledge
- A project directory to practice in (any language)
- Docker installed if following the container build targets
Step 1 — Install Make on RHEL 8
Make is part of the make package in the RHEL 8 BaseOS repository. It is often already installed; confirm with make --version.
sudo dnf install -y make
make --version
# Also install the full development toolchain for compiled projects
sudo dnf groupinstall -y "Development Tools"
gcc --version
Step 2 — Understand Makefile Syntax: Targets, Variables, and .PHONY
A Makefile consists of rules with the form target: prerequisites followed by an indented (tab-only) recipe. Variables substitute repeated values; .PHONY prevents conflicts with files of the same name.
mkdir -p ~/make-demo && cd ~/make-demo
cat > Makefile </dev/null || echo "dev")
BUILD_DIR := ./build
# .PHONY tells make these are not file targets
.PHONY: all build test clean help
# Default target — runs when you type just "make"
all: build
## build: Compile the application
build:
@echo "Building $(APP_NAME) version $(VERSION)..."
mkdir -p $(BUILD_DIR)
go build -ldflags="-X main.Version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/...
## test: Run all tests
test:
go test ./... -v -cover
## clean: Remove build artifacts
clean:
rm -rf $(BUILD_DIR)
@echo "Cleaned."
## help: Show this help message
help:
@grep -E '^## ' Makefile | sed 's/^## //'
EOF
# Display the help output
make help
Step 3 — Use Automatic Variables: $@, $<, and $^
Make’s automatic variables reduce duplication in recipes. $@ is the target name, $< is the first prerequisite, and $^ is the full list of prerequisites.
cat >> Makefile << 'EOF'
# Pattern rule: compile any .c file to a .o object file
# $< = the .c source file, $@ = the .o output file
%.o: %.c
gcc -Wall -c $< -o $@
# Link object files into a binary
# $^ = all prerequisites (all .o files), $@ = the target binary
$(BUILD_DIR)/program: main.o utils.o
gcc $^ -o $@
@echo "Linked $@"
# Run lint tools across all Python files in the project
PYTHON_FILES := $(shell find . -name "*.py" -not -path "./.venv/*")
lint:
flake8 $(PYTHON_FILES)
@echo "Lint passed for: $^"
EOF
Step 4 — Practical Targets for Docker and Deployment
Add Docker build, push, and deploy targets to make the Makefile a complete CI/CD command interface. Using $(VERSION) for image tags ensures every image is traceable to a specific commit.
cat >> Makefile << 'EOF'
REGISTRY := docker.io/YOUR_USERNAME
IMAGE := $(REGISTRY)/$(APP_NAME):$(VERSION)
IMAGE_LATEST := $(REGISTRY)/$(APP_NAME):latest
.PHONY: docker-build docker-push deploy
## docker-build: Build the Docker image
docker-build:
docker build -t $(IMAGE) -t $(IMAGE_LATEST) .
@echo "Built image: $(IMAGE)"
## docker-push: Push the Docker image to the registry
docker-push: docker-build
docker push $(IMAGE)
docker push $(IMAGE_LATEST)
## deploy: Deploy to Kubernetes (requires kubectl context)
deploy:
kubectl set image deployment/$(APP_NAME)
$(APP_NAME)=$(IMAGE)
--record
kubectl rollout status deployment/$(APP_NAME)
EOF
# Now run a full pipeline
make test
make docker-build
make docker-push
Step 5 — Language-Specific Patterns: Python and Node.js
Make is language-agnostic. Here are idiomatic patterns for Python virtual environments and Node.js projects — each following the same convention so every project is operated the same way.
# Python project Makefile snippet
cat > Makefile.python < Makefile.node << 'EOF'
.PHONY: install build test lint clean
install:
npm ci
build: install
npm run build
test: install
npm test -- --watchAll=false
lint: install
npm run lint
clean:
rm -rf node_modules dist
EOF
Step 6 — Integrate Make into a CI/CD Pipeline
Because make is ubiquitous, any CI system — GitHub Actions, GitLab CI, Jenkins, or Drone — can call Makefile targets directly. This keeps CI YAML minimal and moves logic into the repository.
# GitHub Actions example (.github/workflows/ci.yml)
cat > ci-example.yml << 'EOF'
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: make install
- name: Lint
run: make lint
- name: Test
run: make test
- name: Build Docker image
run: make docker-build
- name: Push image
if: github.ref == 'refs/heads/main'
run: make docker-push
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
EOF
# Run the full pipeline locally before pushing
make clean install lint test docker-build
Conclusion
You have installed Make on RHEL 8, mastered core Makefile syntax including targets, .PHONY, variables, pattern rules, and automatic variables ($@, $<, $^), and built a practical Makefile covering build, test, lint, Docker image creation, registry push, and Kubernetes deployment. Adopting Make as your project’s command interface creates a consistent developer experience across Go, Python, and Node.js projects — any developer new to the codebase can run make help and immediately understand how to build, test, and ship the application. CI systems call the same Makefile targets as developers, eliminating the “works on my machine” class of CI failures.
Next steps: Combining Make with Docker Compose for Local Development Environments on RHEL 8, Caching Docker Layers in CI Pipelines with BuildKit on RHEL 8, and Managing Multi-Service Deployments with Makefile and Helm on RHEL 8.