Makefile Patterns That Actually Work in Terraform Infrastructure Repositories

devops-terraform

Cognitive overhead compounds quickly in infrastructure repositories: engineers context-switch between terraform init, tflint, plan, and apply commands that each carry their own flags, environment context, and ordering constraints. A well-structured Makefile collapses that surface area into a single, self-documenting interface that works identically on a laptop and in a pipeline.

Requirements

Before writing a single target, establish a consistent foundation. Mismatched tooling versions and missing conventions are the most common reasons Makefiles degrade into unmaintainable noise.

GNU Make version. macOS ships with BSD Make 3.x, which lacks features present in GNU Make 4.3 — notably improved error handling and the $(file ...) function. Install the GNU version via Homebrew and invoke it as gmake, or alias it in your shell profile:

brew install make
# Then either use gmake directly, or prepend to PATH:
export PATH="$(brew --prefix make)/libexec/gnubin:$PATH"

Verify you are running 4.3 or later before committing any Makefile to a shared repo:

make --version
# GNU Make 4.4.1

Terraform and tflint. The Makefile in this guide targets Terraform 1.x and pins tflint at version 0.50.3 via a variable. Both must be present in PATH on every machine and CI runner that executes these targets. Use tfenv and a pinned .terraform-version file to eliminate version drift across the team.

Repository layout convention. The patterns here assume the following directory structure:

repo-root/
├── Makefile
├── Makefile.env              # shared defaults, committed
├── envs/
│   ├── dev/
│   │   └── Makefile.env      # env-specific overrides, may be gitignored
│   └── prod/
│       └── Makefile.env
└── terraform/
    └── envs/
        ├── dev/
        └── prod/

Environment-specific Makefile.env files live under envs/<env>/. The top-level Makefile.env holds team-wide defaults — backend bucket names, lock timeout, linter version. Both are loaded with -include (the leading dash prevents Make from erroring when the file does not yet exist).

Editor configuration. Recipe lines in a Makefile must use hard tabs, not spaces. This is the single most common silent failure mode: an editor set to expand tabs will produce a file that looks correct but breaks with Makefile:N: *** missing separator. Stop. Configure your editor to preserve literal tabs in *.mk and Makefile files. In VS Code, add the following to .editorconfig:

[Makefile]
indent_style = tab

Implementation

The Makefile below is structured in deliberate layers: variable resolution at the top, guard assertions before any target can run, .PHONY declarations, and then phased lifecycle targets that compose naturally.

# Makefile — Infrastructure task runner for Terraform-based repos
# Usage: make <target> ENV=dev

-include Makefile.env
-include envs/$(ENV)/Makefile.env

# ── Variables ────────────────────────────────────────────────────────────────
ENV          ?= dev
TF_DIR       := terraform/envs/$(ENV)
PLAN_FILE    := $(TF_DIR)/.tfplan
LOCK_TIMEOUT := 30s
TFLINT_VER   := 0.50.3

export TF_VAR_environment := $(ENV)
export TF_CLI_ARGS_plan    := -lock-timeout=$(LOCK_TIMEOUT)

# ── Guards ───────────────────────────────────────────────────────────────────
ifndef ENV
  $(error ENV is not set. Usage: make <target> ENV=dev)
endif

# ── Phony declarations ───────────────────────────────────────────────────────
.PHONY: all init lint fmt validate plan apply destroy clean help

all: lint validate plan

# ── Lifecycle targets ────────────────────────────────────────────────────────
init:
	@echo "==> Initialising Terraform for env: $(ENV)"
	terraform -chdir=$(TF_DIR) init -upgrade -reconfigure

fmt:
	@echo "==> Formatting Terraform files"
	terraform fmt -recursive terraform/

lint: fmt
	@echo "==> Running tflint"
	tflint --chdir=$(TF_DIR) --config=.tflint.hcl

validate: init
	@echo "==> Validating configuration"
	terraform -chdir=$(TF_DIR) validate

plan: validate
	@echo "==> Planning (ENV=$(ENV))"
	terraform -chdir=$(TF_DIR) plan -out=$(PLAN_FILE)

apply:
	@echo "==> Applying plan for ENV=$(ENV)"
	terraform -chdir=$(TF_DIR) apply $(PLAN_FILE)

destroy:
	@echo "==> Destroying ENV=$(ENV) — confirm manually"
	terraform -chdir=$(TF_DIR) destroy -auto-approve

clean:
	@echo "==> Removing local plan and cache files"
	find terraform/ -name ".tfplan" -delete
	find terraform/ -name ".terraform.lock.hcl" -delete

# ── Help ─────────────────────────────────────────────────────────────────────
help:
	@grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \
	  awk 'BEGIN {FS = ":.*?## "}; {printf "  %-15s %s\n", $$1, $$2}'

Several decisions here are worth unpacking.

Variable assignment operators matter. ?= sets a default only when the variable is not already defined — this is how ENV ?= dev allows a caller to override from the command line or from CI environment variables without touching the file. := performs immediate expansion at parse time, which is what you want for derived paths like TF_DIR. Avoid $(shell ...) for anything that changes between invocations; it evaluates once at parse time and will return stale values if the filesystem state changes mid-run.

Exporting Terraform variables. The export TF_VAR_environment := $(ENV) line passes the environment name directly into Terraform’s variable resolution without requiring a -var flag on every command. The same pattern applies to TF_CLI_ARGS_plan, which Terraform reads automatically — a clean way to inject consistent flags without duplicating them across targets.

The all target as a safety gate. Composing all: lint validate plan means a single make ENV=staging call runs the full pre-apply verification chain. In pull request pipelines, this is the only target CI needs to invoke. The apply target is intentionally excluded from all — it requires an explicit call, which forces intent.

Recursive make calls. If you split targets across included sub-makefiles and need to call a target from within a recipe, use $(MAKE) rather than a bare make invocation. This preserves the jobserver context and any flags passed to the parent process, preventing subtle parallelism bugs.

go-task as an alternative. If your team finds Makefile DSL syntax a barrier to contribution, go-task (Taskfile.yml, version 3.x) offers equivalent functionality in YAML. The tradeoff is an additional binary dependency and slightly less ubiquity — make is present on virtually every Unix-like system without installation.

Test the Setup

Validation has three dimensions: correct execution on the happy path, loud failure when dependencies or variables are missing, and clean output in CI log streams.

Happy path — local run. From the repository root, run the full pre-apply chain against the dev environment:

make all ENV=dev

You should see each phase announce itself via the @echo lines, with Terraform output following. Because @ suppresses the recipe line itself, the output stays readable. During initial development, drop the @ prefix on a target to trace exactly what Make is executing — it is a faster debugging loop than adding print statements.

Guard assertion test. Confirm the guard fires correctly by omitting the variable:

make plan
# Expected:
# Makefile:14: *** ENV is not set. Usage: make <target> ENV=dev.  Stop.

The $(error ...) directive halts Make at parse time, before any recipe runs. This is intentional — you want the failure to be immediate and unambiguous, not discovered halfway through a plan.

Missing binary detection. Add a guard target for required tools to catch environment mismatches before they produce confusing Terraform errors:

check-deps:
	@command -v terraform >/dev/null 2>&1 || \
	  { echo "ERROR: terraform not found in PATH"; exit 1; }
	@command -v tflint >/dev/null 2>&1 || \
	  { echo "ERROR: tflint not found in PATH"; exit 1; }
	@echo "All required tools present."

Make check-deps a prerequisite of init and the entire chain becomes self-validating.

CI integration — GitHub Actions. Pass --no-print-directory to suppress the Entering directory noise that Make emits when called from a subdirectory context. A minimal job step looks like this:

- name: Run Terraform checks
  run: make all ENV=${{ inputs.environment }} --no-print-directory
  env:
    AWS_DEFAULT_REGION: eu-west-1

GitLab CI works identically — set the flag in the script line. The environment-specific Makefile.env file for the target environment should either be committed (for non-sensitive values) or written to disk by a preceding pipeline step that pulls from a secrets manager.

Verify .PHONY correctness. Create a file named plan in the repository root and run make plan ENV=dev. Without the .PHONY declaration, Make would treat the file as an up-to-date target and skip execution silently. With it declared, Make ignores the file and runs the recipe. This is a non-obvious failure mode in repos where generated artifact names can collide with target names.


  • Always declare every non-file target in .PHONY — the cost is one line; the silent failure it prevents is hours of debugging.
  • Use ?= for user-overridable variables and := for derived paths; mixing them up causes evaluation-order bugs that are hard to reproduce.
  • The export TF_VAR_ pattern eliminates flag duplication across targets and works transparently in both local and CI contexts.
  • Keep apply and destroy out of composite targets like all — explicit invocation is the only safe gate for destructive operations.
  • Validate your Makefile on GNU Make 4.3+ explicitly; macOS default 3.x will silently accept syntax that behaves differently or fails on Linux CI runners.
  • The -include directive (with leading dash) is the correct way to load optional env files — it does not error on absence, unlike include.

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank