DEV Community

Oleksandr Kuryzhev
Oleksandr Kuryzhev

Posted on • Originally published at kuryzhev.cloud

Makefile Patterns for Terraform and Infra Repos That Actually Scale

Originally published on kuryzhev.cloud


Every infra repo eventually gets a Makefile — but most of them silently break in CI, leak credentials in logs, or stop working the moment someone creates a file named plan. Makefile infra repo patterns are one of those topics where everyone has an opinion, but few people have actually debugged a production pipeline that failed because Make thought a directory was an up-to-date target. I have. Twice. Here is what I learned.

What Make Actually Does in an Infra Repo

Make was not designed for infrastructure. It was designed to avoid recompiling C files that had not changed. That file-dependency model is exactly what causes confusion when you drop it into a Terraform repo — because you are not producing files, you are running commands against remote APIs.

What Make actually gives you in an infra context is a self-documenting command interface layer over shell, Terraform, kubectl, and Ansible. Nothing more. When you run make plan, Make reads the Makefile top-to-bottom, resolves targets as a directed acyclic graph, checks whether a file named plan exists in the current directory, and if it does not find one, executes the recipe. That last part is the trap. If a file named plan exists — maybe leftover from a terraform plan -out=plan run — Make considers the target up-to-date and silently skips it. No error. No output. Nothing happens.

This is why .PHONY declarations are not optional. They tell Make: this target is a command, not a file. Declare every infra target as phony without exception.

It is also worth knowing that macOS ships with GNU Make 3.81 from 2006. Homebrew installs 4.4.1. Recipe-scoped variables using := inside targets behave differently between these versions. If your Makefile works locally but not in a fresh CI container, version mismatch is the first thing to check with make --version.

For teams uncomfortable with Make's syntax, two alternatives are worth knowing: Task (go-task) v3.35+ uses a YAML-native Taskfile.yml with built-in dotenv, preconditions, and parallel deps support. Justfile (v1.25+) is even simpler — pure shell-like syntax with no file-dependency model at all. I reach for Make when the team knows it and the repo is small. I reach for Task when the workflow grows beyond 20 targets or when I want native parallelism on lint jobs without thinking about it.

How People Use Makefile Infra Repos Wrong

Three patterns show up in almost every infra repo I have reviewed, and all three cause real production problems.

Hardcoded environment values inside targets. I see this constantly: REGION=eu-west-1 baked into the recipe line, not in a variable. The target works for the engineer who wrote it and silently targets the wrong environment for everyone else. The fix is ?= defaults at the top of the file — ENV ?= dev, REGION ?= eu-west-1 — so the target works locally and in CI without modification, but can be overridden with make plan ENV=staging.

Missing .PHONY declarations. As described above: if a file named apply, plan, or destroy ever appears in the repo, Make skips the target silently. The full declaration you need at minimum is: .PHONY: all help plan apply destroy lint fmt validate. Put it at the top. Add to it every time you add a target.

Chaining targets with && inside recipes instead of using Make's dependency graph. When you write plan: init && validate inside a recipe line, you break make -n dry-run output, you hide individual target failures, and you make parallelism impossible. The correct pattern is to declare dependencies: plan: init validate as the target line, then the recipe body. Make resolves the dependency graph before executing.

Watch out for this one: include .env at the top of a Makefile loads all values as Make variables, which means they appear in make -p output in plain text. If your .env file contains AWS credentials or tokens, anyone with access to the CI log for make -p can read them. Use dotenv in Task, or export variables inside recipe lines rather than at the Makefile top level.

Another gotcha: each line in a Make recipe is a separate shell subprocess. Using export FOO=bar on line one of a recipe does not export FOO to line two. If you need a variable to persist across recipe lines, either collapse them into one line with ; or use .ONESHELL: (GNU Make 3.82+).

The Correct Approach — Structured Makefile for Infra Repos

The pattern I use in production repos has three rules: the default target is always help, secrets never appear as top-level variable assignments, and sub-makefiles keep the root file under 30 lines.

The help target uses grep and awk to parse ## comments on target lines. Every engineer who clones the repo runs make and immediately sees documented commands. No README hunting. The guard-% macro is the second essential pattern — a reusable prerequisite target that checks required environment variables exist before any target executes. It prevents terraform apply with empty TF_VAR_ values, which is a very easy way to corrupt state.

Here is the full production Makefile pattern. Note the SHELL and .SHELLFLAGS settings at the top — these make every recipe behave like set -euo pipefail, which means unset variables and failed pipe commands abort the recipe instead of silently continuing:

# Makefile — root infra repo task interface
# Compatible with GNU Make 4.x (brew install make on macOS)
# Usage: make help

SHELL := /bin/bash
.SHELLFLAGS := -eu -o pipefail -c

# ── Overridable defaults ────────────────────────────────────────────────────
ENV        ?= dev
REGION     ?= eu-west-1
TF_DIR     ?= terraform/envs/$(ENV)
PLAN_FILE  ?= $(ENV).tfplan

# ── Default target: self-documenting help ───────────────────────────────────
.DEFAULT_GOAL := help

.PHONY: help
help: ## Show this help message
    @grep -E '^[a-zA-Z_%-]+:.*##' $(MAKEFILE_LIST) \
        | awk 'BEGIN {FS = ":.*##"}; {printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2}'

# ── Guard macro: require env var before any target runs ────────────────────
# Usage: add guard-VARNAME as a dependency
# Example: plan: guard-ENV guard-AWS_PROFILE
guard-%:
    @[ "${$*}" ] || (echo "❌  Required variable '$*' is not set"; exit 1)

# ── Include modular sub-makefiles ───────────────────────────────────────────
-include make/terraform.mk
-include make/docker.mk
-include make/lint.mk

# ── Terraform targets ───────────────────────────────────────────────────────
.PHONY: init plan apply destroy

init: guard-ENV ## terraform init for ENV (default: dev)
    @echo "→ Initialising $(TF_DIR)"
    @cd $(TF_DIR) && terraform init -reconfigure

plan: guard-ENV guard-AWS_PROFILE ## Generate plan for ENV, saves to PLAN_FILE
    @cd $(TF_DIR) && terraform plan \
        -var-file="vars/$(ENV).tfvars" \
        -out=$(PLAN_FILE)

apply: guard-ENV guard-AWS_PROFILE ## Apply saved plan (requires PLAN_FILE)
    @[ -f "$(TF_DIR)/$(PLAN_FILE)" ] || \
        (echo "❌  No plan file found. Run: make plan ENV=$(ENV)"; exit 1)
    @cd $(TF_DIR) && terraform apply $(PLAN_FILE)

destroy: guard-ENV guard-AWS_PROFILE ## Destroy ENV — requires CONFIRM=yes
    @[ "$(CONFIRM)" = "yes" ] || \
        (echo "❌  Set CONFIRM=yes to proceed with destroy"; exit 1)
    @cd $(TF_DIR) && terraform destroy -var-file="vars/$(ENV).tfvars"

# ── Lint targets — safe to parallelise ─────────────────────────────────────
.PHONY: lint fmt validate

lint: fmt validate ## Run all linters (parallelisable with make -j3 lint)
    @echo "✅  Lint complete"

fmt: ## Run terraform fmt check (non-destructive)
    @terraform fmt -check -recursive $(TF_DIR)

validate: ## Run terraform validate
    @cd $(TF_DIR) && terraform validate

# ── Dynamic per-environment plan targets ───────────────────────────────────
# Generates: plan-dev, plan-staging, plan-prod automatically
ENVS := dev staging prod

define ENV_TARGET
.PHONY: plan-$(1)
plan-$(1): guard-AWS_PROFILE ## Run plan for environment: $(1)
    @$(MAKE) plan ENV=$(1)
endef

$(foreach env,$(ENVS),$(eval $(call ENV_TARGET,$(env))))

A few things worth calling out in this file. The -include prefix on sub-makefile includes means Make silently skips the file if it does not exist — useful when not every repo has Docker targets. The $(MAKE) call inside the ENV_TARGET macro is intentional: using bare make would drop flags like -n or -j that the caller passed in. Always use $(MAKE) for recursive calls.

Advanced Patterns for Real Infrastructure Workflows

The guard-% macro and the foreach/eval pattern for generating environment targets are the two that have saved us the most time in real CI pipelines. The dynamic target generation means adding a new environment to ENVS := dev staging prod canary automatically creates plan-canary, apply-canary, and any other targets defined in the macro block. No copy-paste. No drift between environments.

For teams that find Make syntax genuinely hostile — and some do — go-task v3.35+ maps to all of these patterns in YAML. The preconditions key replaces guard-%, dotenv replaces include .env without the credential exposure risk, and deps runs subtasks in parallel by default. Here is the equivalent Taskfile:

# Taskfile.yml — go-task v3.35+ equivalent for teams preferring YAML
# Install: brew install go-task  |  version: '3' schema required
# Usage: task help

version: '3'

set: [pipefail]
shopt: [globstar]

dotenv: ['.env', '.env.{{.ENV}}']  # loads .env.dev, .env.staging etc.

vars:
  ENV: '{{.ENV | default "dev"}}'
  REGION: '{{.REGION | default "eu-west-1"}}'
  TF_DIR: 'terraform/envs/{{.ENV}}'

tasks:

  default:
    desc: Show available tasks
    cmds:
      - task --list

  plan:
    desc: "Run terraform plan for ENV (default: dev)"
    preconditions:
      - sh: '[ -n "{{.AWS_PROFILE}}" ]'
        msg: "AWS_PROFILE must be set"
      - sh: '[ -d "{{.TF_DIR}}" ]'
        msg: "Directory {{.TF_DIR}} does not exist — check ENV value"
    cmds:
      - cd {{.TF_DIR}} && terraform plan
          -var-file="vars/{{.ENV}}.tfvars"
          -out={{.ENV}}.tfplan

  lint:
    desc: Run all linters in parallel
    deps: [lint:fmt, lint:validate, lint:tflint]  # parallel by default in Task

  lint:fmt:
    internal: true
    cmds:
      - terraform fmt -check -recursive {{.TF_DIR}}

  lint:validate:
    internal: true
    cmds:
      - cd {{.TF_DIR}} && terraform validate

  lint:tflint:
    internal: true
    cmds:
      - tflint --chdir={{.TF_DIR}} --config=.tflint.hcl

The dotenv key in Task is safer than Make's include .env because Task does not expose dotenv values through a --list or debug output equivalent. It also supports per-environment files automatically: .env.staging is loaded when ENV=staging. That pattern alone has replaced a lot of fragile CI variable injection in our pipelines. More on our CI patterns at kuryzhev.cloud.

Performance Notes and Security Considerations

Make's parallel execution flag -j is genuinely useful for lint targets. Running make -j$(nproc) lint across 20 Terraform modules — tflint, tfsec, checkov in parallel — drops CI lint time from around four minutes to under 45 seconds in our experience. That is a meaningful improvement on a team running 30+ PRs a day.

But make -j4 on Terraform targets that share a state backend is a different story. Parallel plan and apply jobs against the same S3 backend will race on the state lock. You will see Error: Error acquiring the state lock in CI, and if the lock acquisition fails mid-apply, you may end up with a partial state that requires manual terraform force-unlock. Never use -j on stateful Terraform targets. Lint and format targets are safe. Apply and plan are not.

Watch out for this security issue: never put $(shell aws sts get-caller-identity) or any credential-fetching shell call at the top level of a Makefile as a variable assignment. Top-level variable assignments are evaluated at parse time, which means they execute on every single make invocation — including make help. Every engineer running make help on a shared CI runner triggers an AWS API call that lands in CloudTrail. Multiply that by 50 engineers and 200 PR checks per day and you have noisy CloudTrail logs that obscure real security events. Move shell calls that contact external APIs into recipe bodies, prefixed with @ to suppress echo, and only in targets that actually need them.

The @ prefix suppresses command echo in Make output. Use it on every recipe line that handles credentials or tokens. One common typo: @@command. Double @ does not exist in Make syntax — it causes a parse error that looks like a missing separator. If your Makefile suddenly breaks with Makefile:42: *** missing separator. Stop., check for accidental double-@ on recipe lines.

Finally, the CONFIRM=yes guard on destroy targets is not paranoia. It is the one pattern I will argue for in every code review. Running make destroy without a confirmation check in a repo where ENV defaults to prod — or where a CI variable is misconfigured — is a bad day. The two-second friction of typing CONFIRM=yes has prevented at least one production incident I can point to directly. See the GNU Make manual for the full reference on guard patterns and conditional variable assignment.

Makefile infra repo patterns are not glamorous, but they are the difference between a repo that onboards new engineers in ten minutes and one that requires a tribal knowledge transfer every time someone joins the team. The patterns here — .PHONY everywhere, guard-% macros, overridable defaults, and parallel-safe lint targets — are what I would put in every infra repo I own today.

Related

Top comments (0)