TERRAFORM / PLATFORM ENGINEERING

Terraform and AI agents: when modules help and when they hurt

We measured five production Terraform repositories across AWS and Azure. The result: 169 references to shared modules, not one pinned to an immutable version, zero tests. And one repository running three module calls for every real resource.

July 2026 · 16 min read · For platform engineers and architects

TL;DR

  • AI agents do not need modules. They have no evaluation engine - they search text. A module turns a literal value into a computation that cannot be found in the repository.
  • Measured: in the modular version, grep for the target value returned 0 results, even though the plan computes it correctly. The flat version returned 1.
  • The cost of abstraction is bimodal. Community modules are enormous (a VPC module is ~36k tokens); "entity" modules are tiny - but produce three layers of wiring per resource.
  • Worst finding: 169 git module references across the repositories, 0 pinned to a tag or SHA. Shared modules were meant to guarantee consistency - instead they make it impossible.
  • Zero terraform test files across five production repositories. Standards were nowhere enforceable, only recommended.
  • The fix: stop enforcing consistency through module inheritance; enforce it with contracts on plan output. Those work regardless of layout and survive AI-written code.

A question worth asking again

The rules for structuring Terraform were written when only humans wrote it. Modules, file splitting, layers of abstraction - all of it optimises for human reuse and for state separation.

Today a significant share of infrastructure code is written and read by AI. Sonar's 2026 State of Code survey puts 42% of committed code as written or assisted by an AI agent. So the question is fair: do those rules still hold when the primary reader of the code is no longer a person?

We did not answer that in theory. We built a controlled experiment, then measured five real production repositories across two clouds.

Method

We analysed five production Terraform codebases managed by one team: a static website on AWS, an AWS organisations landing zone, an application platform on AWS EKS, an application platform on Azure AKS, and a global network hub on Azure. All measurement was static - source code and saved plans only. No cloud calls, no infrastructure changes.

Anonymisation: repositories are labelled A-E. No organisation names, domains, IP addresses, subscription or account identifiers appear here. Every code sample in this article is synthetic.

How an AI agent actually reads Terraform

An agent has no language server, no symbol index, and cannot reliably evaluate an HCL expression in its head. It has three operations: glob for files, grep for a string, read a range of lines.

That has one consequence which never comes up in discussions about code structure: the value you are looking for often does not exist as text anywhere in the repository.

And Terraform has it worse than other languages. The peer-reviewed benchmarks agree:

  • IaC-Eval (NeurIPS 2024): GPT-4 scored 19.4% on 458 AWS Terraform scenarios versus 86.6% on comparable Python tasks - roughly four times worse.
  • DPIaC-Eval (FSE 2026): six frontier models reached 20.8-30.2% first-attempt deployment success on 153 real tasks, dropping to 8.4% once compliance checks were applied.
  • TerraFormer (ICSE 2026, 17 models): the stated cause is that HCL is substantially under-represented in training data compared with the YAML and JSON of Kubernetes, CloudFormation, and Ansible.

An honest note on these numbers

The GPT-4 figure is from 2024 and today's models do better in absolute terms. The robust finding is not the absolute score but the relative gap between infrastructure code and application code. That gap is structural: less training data, no type system, and no way to verify correctness by running the code.

The experiment: what one layer of abstraction costs

We built two configurations that produce byte-for-byte identical infrastructure. One is flat; the other has two levels of modules with defaults in variables.tf. Then we asked the question agents actually get asked in practice: "what CIDR does the prod private subnet get, and is deletion protection on for the database?"

Metric Flat config Modular (2 levels)
grep for the CIDR 1 hit 0 hits
Files to read 1 8
Bytes to read 557 2,265
How the answer is obtained read a literal compute across 3 defaults

Both produce 10.20.16.0/20 and deletion_protection = true. Only one lets you find it.

The modular version is better engineering by conventional standards. It is also the version where an agent searching for text finds nothing and has to derive the value through cidrsubnet(), coalesce(), and contains() - the operation language models are least reliable at.

Two constructs that turn abstraction into a trap

1. Defaults in a module's variables.tf. The effective value lives in a file nobody reads at the call site. 2. Computed names and ranges (cidrsubnet, format, concatenated identifiers). The result is undiscoverable - literally zero hits, as measured.

Field measurement: five production repositories

An experiment is one thing; reality is another. Here are five codebases currently running in production:

Repository Cloud Size Resources Module calls Calls / resource
A - static website AWS ~8k tokens 43 0 0.0
B - landing zone AWS ~22k 52 26 0.5
C - application platform AWS ~48k 113 11 0.1
D - application platform Azure ~39k 44 73 1.7
E - global network Azure ~38k 32 99 3.1

Size is an estimate in tokens (bytes ÷ 4). Resources = directly declared resource blocks.

Finding 1: the plumbing-to-substance ratio

The "calls per resource" column is the most useful number in the table. It tells you how much of the configuration is plumbing and how much is substance.

Repository E has 99 module calls and 32 directly declared resources - three layers of wiring for every real piece of infrastructure. Not because anyone got it wrong, but because it is the logical endpoint of "everything is a reusable module". This is exactly what people mean by spaghetti Terraform - except the usual diagnosis ("not modular enough") is precisely backwards.

Finding 2: the cost of a module is bimodal

Here the data surprised us, and it is worth saying out loud because it refutes the simplistic "modules are expensive". We measured the real size of the modules actually in use:

~36k
tokens in the root of the community AWS VPC module alone
~14k
tokens in its variables.tf alone - just the interface
0.3×
ratio of module code to root config in repository E

Community modules like VPC or EKS are enormous - the interface of one such module is larger than the entire infrastructure of a smaller project. The tiny "entity" modules in repository E, by contrast, add up to only 0.3× the root config; there the pain is not size but the number of hops.

So the right metric is not "modules yes/no" but how many tokens and how many hops it costs to answer one question. Both failure modes - the giant module and the forest of tiny ones - arrive at the same place from opposite directions.

Finding 3: shared modules with no immutable version

This is the most serious finding of the whole analysis. Repositories D and E draw modules from shared git libraries - precisely the mechanism meant to guarantee consistency between projects and global infrastructure.

169
references to shared git modules in total
0
of them pinned to a tag or commit SHA
49
references pointing at an in-progress fix branch

Every reference took the form ?ref=master or pointed straight at a development branch. That means your infrastructure can change without a single line changing in your repository. Two plans run at different times from an identical commit can produce different infrastructure.

For an AI agent this is fatal: it reads the module call, reads the module, and draws a conclusion that may be invalid tomorrow. But the agent is not the problem - a human has no way to determine what will actually deploy either.

The contrast that points to the fix

Repository C consumes community registry modules with version constraints (version = "~> 5.0"). It is not an immutable pin, but it is an order of magnitude better than a moving branch - and crucially it is visible at the call site, so agent and human alike know what they are working with.

Finding 4: zero enforceable contracts

Across all five repositories we found zero .tftest.hcl files. The native test framework has shipped since Terraform 1.6 and can validate a plan offline, with no cloud and no state.

The consequence: standards exist as an agreement and as code review, not as a rule. On a team where humans write the code, that hurts slowly. On a team where AI generates a large share of it, it stops working immediately - because the documented failure modes of generated IaC are exactly the ones code review skims past: missing encryption, over-broad permissions, deprecated attributes.

A decision rule for modules

The data supports a rule that is usable and independent of fashion:

"A module must be justified by repetition (used three times or more) or by a state boundary. Never by aesthetics."

Situation Recommendation Why
Up to ~30 resources in one state Flat files, literal values A module adds hops and returns nothing. One file per concern.
Pattern repeats 3+ times Module, but keep the tree flat The alternative is copy-paste drift, which is worse.
Different lifecycle or blast radius Separate state, not a module A state boundary is a real boundary; a module is just syntax.
"To keep files short" Don't This is exactly how you get 3 calls per resource.
Thin wrapper over one resource Don't It raises no level of abstraction, only the file count.

This is also HashiCorp's own guidance: keep the module tree flat, prefer composition over deep nesting, and do not write modules that merely wrap a single resource type.

Consistency without spaghetti code

This is the heart of it. Most teams try to hold consistency through source inheritance: every project imports the same module. The data shows where that leads - 169 unpinned references and a ratio of 3 calls per resource.

The working model is different: a shared module governs how configuration is written. A contract governs what it produces. Enforce the second one.

Layer 1: contracts on plan output

terraform test with command = plan runs offline, with no cloud access and no state. Projects are then free to differ structurally, but cannot differ on what matters:

# tests/guardrails.tftest.hcl
variables {
  environment = "prod"
  vpc_cidr    = "10.20.0.0/16"
}

run "prod_database_is_hardened" {
  command = plan

  assert {
    condition     = output.db_settings.deletion_protection == true
    error_message = "prod database must have deletion protection enabled"
  }
  assert {
    condition     = output.db_settings.backup_retention >= 30
    error_message = "prod database must retain backups for at least 30 days"
  }
  assert {
    condition     = output.db_settings.storage_encrypted == true
    error_message = "prod database storage must be encrypted"
  }
}

run "dev_is_not_over_provisioned" {
  command = plan
  variables { environment = "dev" }

  assert {
    condition     = output.db_settings.instance_class == "db.t4g.medium"
    error_message = "dev should use the cheap instance class"
  }
}

Verified in practice: both runs pass offline in seconds, with no credentials.

Layer 2: cross-project policy over plan JSON

One policy repository applied to every project's plan. This is the only consistency mechanism that survives differing structures, differing module versions, and AI-generated code - because it checks the outcome, not the authorship:

terraform show -json tfplan > plan.json

# example check over the resolved plan
conftest test --policy ./platform-policies plan.json

We verified this against a real plan: the check covered the whole infrastructure without reading a single .tf file or knowing how the project was structured. That is exactly what you need when every team has a different layout.

It also covers the documented failure modes of AI-generated IaC that terraform validate and plan will never flag, by design: wildcards in IAM policies, missing encryption, public ACLs, 0.0.0.0/0, and hardcoded values.

Layer 3: immutable module versions

The cheapest fix with the highest impact. A shared module without an immutable reference is not consistency - it is shared instability:

# WRONG - infrastructure changes with no change in your repository
module "role_assignment" {
  source = "git::ssh://git@example.com/platform/modules.git//authorization/role_assignment?ref=master"
}

# WRONG - a work-in-progress branch referenced from production
module "subnet" {
  source = "git::ssh://git@example.com/platform/modules.git//network/subnet?ref=fix/symlinks"
}

# RIGHT - immutable tag, visible at the call site
module "role_assignment" {
  source = "git::ssh://git@example.com/platform/modules.git//authorization/role_assignment?ref=v2.4.0"
}

CI rule: reject the plan if any ref= is not a tag or a SHA.

Layer 4: give agents an oracle, not the source

Do not make an agent derive values - let it query them. This belongs in AGENTS.md or CLAUDE.md in every infrastructure repository:

# Terraform: NEVER derive values by reading modules. Query them.

terraform show -json tfplan | jq '.planned_values'    # every resolved value
echo 'cidrsubnet("10.20.0.0/16", 4, 1)' | terraform console
terraform console -var-file=prod.tfvars               # verify an expression

# NEVER pipe a whole plan JSON into context - it is 4.5x larger than the
# HCL that produced it. Use a jq slice.
Plan slice Size Tokens
Full plan JSON150,900 B~37,700
resource_changes only47,186 B~11,800
Change summary (address + action + type)2,263 B~600

Measured on a real 20-resource plan. The gap between the full plan and the summary is 60×.

What to do this week

Ordered by return on effort

Compute your module-calls-per-resource ratio. Above 1.0 you have plumbing, not infrastructure.
Audit every ref= on shared modules and pin them to tags. Add a CI check that rejects moving references.
Write your first three .tftest.hcl contracts for the rules you actually care about.
Turn on policy checks over plan JSON in CI - one policy repo for all projects.
Add an instruction to AGENTS.md telling agents to query values via plan and console rather than derive them.
Move defaults out of module variables.tf to the call site, or expose them as outputs.
Do not refactor small flat repositories into modules. If you have 20 resources and zero modules, you are done.

Frequently asked questions

Do AI agents need Terraform modules?

No. Modules optimise for human reuse and state separation, not agent readability. An agent searches text; a module turns a literal into a computation spread across files. They earn their place through repetition (three times or more) or state separation - never through aesthetics.

Should we refactor existing modular code?

Usually not - rewriting working infrastructure carries its own risk. The priority is pinning versions, adding contracts and policy, and exposing computed values as outputs. Dissolving modules is only worth it where calls per resource sits well above one.

Does this mean writing one giant file?

No. Splitting by concern (network.tf, iam.tf, storage.tf) is ideal for agents - it supports targeted search and partial reads. The problem is not the number of files but the number of hops between them caused by abstraction.

How do you keep projects consistent with global infrastructure?

Contracts on plan output plus policy as code, not module inheritance. A shared module governs how configuration is written; a contract governs what it produces. Only the latter is enforceable across teams with different layouts.

Does AI remove the need for a platform team?

The opposite. The faster infrastructure code appears, the more it matters that someone owns the contracts, the policy, and the versioning. The role shifts from writing configuration to defining and enforcing rules.

Key takeaways

  • Agents don't need modules, they need discoverable values. Measured: 1 hit versus 0 for the same fact.
  • Module calls per resource is the best spaghetti detector. Above 1.0, the configuration is mostly wiring.
  • The cost of abstraction is bimodal - giant community modules and a forest of tiny entities cause the same problem from opposite ends.
  • A shared module without an immutable version is not consistency. 169 references, 0 pinned - and this was meant to be the main standardisation tool.
  • Enforce outcomes, not structure. terraform test and policy over plan JSON work across layouts and survive AI-generated code.
  • Zero tests across five production repositories is the normal state, not an outlier. That is an opportunity, not an embarrassment.

Related articles

Sources and method

  • Original measurement: five production Terraform codebases (AWS and Azure), static analysis of source and saved plans, July 2026. Data anonymised.
  • IaC-Eval (NeurIPS 2024) - benchmark of 458 AWS Terraform scenarios.
  • DPIaC-Eval (FSE 2026) - 153 real-world tasks, six frontier models.
  • TerraFormer (ICSE 2026) - evaluation of 17 models, analysis of HCL representation in training data.
  • Sonar, State of Code 2026 - share of code written or assisted by AI.
  • HashiCorp documentation: module development and composition, terraform test, plan JSON format.

Want to know where your infrastructure stands?

We audit Terraform codebases - abstraction ratio, module versioning, whether your standards are actually enforceable, and readiness for AI-assisted work. You get a concrete list of actions, not a slide deck.

Book an Audit