Writing Terraform With LLMs in October 2026: Read the Plan, Block Surprise Destroys, and Scan for Insecure Defaults Before You Apply
Asking an LLM to write Terraform feels like a shortcut: describe the bucket, the database, or the IAM role you want, and a few seconds later you have HCL that looks right. The problem is that infrastructure code has a much bigger blast radius than most code. A wrong line in a web handler throws an error. A wrong line in Terraform can replace a production database, open a storage bucket to the internet, or grant an account far more access than anyone intended. And because generated HCL usually looks tidy, the mistakes are easy to wave through.
This guide covers a practical workflow for using LLMs to write and change Terraform (the same ideas apply to OpenTofu, the open-source fork) without letting the model's confidence stand in for review. The core rules are simple: read the plan rather than the code, make destroys and replacements impossible to miss, and run policy checks before anything is applied.
Why generated Terraform goes wrong
Most bad Terraform from an LLM falls into a handful of patterns. Knowing them makes review faster.
- Outdated provider syntax. Providers change between major versions. A model trained on older examples may produce arguments that were deprecated or moved into separate resources. A common example is configuring storage bucket settings inline when the current provider version expects them as standalone resources.
- Invented arguments. The model may add an argument that sounds plausible but does not exist on that resource. This one is the easiest to catch, because
terraform validaterejects unknown arguments. - Changes that force replacement. Some arguments cannot be updated in place. Renaming a resource, changing a database engine setting, or editing an immutable field can make Terraform destroy the old resource and create a new one. The generated diff looks like a one-line edit; the plan shows a delete.
- Overly permissive defaults. Wildcard IAM actions, security groups open to
0.0.0.0/0, and public access settings left off are common in examples on the internet, so they are common in model output too. - Missing state awareness. The model only sees the files you give it. It does not know what is already deployed, what was changed by hand, or which resource addresses exist in your state file.
None of these are rare edge cases. They are the normal failure modes, so the workflow should assume they will happen.
Give the model the context it actually needs
The quality of generated HCL depends heavily on what you put in the prompt. Before asking for a change, include:
- Your provider versions. Paste the
required_providersblock, for example:
Then tell the model explicitly to target that major version. This alone removes a lot of outdated syntax.terraform { required_version = ">= 1.6" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } - The existing module or file you are changing, not a summary of it. Resource names and addresses matter, because renaming a resource without a
movedblock looks to Terraform like a delete plus a create. - Your conventions. Naming patterns, required tags, which modules are approved, and whether resources should go in an existing module or a new one.
- Hard constraints. For example: "Do not rename existing resources. Do not change any argument that forces replacement. No wildcard IAM actions. No ingress from 0.0.0.0/0."
Ask for the smallest change that does the job. "Add a lifecycle rule to the existing logs bucket" is much safer than "refactor the storage module and add a lifecycle rule," because a small diff produces a small plan you can actually read.
Run the cheap checks first
Before you even look at a plan, run the fast, local checks. They catch the syntax and hallucination problems in seconds:
terraform fmt -check -recursive
terraform init -backend=false
terraform validate
tflint --recursive
terraform validate catches unknown arguments, missing required arguments, and type errors. tflint adds provider-aware rules, such as invalid instance types or deprecated syntax, when you enable the ruleset plugin for your cloud provider. If any of these fail, paste the exact error back to the model and ask for a fix. Do not move on until they pass.
Review the plan, not just the code
This is the most important habit. Generated HCL can look reasonable while doing something you did not expect. The plan tells you what Terraform will actually do against real state.
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
Read the human-readable plan output, and pay special attention to the summary line at the end, which tells you how many resources will be added, changed, and destroyed. If you asked for a small additive change and the summary says anything will be destroyed, stop and find out why.
Make destroys and replacements impossible to miss
Humans skim long plans. A script does not. The JSON plan lists every resource change with its actions, and a delete or a replacement shows up as an action list that contains "delete" (a replacement is a delete and a create together). You can pull those out with jq:
jq -r '.resource_changes[]
| select(.change.actions | index("delete"))
| "\(.change.actions | join(",")) \(.address)"' plan.json
Wire this into your pipeline so that any plan containing a delete fails by default and needs an explicit human override. For a pull request that was mostly written by a model, this one check prevents the most expensive class of mistakes.
For resources that must never be destroyed by accident, such as production databases or state buckets, add a lifecycle guard:
resource "aws_db_instance" "main" {
# ...
lifecycle {
prevent_destroy = true
}
}
With prevent_destroy set, Terraform refuses to produce a plan that destroys that resource. Tell the model never to remove these blocks, and treat any diff that touches them as a red flag.
Use moved and import blocks instead of letting resources churn
If the model renames a resource or moves it into a module, Terraform sees the old address disappear and a new one appear. Ask the model to add a moved block whenever an address changes:
moved {
from = aws_s3_bucket.logs
to = module.logging.aws_s3_bucket.logs
}
Similarly, if you are bringing an existing, hand-created resource under Terraform, use an import block (available since Terraform 1.5) rather than letting the model write a new resource that would try to create a duplicate. In both cases, the plan should show a move or an import with no destroy.
Run policy and security scanning before apply
Validation tells you the code is well formed. It does not tell you it is safe. Add a static security scanner to catch insecure configurations that models reproduce from public examples:
checkov -d .
trivy config .
Both tools flag issues such as unencrypted storage, public buckets, security groups open to the world, and overly broad IAM policies. Trivy now includes the checks that used to live in tfsec. Pick one, run it in CI on every pull request, and fail the build on high-severity findings.
For rules specific to your organization, such as "every resource must have an owner tag" or "no instances larger than a given size in staging," a policy engine like Open Policy Agent (with a tool such as Conftest) can evaluate the JSON plan directly. Checking the plan rather than the source has a real advantage: it sees computed values and module outputs, not just what is written in the file.
Read IAM changes line by line
IAM policies deserve extra attention because scanners cannot always tell whether a permission is too broad for your specific use. When a model writes a policy, check each statement:
- Are the actions listed explicitly, or is there a wildcard such as
s3:*or*? - Is the
Resourcescoped to specific ARNs, or is it"*"? - Is there a condition that should limit it, such as a source account or a required tag?
A useful prompt pattern is to ask the model to explain, in one sentence per statement, why each permission is needed. Any statement it cannot justify clearly should be removed.
A practical workflow, end to end
Putting it together, a safe loop for LLM-assisted Terraform changes looks like this:
- Give the model the provider versions, the existing files, your conventions, and hard constraints. Ask for the smallest possible change.
- Run
terraform fmt,terraform validate, andtflint. Feed exact errors back to the model until they pass. - Run
terraform plan -out=tfplanagainst the real backend, and export the plan as JSON. - Fail automatically on any delete or replace action unless a person explicitly approves it.
- Run a security scanner and any organization policies against the code and the plan.
- Have a human review the plan summary, every IAM change, and every network rule change.
- Apply the saved plan file, not a fresh plan, so what runs is exactly what was reviewed:
terraform apply tfplan.
Step seven matters more than it looks. If you run terraform apply without a saved plan, Terraform computes a new plan at apply time, and if state or code changed in between, you can end up applying something nobody reviewed.
Keep models away from apply credentials
It is tempting to let a coding agent run the whole loop, including apply. Resist that for anything beyond a sandbox account. A reasonable split is:
- The model can write code and run read-only commands, such as
validateandplan, using credentials that cannot modify infrastructure. - Apply runs only in CI, after review, using a separate role that the model never has access to.
- Sandbox accounts are fair game for fully automated experiments, as long as they are isolated from production and torn down regularly.
Also keep secrets out of prompts. Do not paste terraform.tfvars files that contain credentials, and remember that state files can contain sensitive values such as database passwords in plain text. Share the configuration, not the state.
Where LLMs genuinely help with Terraform
With those guardrails in place, models are useful for real work:
- Boilerplate. Variables, outputs, and tagging blocks are tedious and low risk to generate.
- Explaining plans. Paste a long plan and ask which changes force replacement and why. Verify against the plan output, but it speeds up reading.
- Upgrades. Moving a module to a new provider major version is mostly mechanical, and the validate-then-plan loop catches what the model gets wrong.
- Writing tests. Terraform's built-in
terraform testcommand and its.tftest.hclfiles let you assert on planned values, and models are good at drafting those assertions.
The short version
Treat generated Terraform as a proposal, not a change. Give the model your provider versions and existing code, keep diffs small, and let validate and a linter catch the invented arguments. Then read the plan, make any delete or replacement fail by default, scan for insecure settings, and apply only the exact plan file a human reviewed. The model saves you typing; the plan is what keeps production standing.
Comments
Post a Comment