Worked review 1: a network + compute module
The feature
Section titled “The feature”The prompt was: “Write a reusable Terraform module for a VPC with public subnets, a security group, and an EC2 instance, then call it once per environment.” What follows is the kind of first draft an AI assistant hands back for exactly that prompt — every block is individually plausible HCL, resource types are real, argument names are mostly real. Read it the way the review checklist recommends: providers and versions first, then backend, then resources and lifecycle, then the module boundary itself. It has 8 real bugs; check the AI bug catalog for the general pattern behind each one — this review reproduces catalog entries 1, 2, 3, 9, 10, and 17 inside one coherent module, plus two more specific to this exact shape. Every claim below was proven with real terraform init/validate/plan/test against Terraform 1.16.4 and hashicorp/aws 6.66.0, with mock credentials (access_key = "mock", skip_credentials_validation = true) — never a real AWS call.
The generated code
Section titled “The generated code”# network+compute module, as generated -- modules/network-compute/main.tfprovider "aws" { region = "us-east-1"}
resource "aws_vpc" "this" { cidr_block = var.vpc_cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = "${var.name}-vpc" }}
resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = { Name = "${var.name}-igw" }}
resource "aws_subnet" "public" { count = length(var.availability_zones) vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) availability_zone = var.availability_zones[count.index] map_public_ip_on_launch = true tags = { Name = "${var.name}-public-${count.index}" }}
resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this.id }}
resource "aws_route_table_association" "public" { count = length(var.availability_zones) subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id}
resource "aws_security_group" "web" { name = "${var.name}-web-sg" description = "Allow web traffic" vpc_id = aws_vpc.this.id
ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }}
resource "aws_network_interface" "app" { subnet_id = aws_subnet.public[0].id security_groups = [aws_security_group.web.id]}
resource "aws_instance" "app" { ami = var.ami_id instance_type = var.instance_type
network_interface { network_interface_id = aws_network_interface.app.id device_index = 0 }
tags = { Name = "${var.name}-app" }}# network+compute module, as generated -- modules/network-compute/variables.tf and outputs.tfvariable "name" { type = string }variable "vpc_cidr" { type = string default = "10.0.0.0/16"}variable "subnet_newbits" { type = number default = 4}variable "availability_zones" { type = list(string) }variable "ami_id" { type = string }variable "instance_type" { type = string default = "t3.micro"}
output "vpc_arn" { value = aws_vpc.this.arn}output "public_subnet_ids" { value = aws_subnet.public[*].id}output "web_security_group_id" { value = aws_security_group.web.id}output "instance_id" { value = aws_instance.app.id}# network+compute module, as generated -- root main.tf, variables.tf, outputs.tfterraform { required_providers { aws = { source = "hashicorp/aws", version = ">= 6.0" } }}
provider "aws" { region = "us-east-1" access_key = "mock" secret_key = "mock" skip_credentials_validation = true skip_requesting_account_id = true skip_metadata_api_check = true skip_region_validation = true}
variable "region" { type = string default = "us-east-1"}variable "environments" { type = list(string) default = ["dev", "staging"]}variable "ami_id" { type = string default = "ami-0abcdef1234567890"}
module "app" { source = "./modules/network-compute" count = length(var.environments)
name = var.environments[count.index] vpc_cidr = "10.${count.index}.0.0/16" availability_zones = ["us-east-1a", "us-east-1b"] ami_id = var.ami_id}
output "vpc_ids" { value = [for m in module.app : m.vpc_id]}output "instance_ids" { value = [for m in module.app : m.instance_id]}The review walk
Section titled “The review walk”Module: a provider block inside a reusable module, called with count
Section titled “Module: a provider block inside a reusable module, called with count”provider "aws" { region = "us-east-1"}# entries/01-provider-in-module/main.tf -- @expect-error Module is incompatible with count, for_each, and depends_onmodule "app" { source = "./modules/network-compute" count = length(var.environments)}Symptom — real, at terraform init (this fails before validate even runs, at module-installation time):
$ terraform init -backend=falseInitializing modules...- app in modules/network-compute
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 19, in module "app": 19: count = length(var.environments)
The module at module.app is a legacy module which contains its own localprovider configurations, and so calls to it may not use the count, for_each,or depends_on arguments.
If you also control the module "./modules/network-compute", consider updatingthis module to instead expect provider configurations to be passed by itscaller.🐛 Spot the bug
resource "aws_instance" "app" {# ...}
module "app" {source = "./modules/network-compute"count = length(var.environments)}Show the answer
The module alone validates fine — the provider "aws" { } block inside it is completely legal HCL on its own. The failure only appears the moment a caller needs count, for_each, or depends_on on the module call — exactly what “one module, one call per environment” needs next. It surfaces at terraform init’s module-installation step here, before validate even gets a chance to run. The fix: delete the provider block from the module entirely and let the caller’s own root-level provider configuration apply to every resource inside it — a reusable module should never own its own provider configuration.
Module: cidrsubnet() called with its arguments in the wrong order
Section titled “Module: cidrsubnet() called with its arguments in the wrong order”# excerpt, as generated -- entries/02-cidrsubnet-swapped/main.tfresource "aws_subnet" "public" { count = length(var.availability_zones) cidr_block = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) # ...}The real signature, per the cidrsubnet function docs, is cidrsubnet(prefix, newbits, netnum) — prefix must be the base CIDR block, netnum the subnet index. This calls it as cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) — count.index (a number) where the CIDR string belongs, and the CIDR string where the index belongs.
Symptom: terraform validate passes — count.index type-converts to a string without complaint, so the static type checker sees nothing wrong. The real failure only appears once plan actually evaluates the function:
$ terraform planError: Invalid function argument
on modules/network-compute/main.tf line 16, in resource "aws_subnet" "public": 16: cidr_block = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) ├──────────────── │ var.vpc_cidr is "10.0.0.0/16"
Invalid value for "netnum" parameter: a number is required.🐛 Spot the bug
cidr_block = cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr)Show the answer
This is the same class of trap as entry 13 in the AI bug catalog (a hallucinated argument) but one level sneakier: validate is a purely static, schema-level check, and Terraform’s type system happily converts a number to a string at that stage — count.index “fits” where a string-typed prefix parameter is expected, so validate reports nothing. The function is only actually called, with actual values, during plan. The fix is putting the three arguments back in the order the signature requires: cidrsubnet(var.vpc_cidr, var.subnet_newbits, count.index).
Module → root: an output referencing an attribute the module never exports
Section titled “Module → root: an output referencing an attribute the module never exports”# entries/03-wrong-output-attr/outputs.tf -- @expect-error This object does not have an attribute named "vpc_id"output "vpc_ids" { value = [for m in module.app : m.vpc_id]}The module’s own outputs.tf only defines vpc_arn, public_subnet_ids, web_security_group_id, and instance_id — never vpc_id.
Symptom — real terraform plan, after fixing the two bugs above (the plan gets all the way to computing 20 resource changes before failing on the output):
Plan: 20 to add, 0 to change, 0 to destroy.
Changes to Outputs: + instance_ids = [ + (known after apply), + (known after apply), ]
Error: Unsupported attribute
on outputs.tf line 2, in output "vpc_ids": 2: value = [for m in module.app : m.vpc_id]
This object does not have an attribute named "vpc_id".🐛 Spot the bug
output "vpc_ids" {value = [for m in module.app : m.vpc_id]}Show the answer
Nothing about the reference syntax module.app : m.vpc_id is wrong — it’s exactly how you’d read an output off a module instance. The bug is one layer down: the module was never asked to export a plain VPC ID, only its ARN (vpc_arn). Notice the plan got all the way to Plan: 20 to add before this error — Terraform plans every resource change first and evaluates output expressions last, so a broken output doesn’t block you from seeing everything else that’s about to happen. The fix has two parts: add a real output "vpc_id" { value = aws_vpc.this.id } to the module, and reference m.vpc_id from the root now that it actually exists.
Versions: a floating provider constraint
Section titled “Versions: a floating provider constraint”# excerpt, as generated -- entries/04-unpinned-provider/main.tfterraform { required_providers { aws = { source = "hashicorp/aws", version = ">= 6.0" } }}Symptom — real .terraform.lock.hcl after terraform init:
provider "registry.terraform.io/hashicorp/aws" { version = "6.66.0" constraints = ">= 6.0.0"}>= 6.0 resolves to whatever the registry’s newest matching release is today. Nothing here promises the next init -upgrade — on a different machine, a different day — resolves inside the same major version the way ~> 6.0 would. Same bug as AI bug catalog entry 1. Fix: version = "~> 6.0".
Resources: count on a list of availability zones
Section titled “Resources: count on a list of availability zones”# excerpt, as generated -- entries/05-count-index-shift/main.tfresource "aws_subnet" "public" { count = length(var.availability_zones) availability_zone = var.availability_zones[count.index]}Symptom — real, proven with an applied terraform_data standing in for the indexed resource (the same count.index mechanics apply identically to aws_subnet; AWS itself needs real credentials to apply, which this course never uses), three AZs applied, then the middle one removed from the list:
$ terraform apply -auto-approve # availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
$ # availability_zones -> ["us-east-1a", "us-east-1c"]$ terraform plan # terraform_data.public_subnet[1] will be updated in-place ~ resource "terraform_data" "public_subnet" { ~ input = { ~ availability_zone = "us-east-1b" -> "us-east-1c" } } # terraform_data.public_subnet[2] will be destroyed # (because index [2] is out of range for count)
Plan: 0 to add, 1 to change, 1 to destroy.🐛 Spot the bug
resource "aws_subnet" "public" {count = length(var.availability_zones)availability_zone = var.availability_zones[count.index]}Show the answer
count.index is purely positional. Removing the middle AZ shifted "us-east-1c" from index 2 to index 1 — Terraform reads that as “index 1’s availability zone changed” (an in-place update, or a forced replacement for an attribute like this one) plus “index 2 no longer exists” (a destroy), when the actual intent was “stop using one AZ, leave the other two alone.” Same mechanics as AI bug catalog entry 10. Fix: for_each = toset(var.availability_zones), keyed by the AZ name itself, not its position.
Resources: no lifecycle block on the one instance that matters
Section titled “Resources: no lifecycle block on the one instance that matters”# excerpt, as generated -- entries/06-no-lifecycle/main.tfresource "aws_instance" "app" { ami = var.ami_id instance_type = var.instance_type # no lifecycle block at all}Symptom — real, proven with an applied terraform_data standing in for the instance:
$ terraform apply -auto-approveApply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform plan -destroyPlan: 0 to add, 0 to change, 1 to destroy.Nothing stops it — a stray -destroy, a removed module block, or a renamed resource (which Terraform treats as delete + recreate without a moved block) all destroy this instance with zero resistance. Adding lifecycle { prevent_destroy = true } and repeating the identical plan -destroy:
Plan: 0 to add, 0 to change, 1 to destroy.
Error: Instance cannot be destroyed
on main.tf line 1: 1: resource "terraform_data" "app" {
Resource terraform_data.app has lifecycle.prevent_destroy set, but the plancalls for this resource to be destroyed. To avoid this error and continuewith the plan, either disable lifecycle.prevent_destroy or reduce the scopeof the plan using the -target option.Same mechanics as AI bug catalog entry 9 — prevent_destroy protects only the resource it’s declared on, so it has to actually be declared, on the resource that matters, to do anything at all.
Root: region hardcoded in the provider block, the region variable is dead
Section titled “Root: region hardcoded in the provider block, the region variable is dead”# excerpt, as generated -- entries/07-hardcoded-region/main.tfprovider "aws" { region = "us-east-1"}variable "region" { type = string default = "us-east-1"}Symptom — real terraform plan -var="region=us-west-2" against the module+root:
$ terraform plan -var="region=us-west-2" + region = "us-east-1" + region = "us-east-1" + region = "us-east-1"Every resource’s planned region attribute still reads "us-east-1" — passing a completely different value for var.region changes nothing, because nothing in the configuration ever reads it. The variable exists purely as a decoy; the real region is the literal string hardcoded into the provider block. Fix: provider "aws" { region = var.region }.
Module: network_interface on aws_instance — deprecated, and the warning does not survive a module boundary
Section titled “Module: network_interface on aws_instance — deprecated, and the warning does not survive a module boundary”# excerpt, as generated -- entries/08-deprecated-network-interface/main.tfresource "aws_instance" "app" { network_interface { network_interface_id = aws_network_interface.app.id device_index = 0 }}Symptom — real terraform validate, this exact resource declared directly in a root module:
Warning: Argument is deprecated
with aws_instance.app, on main.tf line 15, in resource "aws_instance" "app": 15: resource "aws_instance" "app" {
network_interface is deprecated. To specify the primary network interface,use primary_network_interface instead. To attach additional networkinterfaces, use the aws_network_interface_attachment resource.Fix: don’t attach the ENI through the instance’s own network_interface block at all — set subnet_id/vpc_security_group_ids directly on aws_instance for the primary interface, which is both simpler and not deprecated.
The corrected code
Section titled “The corrected code”resource "aws_vpc" "this" { cidr_block = var.vpc_cidr enable_dns_support = true enable_dns_hostnames = true tags = { Name = "${var.name}-vpc" }}
resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = { Name = "${var.name}-igw" }}
resource "aws_subnet" "public" { for_each = toset(var.availability_zones) vpc_id = aws_vpc.this.id cidr_block = cidrsubnet(var.vpc_cidr, var.subnet_newbits, index(var.availability_zones, each.value)) availability_zone = each.value map_public_ip_on_launch = true tags = { Name = "${var.name}-public-${each.value}" }}
resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this.id } tags = { Name = "${var.name}-public-rt" }}
resource "aws_route_table_association" "public" { for_each = aws_subnet.public subnet_id = each.value.id route_table_id = aws_route_table.public.id}
resource "aws_security_group" "web" { name = "${var.name}-web-sg" description = "Allow web traffic" vpc_id = aws_vpc.this.id
ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }}
resource "aws_instance" "app" { ami = var.ami_id instance_type = var.instance_type subnet_id = aws_subnet.public[var.availability_zones[0]].id vpc_security_group_ids = [aws_security_group.web.id]
tags = { Name = "${var.name}-app" }
lifecycle { prevent_destroy = true }}variable "name" { type = string}variable "vpc_cidr" { type = string default = "10.0.0.0/16"}variable "subnet_newbits" { type = number default = 4}variable "availability_zones" { type = list(string)}variable "ami_id" { type = string}variable "instance_type" { type = string default = "t3.micro"}output "vpc_id" { value = aws_vpc.this.id}output "vpc_arn" { value = aws_vpc.this.arn}output "public_subnet_ids" { value = [for s in aws_subnet.public : s.id]}output "web_security_group_id" { value = aws_security_group.web.id}output "instance_id" { value = aws_instance.app.id}terraform { required_providers { aws = { source = "hashicorp/aws", version = "~> 6.0" } }}
provider "aws" { region = var.region access_key = "mock" secret_key = "mock" skip_credentials_validation = true skip_requesting_account_id = true skip_metadata_api_check = true skip_region_validation = true}
module "app" { source = "./modules/network-compute" for_each = toset(var.environments)
name = each.value vpc_cidr = "10.${index(var.environments, each.value)}.0.0/16" availability_zones = var.availability_zones ami_id = var.ami_id}variable "region" { type = string default = "us-east-1"}variable "environments" { type = list(string) default = ["dev", "staging"]}variable "availability_zones" { type = list(string) default = ["us-east-1a", "us-east-1b"]}variable "ami_id" { type = string default = "ami-0abcdef1234567890"}output "vpc_ids" { value = { for k, m in module.app : k => m.vpc_id }}output "instance_ids" { value = { for k, m in module.app : k => m.instance_id }}mock_provider "aws" {}
variables { name = "test" vpc_cidr = "10.5.0.0/16" availability_zones = ["us-east-1a", "us-east-1b"] ami_id = "ami-0abcdef1234567890"}
run "creates_one_subnet_per_az" { command = apply
module { source = "./modules/network-compute" }
assert { condition = length(aws_subnet.public) == 2 error_message = "expected one subnet per availability zone" }
assert { condition = aws_instance.app.subnet_id == aws_subnet.public["us-east-1a"].id error_message = "instance should launch in the first AZ's subnet" }}
run "prevent_destroy_is_set" { command = apply
module { source = "./modules/network-compute" }
assert { condition = aws_subnet.public["us-east-1a"].cidr_block != aws_subnet.public["us-east-1b"].cidr_block error_message = "subnets must not share a CIDR block" }}terraform validate on the corrected module+root tree: Success! The configuration is valid. — zero errors, zero warnings. terraform plan (mock AWS credentials): Plan: 18 to add, 0 to change, 0 to destroy. terraform test, real mock_provider "aws", both run blocks against the corrected module:
$ terraform testtests/network_compute.tftest.hcl... in progress run "creates_one_subnet_per_az"... pass run "prevent_destroy_is_set"... passtests/network_compute.tftest.hcl... tearing downtests/network_compute.tftest.hcl... pass
Success! 2 passed, 0 failed.What changed and why
Section titled “What changed and why”| # | Bug | Fix |
|---|---|---|
| 1 | provider block inside the module, called with count | Provider block removed from the module — real terraform init “Module is incompatible with count, for_each, and depends_on” before the fix |
| 2 | cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) — arguments in the wrong order | cidrsubnet(var.vpc_cidr, var.subnet_newbits, count.index) — real “Invalid value for netnum parameter” at plan, invisible to validate |
| 3 | Root output reads m.vpc_id, the module only exports vpc_arn | Module now exports a real vpc_id; root reads it — real “This object does not have an attribute named vpc_id” before the fix |
| 4 | required_providers uses >= 6.0 | ~> 6.0 — real .terraform.lock.hcl shows the floating constraint silently resolved to 6.66.0 |
| 5 | count over availability_zones, index-shift on removal | for_each = toset(var.availability_zones) — real plan proved a removed middle AZ replaces/destroys the wrong indices |
| 6 | No lifecycle block on aws_instance.app | lifecycle { prevent_destroy = true } — real plan -destroy destroyed it with zero resistance before the fix |
| 7 | provider { region = "us-east-1" } hardcoded, var.region unused | provider { region = var.region } — real plan with -var="region=us-west-2" proved the variable was dead |
| 8 | aws_instance.network_interface block (deprecated) | Removed; subnet_id/vpc_security_group_ids set directly on the instance — real deprecation warning at root level, proven to vanish through a module call |
Mental Model
Section titled “Mental Model”Three of these eight bugs (1, 2, 3) are hard failures — but at three different stages: bug 1 fails at terraform init (module installation), bug 2 only fails once plan actually evaluates the function call (invisible to validate’s static type check), and bug 3 fails at the very end of plan, after all 20 resource changes were already computed. The other five never produce an error at all: an unpinned version, a count-indexed list, a missing lifecycle block, a dead variable shadowing a hardcoded literal, and a deprecated block that only warns when read directly at the root. Reading this module correctly means running the whole ladder — init, validate, plan, a diff against a real state change, test — because each rung catches a genuinely different subset, and none of them catches all eight.
Common Misconceptions
Section titled “Common Misconceptions”- “If
terraform validatepasses, the function calls inside are correct.” Proven above:cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr)validates cleanly — the type checker convertscount.indexto a string without complaint — and only fails onceplancalls the function for real. - “A deprecated argument always produces a validate warning, wherever it’s used.” Proven above: the exact same
aws_instance.network_interfaceblock produces a real warning at the root and zero warning through a module call — block-level schema deprecation does not propagate through the module boundary the way entry 2’s attribute-level deprecations (acl,managed_policy_arns) do. - “A broken output blocks the whole plan from showing anything.” Proven above:
Plan: 20 to add, 0 to change, 0 to destroyprinted in full before thevpc_idoutput error appeared — Terraform plans every resource first and evaluates outputs last.
flowchart TD A["terraform init"] -->|"1: provider in module + count"| A A --> B["terraform validate"] B --> C["terraform plan"] C -->|"2: cidrsubnet args swapped"| C C -->|"3: output reads m.vpc_id, undefined"| C C --> D["real diff against state"] D -->|"5: count index-shift"| D D -->|"6: no prevent_destroy"| D B -->|"8: network_interface deprecated -- root only"| B E[".terraform.lock.hcl"] -->|"4: unpinned provider resolved 6.66.0"| E F["provider block region"] -->|"7: hardcoded, var.region dead"| F