TL;DR: A Terraform or CloudFormation prompt needs more than "write the resource" — it needs the existing state, the blast radius, and the target format spelled out, because the model can't see your account. Draft with AI, then gate every change behind terraform plan (or a change set) that a human actually reads before apply.
Ask a general-purpose coding assistant to "write me an AWS S3 bucket in Terraform" and you'll get something that runs. It probably won't have versioning, won't have encryption, and won't tell you that the bucket name you asked for already exists in another AWS account somewhere in the world and needs a suffix. None of that is a hallucination in the usual sense. It's a prompt that never said what mattered.
Infrastructure-as-code prompting is a narrower discipline than general code prompting, for one structural reason: a bug in application code fails a test. A bug in infrastructure code changes a running account, sometimes destructively, the moment someone runs apply. The prompt has to do more work up front because the safety net is a human reading a plan, not a CI pipeline catching a red test.
What Makes an Infrastructure-as-Code Prompt Different From a Regular Code Prompt?
Three things a normal "write me a function" prompt doesn't need to carry, and an IaC prompt always should:
- What already exists. Terraform, CloudFormation, and Pulumi all reconcile your code against real state. A prompt written in a vacuum produces code that looks right and diffs badly, because the model has no idea a VPC, a security group, or a bucket with that name is already there.
- The blast radius. "Add a resource" and "modify a resource that three other services depend on" are different requests even when the diff is three lines. State which one you're doing.
- The dry-run step is non-negotiable. Every mainstream IaC tool ships a preview command for exactly this reason. A prompt that produces code and stops there is only half the workflow.
The Six Things Your Terraform Prompt Should Specify
A prompt that produces a mergeable, safe-to-plan file usually states:
- Provider and version constraint —
hashicorp/aws,~> 5.0, not "the AWS provider." - What already exists in the root module — so the model doesn't duplicate a
terraformblock or a provider block that's already declared elsewhere. - Naming and tagging conventions — if your org tags every resource with
EnvironmentandManagedBy, say so; the model won't invent your convention correctly. - The specific resource, not the category — "a private S3 bucket with versioning and default encryption, no public access" beats "a bucket."
- Variables vs hardcoded values — tell it which values should be
var.xinputs and which are fixed. - The failure you're protecting against — "this bucket holds financial exports, it must never be publicly readable" gives the model a reason to add the deny-public-access resources instead of leaving them out.
Here's a prompt built from that list, and the Terraform it produces:
Generate a single .tf file for the following. A `terraform` block and an
`aws` provider block already exist elsewhere in this root module — do not
repeat them.
Resource: a private S3 bucket for storing monthly financial exports.
- Bucket name: {{project_name}}-reports-{{environment}} (both are input variables)
- Versioning: enabled
- Default encryption: SSE-S3 (AES256)
- No public access of any kind
- Tags: Project, Environment, ManagedBy=terraform
Declare project_name and environment as string variables with no defaults.
Do not fabricate a provider or terraform block. Do not invent an ARN.
variable "project_name" {
type = string
}
variable "environment" {
type = string
}
resource "aws_s3_bucket" "reports" {
bucket = "${var.project_name}-reports-${var.environment}"
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "reports" {
bucket = aws_s3_bucket.reports.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "reports" {
bucket = aws_s3_bucket.reports.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "reports" {
bucket = aws_s3_bucket.reports.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Notice the shape: versioning and encryption are their own resources attached to the bucket by ID, not arguments inside aws_s3_bucket itself. That split has been the current pattern in the AWS provider for several major versions now, and it's exactly the kind of detail a prompt that says "add versioning and encryption" (rather than "a bucket with versioning and encryption inline") helps a model get right on the first pass, because it matches how the resources are actually documented and composed on the Terraform Registry.
How Do You Prompt for Cost and Blast-Radius Awareness?
Ask directly, in two separate sentences, because they're different questions:
- "Flag anything in this change that would be expensive to run continuously" — a model can reason qualitatively about an always-on NAT gateway versus a VPC endpoint for the same traffic pattern, or a provisioned-IOPS volume versus a general-purpose one. It cannot price your specific account, because pricing depends on region, committed-use discounts, and existing reservations it has no visibility into. Treat the answer as a flag to verify with a real estimator (Infracost or your provider's calculator), not a number to trust.
- "Flag anything in this change that would delete or replace an existing resource" — this is the question that actually prevents outages.
terraform planwill show a replace as-/+, but by the time you're staring at a plan output, the code is already written. Asking the model to call out likely replacements while it's still drafting (a renamed resource, a changedbucketargument, anamiupdate) gives you a chance to redesign before you're reading a diff under time pressure.
Terraform, CloudFormation, and Pulumi Need Different Prompt Shapes
The underlying discipline (existing state, blast radius, dry run) is the same across tools. The artifact and the safety net are not, and a prompt that doesn't say which tool it wants will get whichever one the model defaults to.
| Tool | Config format | Dry-run step | State tracking |
|---|---|---|---|
| Terraform | HCL | terraform plan | Local or remote state file |
| OpenTofu | HCL (open-source fork of Terraform) | tofu plan | Same state model as Terraform |
| AWS CloudFormation | YAML or JSON | Change set | Managed by AWS, no local state file |
| Pulumi | General-purpose languages (TypeScript, Python, Go) | pulumi preview | Pulumi Cloud or a self-managed backend |
If you want CloudFormation, say "output a CloudFormation template in YAML" explicitly and ask for a change-set-ready resource, because the model has no way to infer which of the four rows you meant from "write me the infrastructure code."
Prompting for Modules Instead of One Giant File
Once a stack grows past a handful of resources, the more useful prompt asks for a module boundary, not a flat file. "Write a Terraform module for a private S3 bucket with versioning and encryption, exposing the bucket name and ARN as outputs, callable with project_name and environment as inputs" produces something you can call from three different environments without copy-pasting HCL. A flat "write me the resources" prompt tends to produce exactly what you asked for and nothing you can reuse.
The same applies to reusable prompt templates for your own team: if you're writing the same kind of IaC request weekly, saving the constraint list (provider version, tagging convention, the "don't repeat the provider block" instruction) as a stored prompt means the next person on your team doesn't have to rediscover the same six specifics from scratch.
Prompting Across Environments Without Copy-Pasting Three Times
A prompt that produces one working .tf file for production tells you nothing about whether the same code is safe to run against staging or a developer's sandbox account, and a request that just says "make this work for all three environments" tends to produce either three near-duplicate files with drifting differences, or a single file with enough conditional logic to become its own maintenance burden.
State the actual difference between environments, not just their names: "the only differences across dev, staging, and production are the instance size (t3.micro, t3.medium, m5.large), whether deletion protection is enabled (false, false, true), and the bucket name suffix. Everything else is identical. Generate variables for exactly those three differences and a .tfvars file per environment, not three separate resource blocks." That sentence turns a vague multi-environment request into something the model can actually satisfy correctly, because it no longer has to guess which parts are supposed to vary.
Common Ways Generated Infrastructure Code Goes Wrong
A few patterns show up often enough to name directly, because catching them by category is faster than catching them one plan at a time:
- A resource that looks additive is actually a replace. Renaming an argument that's part of a resource's identity (like a bucket's
bucketargument, or an EC2 instance'samiin some configurations) forces Terraform to destroy and recreate rather than update in place. This is exactly what the plan output's-/+marker exists to catch, and exactly why a generated diff needs to be read, not skimmed. - A hardcoded value that should have been a variable. A region, an account ID, or an AMI ID typed directly into a resource block works for the account the model implicitly assumed, and silently breaks the moment the same file runs against a different one.
- A missing
depends_onfor a resource with an implicit ordering requirement that Terraform's automatic dependency graph doesn't catch, because the dependency isn't expressed through a resource reference the graph can see. - A provider block duplicated across files in a module that already declares one at the root, which produces a "multiple provider configurations" error the first time someone actually runs
terraform initagainst the combined module.
None of these are exotic. They're the same handful of mistakes a junior engineer new to a codebase would make, for the same reason: the context that would have prevented them lived in someone's head, not in the prompt.
What to Check Before You Run terraform apply
A short checklist, not a substitute for reading the plan:
- Did the plan output match what you asked for, resource for resource?
- Are there any
-/+(replace) lines you didn't expect? A replace means downtime for that resource, not an in-place update. - Does anything reference a hardcoded ARN, account ID, or region the model may have invented rather than copied from your input?
- If tags or naming conventions matter to your org, do the new resources actually carry them?
- Has anyone besides the person who wrote the prompt read the plan?
That last one is the one people skip under deadline pressure, and it's the one that catches the mistakes a solo review misses. A general code-review prompt is still worth running against the raw HCL before the plan stage — it catches naming and structure issues a plan diff won't surface, though it can't replace reading the plan itself.
Generated infrastructure code and manifests overlap heavily with prompting for Kubernetes manifests once your Terraform is provisioning the cluster your workloads land on, and with the broader discipline in AI prompts for QA and DevOps teams. If the code you're generating is a change to something already running rather than something new, the constraints in prompting your way through legacy code apply just as much to a decade-old Terraform module as to an old application codebase.
Stop rewriting prompts. Start shipping.
Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.
Create An Account