Your team finished the Terraform tutorials months ago. Everyone can write a resource block. And yet every non-trivial apply still routes through one senior engineer, a routine security group change waits three days for review, and nobody touches the networking stack on a Friday. If that sounds familiar, the problem is not that your engineers need more Terraform training. They learned the easy part. Nobody built the hard part.
The language is the easy 20 percent
HCL is a small language. Resources, data sources, variables, a couple of loop constructs. A mid-level engineer writes useful modules within two weeks. The difficulty was never expressing infrastructure as code; it is managing change to infrastructure through code. Who approves a plan that replaces a production database? What do you do when a one-line diff produces forty resource changes? How do you roll back a declarative system that has no concept of rollback? When we take over cloud infrastructure estates, the HCL is almost always fine. What is missing is everything around it: review norms, state hygiene, stack boundaries, an apply pipeline. That is the 80 percent, and it is process work, not language work.
A plan you cannot read is a plan you cannot approve
Most teams review the HCL diff and treat the plan output as a formality. That is backwards. The diff is the intent; the plan is the change. Only the plan tells you Terraform intends to destroy something.
A reviewable plan is small, scoped to one concern, and explains its replacements. The most important line in any plan is the one saying a resource must be replaced:
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ engine_version = "15.4" -> "16.4"
~ identifier = "orders-db" -> "orders-db-blue" # forces replacement
}
Plan: 1 to add, 0 to change, 1 to destroy.
That comment on the identifier is the difference between a version bump and an outage. A reviewer who cannot point to the attribute forcing replacement has not reviewed the plan, whatever the approval button says.
Here is a claim your team may dislike: a plan touching more than a dozen resources should be rejected on size alone, before anyone reads it. Not because it is wrong, but because no reviewer holds forty changes in their head, and approving an unreadable plan is theater.
State keeps score whether you look or not
The state file records what Terraform believes exists, and reality diverges from it constantly. The commonest source on AWS estates is the console edit made mid-incident: someone widens a security group at 2am, the incident closes, and three weeks later a routine plan quietly reverts the fix because nobody reconciled the change. Drift is not a moral failure; incidents are exactly when console edits are justified. The failure is having no ritual for folding them back in afterwards. Unreconciled drift is also among the first things to surface in an infrastructure review.
Imports and refactors bite the same way. Brownfield imports bring resources into state with attributes nobody set deliberately, so the first plan after an import is always a surprise. Renaming a module without a moved block turns a pure refactor into a destroy-and-create. A scheduled plan against every stack, with unexpected changes treated as a pageable signal, removes most of these surprises before they reach a deploy.
Blast radius, and who is holding the trigger
One big root module is how most teams start, and it is the single biggest source of apply fear. When everything shares one state, every plan churns every resource, every apply locks out every team, and a mistake in a tags variable ripples through the VPC. Split stacks along blast radius and rate of change: networking changes quarterly, IAM monthly, application infrastructure daily, and they do not belong in one state file. Resist the opposite failure too; a stack per resource just relocates the complexity into cross-stack wiring that no plan can check.
Then move applies off laptops. A laptop apply runs with one person's credentials, from whatever branch they had checked out, with no record beyond shell history. A CI apply answers who ran what, from which commit, with which role, and it can enforce that the plan someone approved is exactly the plan that gets applied. If an auditor asked who last applied production, you should be able to answer from logs rather than from memory.
Not everything deserves code on day one
The popular instruction is to codify everything immediately, and it is wrong for infrastructure still finding its shape. A prototype environment rebuilt daily, a proof of concept that may be deleted next sprint, a migration resource that exists for one weekend: writing Terraform for these means every experiment pays the change-process tax you built for production. Codify at the point where a thing stabilizes and other systems begin depending on it. Before that, honest tags for owner and expiry date do more good than a module nobody will maintain.
The corollary: "not in Terraform yet" is a status, not a sin. Track it, schedule the import, and stop treating day-one coverage as the goal.
Your engineers' fear of apply is the most accurate monitoring you have. It will clear the moment the change process deserves their trust, and not a sprint before.