Terraform — Part 12 — Terraform Best Practices and Common Pitfalls
Eleven parts have each carried their own practical rules and production notes. This capstone pulls them into one working reference, alongside a few conventions that only make sense once the rest of the series is in place.
Naming, file layout, and formatting that reads the same across every configuration in a team.
Every change goes through a plan a human actually reads, not just a fast apply.
Environments and modules kept genuinely independent, not just conveniently organised.
Introduction
Eleven parts of this series each ended with a practical rule or a production note tied to whatever that part covered. None of those rules are complicated in isolation. What makes them worth collecting here is that fragile Terraform usually isn’t caused by one dramatic mistake — it’s an accumulation of small, individually reasonable shortcuts that compound over a year of changes.
Naming and File Layout
HashiCorp’s own style guidance is specific: use nouns for resource names and leave the resource type out of the name itself — resource "aws_instance" "web_api", not resource "aws_instance" "aws_instance_web_api". Separate multiple words with underscores, not hyphens, for resource names, variable names, and output names alike.
For file layout, the conventional split is main.tf for resources, variables.tf for inputs, outputs.tf for outputs, providers.tf for provider configuration, terraform.tf for the required_providers/required_version block, and locals.tf for local values — the same split Part 10 used for a module, applied to the root configuration too.
count or for_each first, ordinary arguments in the middle, and a lifecycle block last if present — matching HashiCorp’s own documented block ordering makes any configuration easier for the next person to scan.
Formatting and Validation in the Workflow
Two commands cost nothing and catch real mistakes before they reach a plan: terraform fmt rewrites files into Terraform’s canonical formatting, and terraform validate checks the configuration is internally consistent — correct types, no unresolvable references — without needing provider credentials or network access.
# Rewrite every .tf file in this directory and its subdirectories
# to canonical formatting
terraform fmt -recursive
# Fail with a non-zero exit code if anything isn't already
# formatted correctly, without modifying files — the CI-friendly form
terraform fmt -check -recursive
# Check the configuration is internally valid
terraform validate
terraform fmt -check and terraform validate as an early, fast-failing step in CI, before anything reaches plan. Both are nearly instant and catch a class of mistakes that would otherwise only surface as a confusing plan error.
Pin Everything
This series has pinned versions in three separate places, and all three matter for the same reason — a configuration that resolves to different code on a future run, with nothing in the configuration itself having changed, is not actually reproducible: required_version for the Terraform CLI (Part 4), required_providers version constraints for each provider (Part 2 and Part 4), and a ref or version on any non-local module source (Part 10).
Treat State Like a Credential Store
Part 6 covered why: state holds every attribute of every resource it tracks in plain text, including anything marked sensitive = true at the variable or output level. Never commit state to version control, never hand-edit it — reach for terraform state mv or rm instead — and store it in a backend with encryption at rest and real access controls.
Part 7 covers the team half of this: a remote backend with locking (native S3 locking, Azure Blob leases, or your backend’s equivalent) so concurrent applies can’t silently corrupt each other’s writes.
Prefer References Over Forced Dependencies
Part 4 and Part 9 both landed on the same point from different directions: a real reference to another resource’s attribute both supplies the value and tells Terraform the correct order automatically. Reach for depends_on only when a dependency genuinely can’t be expressed as a reference, and reach for for_each over count the moment a set of resources might have items removed or reordered from the middle.
Small Modules, Genuinely Separate Environments
A module (Part 10) earns its complexity the moment the same resources need to exist more than once — not before. And per Part 11, the deciding factor between a CLI workspace and a fully separate directory isn’t convenience, it’s whether the environment needs genuinely different credentials. Production should almost always live in its own directory with its own backend, not share a backend with dev through a workspace.
Review Before Apply, Every Time
Part 3’s whole case for plan existing as a separate step applies here as a standing habit, not a one-off lesson: read the +/~/-/+/- symbols before confirming, and treat -auto-approve as something CI pipelines use after a human has already reviewed a saved plan — never a shortcut for a person to skip reading one.
A Note on Third-Party Tooling
Beyond what ships with Terraform itself, a mature setup commonly adds community linters and static-analysis tools — tflint for catching provider-specific mistakes validate doesn’t know about, and security scanners like checkov or tfsec for flagging insecure defaults before they’re ever applied. These are third-party projects, not part of Terraform core, and worth adopting deliberately rather than by default — but genuinely useful once a codebase outgrows what fmt and validate alone can catch.
The Series’ Rules in One Table
| Rule | From |
|---|---|
| Pin CLI version, provider versions, and module refs | Parts 2, 4, 10 |
Never hand-edit state; use terraform state mv/rm |
Part 6 |
| Remote backend with locking for any shared work | Part 7 |
Prefer real references over depends_on |
Parts 4, 9 |
Prefer for_each over count for anything that might reorder |
Part 9 |
| Modules only once resources are genuinely reused | Part 10 |
| Separate directories, not workspaces, for different access boundaries | Part 11 |
Always read the plan; -auto-approve only after human review already happened |
Part 3 |
Final Thoughts
None of this is about writing clever Terraform. It’s the opposite — every rule in this post exists to make configuration boring and predictable, so the interesting problems stay in the infrastructure being described instead of in the tooling describing it.
Terraform — Part 13 — Common Terraform Errors and How to Fix Them. A dedicated troubleshooting reference for the errors every newcomer eventually hits, closing out the foundations series.