Skip to content

Worked review 1: a network + compute module

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.

# network+compute module, as generated -- modules/network-compute/main.tf
provider "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.tf
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_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.tf
terraform {
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]
}

Module: a provider block inside a reusable module, called with count

Section titled “Module: a provider block inside a reusable module, called with count”
entries/01-provider-in-module/modules/network-compute/main.tf
provider "aws" {
region = "us-east-1"
}
# entries/01-provider-in-module/main.tf -- @expect-error Module is incompatible with count, for_each, and depends_on
module "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=false
Initializing 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 local
provider 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 updating
this module to instead expect provider configurations to be passed by its
caller.

🐛 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.tf
resource "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 plan
Error: 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.

# excerpt, as generated -- entries/04-unpinned-provider/main.tf
terraform {
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.tf
resource "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.tf
resource "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-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform plan -destroy
Plan: 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 plan
calls for this resource to be destroyed. To avoid this error and continue
with the plan, either disable lifecycle.prevent_destroy or reduce the scope
of 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.tf
provider "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.tf
resource "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 network
interfaces, 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.

modules/network-compute/main.tf
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
}
}
modules/network-compute/variables.tf
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"
}
modules/network-compute/outputs.tf
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
}
main.tf
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
}
variables.tf
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"
}
outputs.tf
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 }
}
tests/network_compute.tftest.hcl
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 test
tests/network_compute.tftest.hcl... in progress
run "creates_one_subnet_per_az"... pass
run "prevent_destroy_is_set"... pass
tests/network_compute.tftest.hcl... tearing down
tests/network_compute.tftest.hcl... pass
Success! 2 passed, 0 failed.
#BugFix
1provider block inside the module, called with countProvider block removed from the module — real terraform init “Module is incompatible with count, for_each, and depends_on” before the fix
2cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) — arguments in the wrong ordercidrsubnet(var.vpc_cidr, var.subnet_newbits, count.index) — real “Invalid value for netnum parameter” at plan, invisible to validate
3Root output reads m.vpc_id, the module only exports vpc_arnModule now exports a real vpc_id; root reads it — real “This object does not have an attribute named vpc_id” before the fix
4required_providers uses >= 6.0~> 6.0 — real .terraform.lock.hcl shows the floating constraint silently resolved to 6.66.0
5count over availability_zones, index-shift on removalfor_each = toset(var.availability_zones) — real plan proved a removed middle AZ replaces/destroys the wrong indices
6No lifecycle block on aws_instance.applifecycle { prevent_destroy = true } — real plan -destroy destroyed it with zero resistance before the fix
7provider { region = "us-east-1" } hardcoded, var.region unusedprovider { region = var.region } — real plan with -var="region=us-west-2" proved the variable was dead
8aws_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

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.

  • “If terraform validate passes, the function calls inside are correct.” Proven above: cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) validates cleanly — the type checker converts count.index to a string without complaint — and only fails once plan calls the function for real.
  • “A deprecated argument always produces a validate warning, wherever it’s used.” Proven above: the exact same aws_instance.network_interface block 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 destroy printed in full before the vpc_id output 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
Where each bug sits in this module's evaluation path
Why does this error appear at terraform init rather than terraform validate?
Error: Module is incompatible with count, for_each, and depends_on
cidrsubnet(count.index, var.subnet_newbits, var.vpc_cidr) has its arguments in the wrong order. Why does terraform validate accept this, when terraform plan rejects it with "Invalid value for \"netnum\" parameter: a number is required"?
A root output does [for m in module.app : m.vpc_id], but the module only exports vpc_arn. What does the real terraform plan output show, in order?
The exact same aws_instance block with a deprecated network_interface { } argument is declared once directly in a root module, and once inside a called module. What does real terraform validate show for each?
aws_subnet.public uses count = length(var.availability_zones). Three AZs are applied for real, then the middle one is removed from the list. What does the real terraform plan show?
provider "aws" { region = "us-east-1" } is hardcoded, while variable "region" exists with a default of the same value. Running terraform plan -var="region=us-west-2" — what does the real plan show for every resource's region attribute?