Production Makefile Conventions
Open any healthy GitHub repository and you will find a Makefile whose targets are surprisingly predictable: build, test, lint, ci, deploy, clean. This is not coincidence. Teams converge on a small, shared vocabulary of targets because it makes every repository feel familiar. New contributors can run make test without reading docs.
This lesson is about the conventions professional projects use, the Docker integration patterns that matter, and the honest assessment of when Make is the right tool and when just, task, or bazel would serve you better.
The targets every Makefile should have: help, build, test, lint, clean, and usually ci. Sticking to this vocabulary means anyone can navigate your repo. Deviate only when you have a specific reason.
The canonical target set
A production-quality Makefile for almost any language has these targets:
A complete Go project Makefile
# Go service Makefile — canonical conventions
# --- config ---
BINARY := myapp
PKG := github.com/myorg/myapp
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_TS := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*')
LDFLAGS := -ldflags "-X $(PKG)/version.Version=$(VERSION) \
-X $(PKG)/version.Commit=$(COMMIT) \
-X $(PKG)/version.BuildTS=$(BUILD_TS)"
BUILD_DIR := bin
DOCKER_IMAGE := $(BINARY)
# --- meta ---
.PHONY: help build test lint clean ci run docker-build docker-run tidy
.DEFAULT_GOAL := help
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} \
/^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' \
$(MAKEFILE_LIST)
# --- develop ---
run: build ## Run the app locally
@./$(BUILD_DIR)/$(BINARY)
tidy: ## Tidy go.mod
@go mod tidy
@go mod verify
# --- build ---
build: $(BUILD_DIR)/$(BINARY) ## Build the binary
$(BUILD_DIR)/$(BINARY): $(GO_FILES) | $(BUILD_DIR)
@echo "building $(BINARY) $(VERSION) ($(COMMIT))"
@go build $(LDFLAGS) -o $@ ./cmd/$(BINARY)
$(BUILD_DIR):
@mkdir -p $@
# --- test ---
test: ## Run tests
@go test -race -coverprofile=coverage.out ./...
cover: test ## Show test coverage summary
@go tool cover -func=coverage.out
# --- lint ---
lint: ## Run linters
@golangci-lint run
fmt: ## Format code
@gofmt -w .
@goimports -w .
# --- clean ---
clean: ## Remove build artifacts
@rm -rf $(BUILD_DIR) coverage.out
# --- ci ---
ci: lint test build ## Full CI check
# --- docker ---
docker-build: ## Build Docker image
@docker build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
-t $(DOCKER_IMAGE):$(VERSION) \
-t $(DOCKER_IMAGE):latest \
.
docker-run: docker-build ## Run Docker image locally
@docker run --rm -p 8080:8080 $(DOCKER_IMAGE):latest
Every target follows the naming convention. Every target has a ## description so make help works. Version metadata comes from git. File targets ($(BUILD_DIR)/$(BINARY)) cache correctly.
A complete Node / TypeScript Makefile
.PHONY: help install build test lint clean ci dev docker-build
.DEFAULT_GOAL := help
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install: ## Install dependencies
@npm ci
build: node_modules ## Build the app
@npm run build
test: node_modules ## Run tests
@npm test
lint: node_modules ## Run eslint + prettier check
@npm run lint
clean: ## Remove build artifacts
@rm -rf dist node_modules coverage
ci: lint test build ## Full CI check
dev: node_modules ## Run dev server
@npm run dev
node_modules: package.json package-lock.json
@npm ci
@touch node_modules # update mtime so make sees it as up-to-date
docker-build: ## Build Docker image
@docker build -t myapp:latest .
Note the node_modules: package.json package-lock.json rule. Treating node_modules as a file target with proper prerequisites means Make reinstalls when lockfile changes and skips when it doesn't.
The ci target — what should it do?
make ci is the single command your CI pipeline should run. Everything a PR needs to pass:
ci: lint test build ## Full CI check
That chain means:
lintruns first. Fast, catches most issues.testruns if lint passes.buildruns if test passes. Proves the code compiles in production mode.
In your CI config:
# GitHub Actions
- name: Run CI
run: make ci
Or for composability:
- run: make lint
- run: make test
- run: make build
Splitting gives you better CI UI; calling make ci is simpler but loses the per-step times.
Docker integration patterns
Pattern 1: build images via make
DOCKER_IMAGE := myorg/myapp
VERSION := $(shell git describe --tags --always --dirty)
docker-build: ## Build Docker image
docker build \
--build-arg VERSION=$(VERSION) \
-t $(DOCKER_IMAGE):$(VERSION) \
-t $(DOCKER_IMAGE):latest \
.
docker-push: docker-build ## Build and push
docker push $(DOCKER_IMAGE):$(VERSION)
docker push $(DOCKER_IMAGE):latest
Pattern 2: run commands inside the container
For repos where the build tools live only in a container (e.g. specific Go / JDK versions):
DOCKER_RUN := docker run --rm -v $(PWD):/app -w /app golang:1.22
build: ## Build inside a containerized toolchain
$(DOCKER_RUN) go build -o bin/app .
test: ## Test inside a containerized toolchain
$(DOCKER_RUN) go test ./...
This gives every contributor the same toolchain without them needing to install anything locally.
Pattern 3: docker-compose targets
up: ## Start dev stack with docker-compose
docker-compose up -d
down: ## Stop dev stack
docker-compose down
logs: ## Tail logs
docker-compose logs -f
psql: up ## Open psql shell
docker-compose exec db psql -U postgres
The run and dev targets
Often confused. Convention:
run: run the built binary/image. Presumesmake buildis done first.dev: run in dev mode (hot reload, debug flags, mock services).
run: build
./$(BINARY)
dev:
air # or nodemon, or whatever does hot reload
Versioning via git
Embed git version info into your binary:
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
build:
go build -ldflags "-X main.Version=$(VERSION) -X main.Commit=$(COMMIT)" -o bin/app .
Now ./bin/app --version prints something like v1.2.3 (abc1234). Invaluable for "what version is running in prod?"
The --dirty suffix
git describe --dirty appends -dirty to the version if the working tree has uncommitted changes. This makes it obvious when a binary was built from an uncommitted state — a common source of production mysteries.
Tips that pay off
Tip 1: default to help, not a random target
.DEFAULT_GOAL := help
Running make with no args should print usage, not accidentally trigger something.
Tip 2: fast shortcuts for common operations
# Single-letter aliases for frequently-run targets
.PHONY: b t l c
b: build
t: test
l: lint
c: clean
make t is faster to type than make test. Used liberally by the people maintaining the project.
Tip 3: timestamps on long-running output
test: ## Run tests
@date -u +'%Y-%m-%dT%H:%M:%SZ start'
@go test ./...
@date -u +'%Y-%m-%dT%H:%M:%SZ end'
When tests take minutes, seeing start/end timestamps in CI logs helps with "when did this regression start?"
Tip 4: dry-run for dangerous targets
deploy: ## Deploy to production (requires CONFIRM=yes)
@[[ "$(CONFIRM)" = "yes" ]] || (echo "use CONFIRM=yes to deploy" >&2; exit 1)
kubectl apply -f manifests/
Forces explicit confirmation on irreversible targets.
Tip 5: colors for human-friendly output
GREEN := \033[0;32m
YELLOW := \033[0;33m
RED := \033[0;31m
NC := \033[0m
build:
@printf "$(GREEN)Building...$(NC)\n"
@go build ./...
@printf "$(GREEN)Done.$(NC)\n"
A little color goes a long way in CI logs.
When Make is the wrong tool
Make is widely supported and does its job. But it has real limitations:
1. Scripting complex conditionals
Make is awful for branching logic. Every if/else is painful; backslash continuations ugly; arguments awkward to pass. When you find yourself writing 20 lines of shell inside a single Make recipe, extract to a script and call it.
2. Cross-platform (Windows) support
Make on Windows is possible but painful. Paths, shells, escape characters all differ. Projects that need first-class Windows support often pick a cross-platform task runner instead.
3. Complex dependency graphs
Make's timestamp-based dependency tracking is fine for "if A is newer than B, rebuild B." It's bad at content-based caching ("rebuild if file hash changes") or cross-target caching ("use remote artifact if available"). Large monorepos outgrow this.
The alternatives: just, task, bazel
just
A modern task runner without Make's quirks. No tabs. Native argument support. Fish-shell-like recipe syntax.
# Justfile
build:
go build -o bin/app .
test:
go test ./...
deploy env:
kubectl apply -f manifests/{{env}}/
Use cases: repos where you want command aliases but never needed Make's dependency tracking. Strong choice for "just run commands."
task (go-task)
YAML-based task runner. Very popular in Go projects.
# Taskfile.yml
version: '3'
tasks:
build:
cmds:
- go build -o bin/app .
sources:
- ./**/*.go
generates:
- ./bin/app
Has timestamp-based caching like Make but without the syntax quirks.
bazel / buck / pants
Heavyweight polyglot build systems. Content-based caching, remote build execution, reproducible builds. Worth it for:
- Monorepos with 100+ people.
- Polyglot codebases (Go + Python + TypeScript + Proto) that need shared build graph.
- Cases where CI build time is a major productivity issue.
Not worth it for: single-service, single-language repos.
Start with Make. Almost every repo should use it. Only move to just/task if Make's quirks are actively hurting productivity. Only move to bazel if you have a specific, large-scale problem it solves.
The decision matrix
| Scenario | Tool |
|---|---|
| Single-service repo, simple commands | make |
| Don't need dependency graph, just command aliases | just |
| Go project with caching but without Make quirks | task (go-task) |
| Cross-platform (Windows required) | task or just |
| Monorepo with 50+ services, polyglot | bazel / buck |
| "I've never used make and just want to run commands" | just |
Make remains the most widely supported and the zero-setup default. The right choice for most repos.
Putting it all together — a real-world Makefile
Here is the Makefile for an imagined Go microservice. It combines everything from all three lessons:
# --- config ---
BINARY := myapi
PKG := github.com/myorg/myapi
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*')
BUILD_DIR := bin
DOCKER_IMAGE := myorg/myapi
LDFLAGS := -ldflags "-X $(PKG)/version.Version=$(VERSION) \
-X $(PKG)/version.Commit=$(COMMIT)"
ENV ?= dev
# --- meta ---
.PHONY: help build test cover lint fmt clean ci run dev \
docker-build docker-run docker-push \
db-up db-down migrate seed \
tidy generate deploy
.DEFAULT_GOAL := help
# --- help ---
help: ## Show this help
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# --- dev ---
run: build ## Run the binary
./$(BUILD_DIR)/$(BINARY)
dev: ## Hot-reload dev mode
air
tidy: ## Tidy go.mod
go mod tidy
go mod verify
generate: ## Run go generate
go generate ./...
# --- build ---
build: $(BUILD_DIR)/$(BINARY) ## Build the binary
$(BUILD_DIR)/$(BINARY): $(GO_FILES) | $(BUILD_DIR)
@echo "building $(VERSION) ($(COMMIT))"
@go build $(LDFLAGS) -o $@ ./cmd/$(BINARY)
$(BUILD_DIR):
@mkdir -p $@
# --- test ---
test: ## Run all tests
@go test -race -coverprofile=coverage.out ./...
cover: test ## Show coverage summary
@go tool cover -func=coverage.out
# --- lint ---
lint: ## Run linters
@golangci-lint run
fmt: ## Format code
@gofmt -w .
@goimports -w .
# --- clean ---
clean: ## Remove build artifacts
@rm -rf $(BUILD_DIR) coverage.out
# --- ci ---
ci: lint test build ## Full CI check
# --- docker ---
docker-build: ## Build Docker image
@docker build \
--build-arg VERSION=$(VERSION) \
--build-arg COMMIT=$(COMMIT) \
-t $(DOCKER_IMAGE):$(VERSION) \
-t $(DOCKER_IMAGE):latest \
.
docker-run: docker-build ## Run the image
@docker run --rm -p 8080:8080 $(DOCKER_IMAGE):latest
docker-push: docker-build ## Push image
@docker push $(DOCKER_IMAGE):$(VERSION)
@docker push $(DOCKER_IMAGE):latest
# --- database ---
db-up: ## Start local db
@docker-compose up -d db
db-down: ## Stop local db
@docker-compose stop db
migrate: ## Run database migrations
@migrate -path ./migrations -database $(DATABASE_URL) up
seed: db-up ## Seed dev data
@go run ./cmd/seed
# --- deploy ---
deploy: ## Deploy to $(ENV) (set CONFIRM=yes for prod)
@if [ "$(ENV)" = "prod" ] && [ "$(CONFIRM)" != "yes" ]; then \
echo "prod deploy requires CONFIRM=yes"; exit 1; \
fi
@kubectl apply -f manifests/$(ENV)/
Every convention from all three lessons. This is roughly what a mid-to-large Go service's Makefile looks like.
Quiz
Your team wants to standardize on a single command so contributors and CI both run the same checks. You currently have separate make lint, make test, make build targets. Whats the right move?
What to take away
- The canonical target vocabulary: help, build, test, lint, clean, ci, run (and often dev).
.DEFAULT_GOAL := helpsomakealone prints usage.- Self-documenting help target. Every target should have a
## descriptioncomment. make ci= lint + test + build. One command for contributors and CI to share.- Use file targets (
$(BUILD_DIR)/$(BINARY)) for artifacts so Make caches correctly. - Embed git version info with
-ldflagsor equivalent for your language. - Docker integration: build, run, push targets that pull from the same
$(VERSION)variable. - Confirm dangerous targets (prod deploy) with a required flag.
- Know when Make is wrong: complex branching → extract scripts; cross-platform →
taskorjust; monorepo scale →bazel.
What to take away from the whole course
You've completed Bash and Shell Scripting for Engineers. The 21 lessons cover:
- How Bash actually parses — quoting, expansion, subshells, the parsing order.
- Variables, arrays, and parameter expansion — the forms every senior engineer uses daily.
- Control flow —
[[ ]],(( )), loops that don't break on weird inputs, functions withlocal. - Error handling —
set -euo pipefail, traps, exit codes, propagation. - Production scripts — argument parsing, file/path safety, structure, when to switch languages.
- Debugging —
set -x,PS4, ShellCheck, common failure modes. - Makefiles — rules, variables, functions, patterns, the production conventions.
The single biggest takeaway: most Bash bugs come from a small set of causes. Quoting. Unset variables. Pipeline subshells. Missing strict mode. Once you internalize those, you stop writing bugs. Combined with ShellCheck in CI, the failure rate of your scripts drops to near zero.
Bash is a glue language. It's the right tool for stitching commands together and wrong for real application logic. This course taught you to use it well for what it's good at and recognize when to reach for Python or Go instead.
Thanks for taking the course. If it helped, the best thing you can do is write cleaner scripts for the next person who reads them.