Bash & Shell Scripting for Engineers

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.

KEY CONCEPT

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:

make helpPrint list of targets with descriptions. Default goal.make buildProduce the artifact (binary, container, bundle). Idempotent.make testRun the full test suite. Exit non-zero on failure.make lintRun all static analysis: linters, formatters, security scanners.make cleanRemove generated artifacts (build, dist, coverage, caches).make ciThe full check sequence a CI pipeline should run: lint + test + build.make run | make devRun the service locally (often with docker-compose or air/nodemon).

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:

  1. lint runs first. Fast, catches most issues.
  2. test runs if lint passes.
  3. build runs 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. Presumes make build is 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.

PRO TIP

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

ScenarioTool
Single-service repo, simple commandsmake
Don't need dependency graph, just command aliasesjust
Go project with caching but without Make quirkstask (go-task)
Cross-platform (Windows required)task or just
Monorepo with 50+ services, polyglotbazel / 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

KNOWLEDGE CHECK

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 := help so make alone prints usage.
  • Self-documenting help target. Every target should have a ## description comment.
  • 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 -ldflags or 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 → task or just; 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 with local.
  • Error handlingset -euo pipefail, traps, exit codes, propagation.
  • Production scripts — argument parsing, file/path safety, structure, when to switch languages.
  • Debuggingset -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.