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.

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,
grepfor 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 testfiles 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. According to Sonar’s State of Code 2026 survey of more than 1,100 developers, AI accounts for 42% of committed code. 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 infrastructure code has it worse than application code. The peer-reviewed benchmarks agree:
- IaC-Eval (NeurIPS 2024): GPT-4 scored 19.36% (pass@1) on 458 AWS Terraform scenarios, while it scores 86.6% on the Python benchmark EvalPlus.
- DPIaC-Eval (FSE 2026, CloudFormation templates): six frontier models reached 20.8-30.2% first-attempt deployment success on 153 real tasks. Even after iterative repair, only 8.4% of deployable templates passed all Checkov security checks.
- TerraFormer (ICSE 2026, compared against 17 models): the authors note that models hallucinate resource types and attributes due to the relative scarcity of HCL in training data, while doing better on YAML- and JSON-based tools.
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:
variables.tf alone - just the interfaceCommunity 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.
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, with
mock providers (since 1.7), can validate a plan with no cloud, no credentials 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 (one level of child modules), 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 and a mock_provider 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
mock_provider "aws" {}
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 JSON, for example with Conftest. 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 JSON | 150,900 B | ~37,700 |
resource_changes only | 47,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
ref= on shared modules and pin them to tags. Add a CI check that rejects moving references.
.tftest.hcl contracts for the rules you actually care about.
AGENTS.md telling agents to query values via plan and console rather than derive them.
variables.tf to the call site, or expose them as outputs.
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.
How does an AI agent read Terraform code?
With three operations: glob for files, grep for strings, and read line ranges. It cannot evaluate HCL expressions, so values produced by functions such as cidrsubnet or format have to be reconstructed by arithmetic - which is exactly the operation language models are least reliable at.
Why is Terraform harder for AI than Python or YAML?
HCL is significantly less represented in training data than the YAML and JSON used by Kubernetes, CloudFormation, and Ansible. The IaC-Eval benchmark measured GPT-4 at 19.4% on Terraform tasks, while it scored 86.6% on the Python benchmark EvalPlus. Absolute numbers improve with newer models, but the relative gap between infrastructure code and application code persists.
How should shared Terraform modules be versioned?
Always to an immutable reference: a semantic tag or a commit SHA. A reference like ref=master means your infrastructure can change without a single change in your repository. Across the repositories we measured we found 169 git module references and not one was pinned to an immutable version.
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 testand 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.
Sources
- NeurIPS 2024: IaC-Eval: A Code Generation Benchmark for Cloud Infrastructure-as-Code Programs (December 2024)
- arXiv / FSE 2026: Deployability-Centric Infrastructure-as-Code Generation (DPIaC-Eval) (January 2026)
- arXiv / ICSE 2026: TerraFormer: Automated Infrastructure-as-Code with LLMs Fine-Tuned via Policy-Guided Verifier Feedback (January 2026)
- Sonar: Sonar data reveals critical verification gap in AI coding (State of Code 2026) (January 2026)
- HashiCorp: Module composition (October 2026)
- HashiCorp: Creating modules (October 2026)
- HashiCorp: Tests (terraform test) (October 2026)
- HashiCorp: Tests - mocks (October 2026)
- HashiCorp: JSON output format (October 2026)
- Conftest: documentation (October 2026)
- AGENTS.md: open format for coding agent instructions (October 2026)