Skip to content

The AI bug catalog

Every snippet below was written to look exactly like the HCL an AI assistant actually produces — fluent, plausible, shaped by training data that still remembers a Terraform 0.11-era dynamodb_table lock or a pre-for_each count habit — then run for real: terraform validate (with features {} + a placeholder subscription_id this course’s harness uses everywhere), terraform test with mock_provider "azurerm" {} for anything that needs a plan-time diff (real azurerm plan always needs a real Azure authorizer — proven directly in this catalog’s entry 9 — so every plan-shaped proof below is either a genuine offline terraform_data plan or a terraform test run, never a raw azurerm plan pretending to be offline), terragrunt hcl validate, terragrunt run --all plan, and real docker run invocations of tflint/checkov/conftest. The quoted output below is copied verbatim from those runs, not reconstructed from memory. Notice which ones terraform validate/test/terragrunt actually reject outright — a minority. Most of these 23 patterns are schema-valid, plan-clean Terraform that fails later: at apply against a real Azure subscription, at the next plan after someone edits an unrelated list, or silently, never, while quietly doing the wrong thing. The 23 patterns are grouped in the same order How to read Terraform you did not write and the Review Checklist use.

1. A floating provider constraint (>= instead of ~>)

Section titled “1. A floating provider constraint (>= instead of ~>)”
entries/01-unpinned-provider/main.tf
terraform {
required_providers {
azurerm = { source = "hashicorp/azurerm", version = ">= 5.0" }
}
}

Symptom — real .terraform.lock.hcl after terraform init:

provider "registry.terraform.io/hashicorp/azurerm" {
version = "5.7.0"
constraints = ">= 5.0.0"
hashes = [ ... ]
}

>= 5.0 resolved to whatever the registry’s newest matching release is today — 5.7.0. If .terraform.lock.hcl isn’t committed (see entry 4) or someone runs init -upgrade, the next init can resolve a different newest release, silently changing provider behavior between two runs of the identical main.tf. Nothing about >= 5.0 promises the next resolve stays inside the same major version’s guarantees the way ~> 5.0 does.

Fix:

entries/01-unpinned-provider-fixed/main.tf
terraform {
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 5.0" }
}
}

Caught by: nothing automatic — terraform init/validate/plan all succeed with either constraint. Only a diff review or a pinned-versions policy catches it. See Verification Tools.

2. Deprecated provider arguments AI still writes from muscle memory

Section titled “2. Deprecated provider arguments AI still writes from muscle memory”

Real deprecation warning pulled straight from the installed hashicorp/azurerm 5.7.0 provider schema (terraform providers schema -json), not guessed:

entries/02-deprecated-args/main.tf
resource "azurerm_resource_group" "this" {
name = "acme-rg"
location = "eastus"
}
resource "azurerm_storage_account" "logs" {
name = "acmelogsstorage"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = "LRS"
public_network_access_enabled = true
}

Symptom — real terraform validate -json:

warning - Argument is deprecated - `public_network_access_enabled` has been deprecated in favour of `public_network_access` and will be removed in v6.0 of the AzureRM provider

It still validates successfully — a warning, not an error, so CI that only checks the exit code (see entry 18) ships it anyway.

Fix:

entries/02-deprecated-args-fixed/main.tf
resource "azurerm_resource_group" "this" {
name = "acme-rg"
location = "eastus"
}
resource "azurerm_storage_account" "logs" {
name = "acmelogsstorage"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
account_tier = "Standard"
account_replication_type = "LRS"
public_network_access = "Enabled"
}

Caught by: terraform validate -json | jq '.diagnostics[] | select(.severity=="warning")' — a warning grep in CI, not the default exit code.

3. A provider block inside a reusable module

Section titled “3. A provider block inside a reusable module”
entries/03-provider-in-module/modules/rg/main.tf
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
resource "azurerm_resource_group" "this" {
name = "acme-rg"
location = "eastus"
}
# entries/03-provider-in-module/main.tf — @expect-error Module is incompatible with count, for_each, and depends_on
module "rg" {
source = "./modules/rg"
count = 2
}

This resolves fine right up until a caller tries to use count/for_each/depends_on on the module call, which is exactly what a second environment usually needs next.

Symptom — real terraform init, Terraform 1.16.4:

Upgrading modules...
- rg in modules/rg
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 3, in module "rg":
3: count = 2
The module at module.rg 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/rg", consider updating this
module to instead expect provider configurations to be passed by its
caller.

Fix:

entries/03-provider-in-module-fixed/modules/rg/main.tf
resource "azurerm_resource_group" "this" {
name = "acme-rg"
location = "eastus"
}
entries/03-provider-in-module-fixed/main.tf
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
module "rg" {
source = "./modules/rg"
count = 2
}

Caught by: terraform init/validate — but only once someone actually adds count/for_each/depends_on, which is usually a whole environment later than when the module was first written and reviewed clean.

A .gitignore copied from a generic “Terraform” template block often includes .terraform.lock.hcl alongside .terraform/ — but only one of those two should ever be ignored.

entries/04-lockfile-gitignored/.gitignore
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup

Symptom — real, reproduced locally: delete .terraform.lock.hcl and re-run terraform init with the floating constraint from entry 1 (>= 6.0):

$ rm .terraform.lock.hcl && terraform init -backend=false
Initializing provider plugins...
- Finding hashicorp/aws versions matching ">= 6.0.0"...
- Installing hashicorp/aws v6.66.0...

Nothing warns you the resolved version might differ from what a teammate — or CI, on a different day — resolves. The lock file exists precisely to make that resolution reproducible across machines and time; gitignoring it throws that guarantee away.

Fix:

entries/04-lockfile-gitignored-fixed/.gitignore
.terraform/
*.tfstate
*.tfstate.backup

Caught by: git status after a clean init showing .terraform.lock.hcl as untracked — or a repo policy that fails CI if the lock file is missing.

5. Copying the S3 backend’s dynamodb_table locking pattern onto azurerm, where it never existed

Section titled “5. Copying the S3 backend’s dynamodb_table locking pattern onto azurerm, where it never existed”
entries/05-backend-locking/backend.tf
terraform {
backend "azurerm" {
resource_group_name = "acme-tfstate-rg"
storage_account_name = "acmetfstate"
container_name = "tfstate"
key = "network.terraform.tfstate"
dynamodb_table = "acme-tf-locks"
}
}

dynamodb_table is real — on the s3 backend, for a completely different cloud. AI training data mixes backend examples across clouds constantly, and this argument simply does not exist on the azurerm backend’s schema at all. Per the azurerm backend docs: the backend “supports state locking and consistency checking with Azure Blob Storage native capabilities” — locking is handled by an Azure blob lease automatically, with no separate table, resource, or extra argument to provision, IAM-permission, or pay for at all, unlike S3’s now-deprecated dynamodb_table (see this catalog’s aws sibling course).

Fix:

entries/05-backend-locking-fixed/backend.tf
terraform {
backend "azurerm" {
resource_group_name = "acme-tfstate-rg"
storage_account_name = "acmetfstate"
container_name = "tfstate"
key = "network.terraform.tfstate"
}
}

Caught by: nothing in validate/plan with -backend=false — the argument is silently accepted without ever being checked against the real backend schema. Only a real, credentialed terraform init (which this course never runs) or a reviewer who knows azurerm locking needs no table at all catches this.

6. terraform_remote_state pointed at a backend/path that was never applied

Section titled “6. terraform_remote_state pointed at a backend/path that was never applied”
# entries/06-remote-state-cross-backend/main.tf — @expect-error No stored state was found for the given workspace in the given backend.
data "terraform_remote_state" "network" {
backend = "local"
config = {
path = "../does-not-exist/terraform.tfstate"
}
}

Symptom — real terraform plan:

data.terraform_remote_state.network: Reading...
Planning failed. Terraform encountered an error while generating this plan.
Error: Unable to find remote state
with data.terraform_remote_state.network,
on main.tf line 1, in data "terraform_remote_state" "network":
1: data "terraform_remote_state" "network" {
No stored state was found for the given workspace in the given backend.

There is no mock fallback — terraform_remote_state hard-fails the whole plan the moment the referenced state doesn’t exist yet, which is exactly the state during a first-ever apply of a stack another stack depends on, or after a state file gets moved to a different key/bucket without every consumer being updated.

Fix: point config.path/config.key at a state file that has actually been applied, or replace the cross-stack read with the Terragrunt dependency block pattern (mock_outputs) covered in this course’s dependencies-between-units lesson, which does have a documented offline fallback.

Caught by: terraform plan — loudly, every time — but only the consumer’s plan, never the producer’s. If the producer stack silently changes its backend key, every consumer breaks on their next plan, not the producer’s.

7. nullable = false silently substituting the default instead of erroring

Section titled “7. nullable = false silently substituting the default instead of erroring”
entries/07-nullable-false/child/variables.tf
variable "instance_count" {
type = number
default = 3
nullable = false
}
entries/07-nullable-false/child/main.tf
output "effective_count" {
value = var.instance_count
}
entries/07-nullable-false/variables.tf
variable "override" {
type = number
default = null
}
entries/07-nullable-false/main.tf
module "child" {
source = "./child"
instance_count = var.override
}
output "child_effective_count" {
value = module.child.effective_count
}

Symptom — real terraform apply, var.override is null:

Changes to Outputs:
+ child_effective_count = 3
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
child_effective_count = 3

With nullable = false (the default for a declared variable with no explicit nullable set — AI usually writes it out explicitly assuming it rejects null), passing an explicit null does not error: Terraform silently substitutes the default, 3, with zero indication anything was overridden at all. Flipping the same variable to nullable = true and re-running proves the difference directly — the output genuinely becomes null:

Changes to Outputs:
- child_effective_count = 3 -> null
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Fix: if null should be a real, distinguishable input, set nullable = true and have downstream logic branch on var.x == null explicitly; if null should genuinely be rejected, don’t set nullable = false and rely on silent substitution — validate it:

entries/07-nullable-false-fixed/child/variables.tf
variable "instance_count" {
type = number
default = 3
validation {
condition = var.instance_count != null
error_message = "instance_count must not be null."
}
}

Caught by: nothing in plan/apply output by default — only terraform plan diffing the before value against the after value across a refactor, or a validation block written deliberately to reject null.

8. An ephemeral variable’s literal default leaking into show -json

Section titled “8. An ephemeral variable’s literal default leaking into show -json”
entries/08-ephemeral-leak/main.tf
variable "api_token" {
type = string
ephemeral = true
default = "shh-do-not-print-me"
}
resource "terraform_data" "example" {
input = "static-value"
}

Symptom — real terraform show -json on a saved plan:

$ terraform plan -out=plan.out && terraform show -json plan.out | jq '.variables'
{
"api_token": {
"value": "shh-do-not-print-me"
}
}

ephemeral = true (Terraform 1.10+) keeps a value out of state, but it does not keep a literal default out of the saved plan file’s variables block — the exact JSON structure a conftest/OPA policy gate in CI reads (see entry 20). A default meant only as a local fallback ships straight into whatever artifact CI archives.

Fix: never give an ephemeral variable a literal secret default — require it to be supplied at each run (no default, or an ephemeral resource as the real source):

entries/08-ephemeral-leak-fixed/main.tf
variable "api_token" {
type = string
ephemeral = true
}
resource "terraform_data" "example" {
input = "static-value"
}

Caught by: grepping an archived show -json plan artifact for secret-shaped strings — validate/plan never flags this on their own.

9. prevent_destroy on the disposable resource, not the one that matters

Section titled “9. prevent_destroy on the disposable resource, not the one that matters”
entries/09-prevent-destroy-wrong-resource/main.tf
resource "azurerm_resource_group" "scratch" {
name = "acme-scratch-rg"
location = "eastus"
lifecycle {
prevent_destroy = true
}
}
resource "azurerm_key_vault" "primary" {
name = "acme-primary-kv"
location = "eastus"
resource_group_name = "acme-prod-rg"
tenant_id = "11111111-1111-1111-1111-111111111111"
sku_name = "standard"
rbac_authorization_enabled = true
}

prevent_destroy is a core Terraform lifecycle mechanism, identical across every provider — proven once, fully, with real AWS resources in this course’s aws sibling: plan -destroy against a stack where only a disposable aws_iam_role carries prevent_destroy = true blocks only that role’s destroy, while the real aws_db_instance next to it destroys with zero resistance. The exact same graph-level check applies to any provider’s resources, including azurerm.

Fix: move prevent_destroy to the resource that actually needs it:

entries/09-prevent-destroy-wrong-resource-fixed/main.tf
resource "azurerm_key_vault" "primary" {
name = "acme-primary-kv"
location = "eastus"
resource_group_name = "acme-prod-rg"
tenant_id = "11111111-1111-1111-1111-111111111111"
sku_name = "standard"
rbac_authorization_enabled = true
lifecycle {
prevent_destroy = true
}
}

Caught by: a plan -destroy/terraform plan for the exact resource in question — prevent_destroy protects only the resource it’s declared on, never “the important thing nearby.”

10. count where the list can be reordered — the index shift

Section titled “10. count where the list can be reordered — the index shift”
entries/10-count-index-shift/main.tf
variable "rg_names" {
type = list(string)
default = ["logs", "assets", "backups"]
}
resource "azurerm_resource_group" "this" {
count = length(var.rg_names)
name = "acme-${var.rg_names[count.index]}-${count.index}"
location = "eastus"
}

Symptom — real terraform test with mock_provider "azurerm" {}: seed three resource groups with apply, then plan with the middle element ("assets") removed from rg_names:

# azurerm_resource_group.this[1] will be updated in-place
~ resource "azurerm_resource_group" "this" {
id = "tbfvcse9"
~ name = "acme-assets-1" -> "acme-backups-1"
# (1 unchanged attribute hidden)
}
# azurerm_resource_group.this[2] will be destroyed
# (because index [2] is out of range for count)
- resource "azurerm_resource_group" "this" {
- id = "7wknoyg5" -> null
- location = "eastus" -> null
- name = "acme-backups-2" -> null
}
Plan: 0 to add, 1 to change, 1 to destroy.

count.index is positional, not identity-based. Removing "assets" from the middle shifted "backups" from index 2 to index 1 — Terraform reads that as “index 1 changed from the assets resource group to the backups resource group” (a rename in place, here — name is mutable on azurerm_resource_group, so this doesn’t even force replacement, it just silently renames the wrong resource group) and “index 2 no longer exists” (a destroy), when the human intent was simply “delete one resource group, leave the other two alone.”

Fix: key by a stable identity with for_each, never a position:

entries/10-count-index-shift-fixed/main.tf
variable "rg_names" {
type = list(string)
default = ["logs", "backups"]
}
resource "azurerm_resource_group" "this" {
for_each = toset(var.rg_names)
name = "acme-${each.value}"
location = "eastus"
}

Caught by: terraform plan -detailed-exitcode on any change to a count-indexed list — the destroy/replace pair is visible in every plan, but only if someone actually reads the plan instead of trusting a green apply.

11. for_each over a value only known after apply

Section titled “11. for_each over a value only known after apply”
entries/11-for-each-unknown/main.tf
resource "azurerm_resource_group" "generator" {
name = "acme-generator-rg"
location = "eastus"
}
resource "azurerm_role_assignment" "per_tag" {
for_each = toset(azurerm_resource_group.generator.tags == null ? [] : keys(azurerm_resource_group.generator.tags))
scope = azurerm_resource_group.generator.id
role_definition_name = "Reader"
principal_id = "11111111-1111-1111-1111-111111111111"
}

count accepts a number known only after apply in some contexts; for_each never accepts a set of keys that isn’t fully known at plan time — the keys are the resource’s identity, and Terraform must know every resource’s identity before it can compute a plan at all. This is core Terraform graph-evaluation behavior, identical for every provider — proven directly, fully, with real AWS resources in this course’s aws sibling: the real, current “Invalid for_each argument” error, naming the exact computed attribute (tags_all is a map of string, known only after apply) that made the set unknown.

Fix: derive the for_each set from something known at plan time — a variable, a locals value, or a data source’s plan-time-known attribute — never from another managed resource’s computed output.

Caught by: terraform plan — always, loudly, before anything gets created, against a real provider. This one never ships silently. Caught by: terraform plan — always, loudly, before anything gets created. This one never ships silently.

12. null_resource where terraform_data (1.4+) is the built-in replacement

Section titled “12. null_resource where terraform_data (1.4+) is the built-in replacement”
entries/12-null-resource/main.tf
resource "null_resource" "seed" {
triggers = { always = timestamp() }
provisioner "local-exec" {
command = "echo seeding"
}
}

This still validates cleanly — null_resource isn’t removed — but it depends on the separate hashicorp/null provider for something Terraform 1.4+ ships as a first-class, provider-free resource type: terraform_data, part of Terraform’s own built-in terraform.io/builtin/terraform provider (used throughout this catalog already, entries 6, 8, 11, 17). terraform_data adds input/output attributes null_resource never had, so downstream references get a real, typed value instead of only id/triggers.

Fix:

entries/12-null-resource-fixed/main.tf
resource "terraform_data" "seed" {
triggers_replace = [timestamp()]
provisioner "local-exec" {
command = "echo seeding"
}
}

Caught by: nothing in validate/plan — both resource types work. Only a reviewer who knows terraform_data removes an entire extra provider dependency (hashicorp/null — one more entry in .terraform.lock.hcl, one more plugin to download and pin) for equivalent behavior catches it.

13. A hallucinated argument copied from a different resource type

Section titled “13. A hallucinated argument copied from a different resource type”
entries/13-hallucinated-argument/network.tf
resource "azurerm_network_interface" "app" {
name = "app-nic"
resource_group_name = "acme-rg"
location = "eastus"
ip_configuration {
name = "internal"
subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/acme-rg/providers/Microsoft.Network/virtualNetworks/acme-vnet/subnets/acme-subnet"
private_ip_address_allocation = "Dynamic"
}
}
# entries/13-hallucinated-argument/main.tf — @expect-error An argument named "deletion_protection" is not expected here.
resource "azurerm_linux_virtual_machine" "app" {
name = "app-vm"
resource_group_name = "acme-rg"
location = "eastus"
size = "Standard_B1s"
admin_username = "adminuser"
deletion_protection = true
network_interface_ids = [azurerm_network_interface.app.id]
admin_ssh_key {
username = "adminuser"
public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCw76OeKnxX3rY+weRZtTNo1mo2LE8lVHFsVULODxrTyLOlEFcAvgeRjFcV7W+WH7FPWmEtCWPKQjPPpCMF4Vi/5xtHh0p0ZGNqa7V31/wdHCMZmyZRtNuKnRznl9EjozCuaHTGIzkKTd7kEsJXETFOgAhR9lNQUAyT895imscQ45Ld6zt3XhU35AgQbQynxGhI7M1MWni3RwRBg5Rzm23wMsXAlv7qXP0axoQhR0CNxFXOCW56E4rNp42yUCoPQJL3LJWBGLv/HN4GMB8P4ksL/e4UdiFcKK4gKL5vlivkyK7brTrit+8khv4Uj66vDGSbKAzDdiYXtI01csKlfwNZ"
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}

deletion_protection is a real Terraform argument on several resources (aws_db_instance, google_sql_database_instance) — it does not exist on azurerm_linux_virtual_machine at all. AI training data mixes deletion-protection idioms across clouds and resource types constantly.

Symptom — real terraform validate:

Error: Unsupported argument
on main.tf line 6, in resource "azurerm_linux_virtual_machine" "app":
6: deletion_protection = true
An argument named "deletion_protection" is not expected here.

Fix: Azure VMs have no direct “deletion protection” toggle at all — the real, current equivalent is a Microsoft.Authorization/locks resource lock (azurerm_management_lock), a separate resource entirely, not an argument on the VM:

entries/13-hallucinated-argument-fixed/network.tf
resource "azurerm_network_interface" "app" {
name = "app-nic"
resource_group_name = "acme-rg"
location = "eastus"
ip_configuration {
name = "internal"
subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/acme-rg/providers/Microsoft.Network/virtualNetworks/acme-vnet/subnets/acme-subnet"
private_ip_address_allocation = "Dynamic"
}
}
entries/13-hallucinated-argument-fixed/main.tf
resource "azurerm_linux_virtual_machine" "app" {
name = "app-vm"
resource_group_name = "acme-rg"
location = "eastus"
size = "Standard_B1s"
admin_username = "adminuser"
network_interface_ids = [azurerm_network_interface.app.id]
admin_ssh_key {
username = "adminuser"
public_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCw76OeKnxX3rY+weRZtTNo1mo2LE8lVHFsVULODxrTyLOlEFcAvgeRjFcV7W+WH7FPWmEtCWPKQjPPpCMF4Vi/5xtHh0p0ZGNqa7V31/wdHCMZmyZRtNuKnRznl9EjozCuaHTGIzkKTd7kEsJXETFOgAhR9lNQUAyT895imscQ45Ld6zt3XhU35AgQbQynxGhI7M1MWni3RwRBg5Rzm23wMsXAlv7qXP0axoQhR0CNxFXOCW56E4rNp42yUCoPQJL3LJWBGLv/HN4GMB8P4ksL/e4UdiFcKK4gKL5vlivkyK7brTrit+8khv4Uj66vDGSbKAzDdiYXtI01csKlfwNZ"
}
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts"
version = "latest"
}
}
resource "azurerm_management_lock" "app" {
name = "app-vm-cannot-delete"
scope = azurerm_linux_virtual_machine.app.id
lock_level = "CanNotDelete"
}

Caught by: terraform validate — this one is always loud, immediately, at plan time. The dangerous version of this pattern is entries 2/9, where the wrong-but-real argument doesn’t error at all.

# not a real path — a git clone against a live, network-reachable repo is out of scope for this offline harness; see the callout below
module "vpc" {
source = "git::https://example.com/vpc.git"
}

Per the module sources docs: “You can add the ref query parameter to the location specified in the source argument to reference any value supported by the git checkout command, such as a branch, SHA-1 hash, or tag.” Without it: “Terraform clones and uses the default branch referenced by HEAD” — meaning the exact same configuration, re-initialized on two different days, can silently pull two different commits of the module.

Fix:

# not a real path — same reason as above
module "vpc" {
source = "git::https://example.com/vpc.git?ref=v1.4.0"
}

Caught by: nothing in validate/plan — both resolve successfully. .terraform.lock.hcl records provider hashes, never module source revisions, so there is no built-in drift detector for this one at all; only a pinned tag/SHA and a human review of the ref= value catch it.

15. depends_on added to paper over a real reference cycle

Section titled “15. depends_on added to paper over a real reference cycle”
# entries/15-depends-on-hides-cycle/main.tf — @expect-error Error: Cycle
resource "terraform_data" "a" {
input = terraform_data.b.output
}
resource "terraform_data" "b" {
input = terraform_data.a.output
}

Symptom — real terraform validate:

Error: Cycle:
terraform_data.a
terraform_data.b

Adding an explicit depends_on to either resource, hoping to “force” an order, does not fix this — a depends_on only adds an additional ordering constraint on top of the graph Terraform builds from real attribute references; it cannot remove the cycle those references themselves create. The correct fix is to break the actual reference loop, not to paper over the graph error with an ordering hint.

Fix: break the cycle by giving both sides an independent, non-circular source of truth:

entries/15-depends-on-hides-cycle-fixed/main.tf
locals {
shared_seed = "2024-01-shared"
}
resource "terraform_data" "a" {
input = local.shared_seed
}
resource "terraform_data" "b" {
input = local.shared_seed
}

Caught by: terraform validate — always, immediately, and no depends_on can suppress it. The real risk is the time an AI assistant spends adding depends_on blocks trying to fix an error whose actual cause is a genuine circular reference.

16. run-all/plan-all — removed in the 1.1 CLI redesign

Section titled “16. run-all/plan-all — removed in the 1.1 CLI redesign”
$ terragrunt plan-all --working-dir live/dev/vpc
ERROR unknown command: "plan-all". Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- plan-all ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command
$ terragrunt run-all plan --working-dir .
ERROR unknown command: "run-all". Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- run-all ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command

Both commands are all over pre-1.1 tutorials and therefore all over AI training data. Terragrunt 1.1’s CLI redesign replaced them.

Fix — the real, current command, run for real against a two-unit tree:

entries/16-run-all-removed/root.hcl
remote_state {
backend = "local"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }
}
entries/16-run-all-removed/modules/vpc/main.tf
variable "cidr" {
type = string
default = "10.0.0.0/16"
}
resource "terraform_data" "vpc" {
input = var.cidr
}
entries/16-run-all-removed/live/dev/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
$ terragrunt run --all plan --working-dir .
[live/dev/vpc] terraform: Plan: 1 to add, 0 to change, 0 to destroy.
❯❯ Run Summary 1 units 56ms
Succeeded 1

Caught by: immediately, every time — Terragrunt refuses to even start. Never ships silently.

17. The same terragrunt.hcl copy-pasted from dev into prod, inputs untouched

Section titled “17. The same terragrunt.hcl copy-pasted from dev into prod, inputs untouched”
entries/17-hardcoded-account-copy/live/dev/vpc/terragrunt.hcl
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
inputs = {
account_id = "111111111111"
region = "us-east-1"
}
entries/17-hardcoded-account-copy/live/prod/vpc/terragrunt.hcl
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
inputs = {
account_id = "111111111111"
region = "us-east-1"
}

Symptom — real, reproduced locally:

$ terragrunt hcl validate --working-dir live/prod/vpc
(no output — both files are syntactically valid HCL)
$ diff live/dev/vpc/terragrunt.hcl live/prod/vpc/terragrunt.hcl
(no output — the files are byte-identical)

Nothing about this is invalid HCL. prod/vpc deploys against dev’s AWS account, silently — terragrunt hcl validate has no way to know account_id = "111111111111" under live/prod/ is wrong, because there is nothing in the file that says which account is “correct” for that path.

Fix: derive account_id/region from the directory path itself, so dev and prod genuinely cannot share a value by copy-paste:

entries/17-hardcoded-account-copy-fixed/live/prod/env.hcl
locals {
account_id = "222222222222"
region = "us-east-1"
}
entries/17-hardcoded-account-copy-fixed/live/prod/vpc/terragrunt.hcl
locals {
env_vars = read_terragrunt_config(find_in_parent_folders("env.hcl"))
}
terraform {
source = "${get_terragrunt_dir()}/../../../modules/vpc"
}
inputs = {
account_id = local.env_vars.locals.account_id
region = local.env_vars.locals.region
}

Caught by: nothing automatic — hcl validate/hcl format --check both pass. Only a real terragrunt run --all plan per environment, diffed by a human who knows which account each environment should target, catches a copy-paste this clean.

Symptom — real, side by side:

$ terraform apply
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: no
Apply cancelled.
$ terraform apply -auto-approve
terraform_data.release: Creating...
terraform_data.release: Creation complete after 0s [id=bb8b46af-eb1e-0b33-104a-b4b39a2559a6]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

-auto-approve doesn’t just skip a prompt CI can’t answer anyway — it removes the last point where a human (or a policy gate reading the exact plan about to be applied) can stop a destructive change. The right replacement is a separate plan -out=tfplan step, a policy check (entry 20) or human approval gate against that saved plan, then apply tfplan — never apply -auto-approve against a fresh, unreviewed plan.

Fix:

# .github/workflows/terraform.yml (fragment, not a collected fence)
- run: terraform plan -out=tfplan -detailed-exitcode
- run: conftest test --policy policy $(terraform show -json tfplan | tee plan.json | true)
- run: terraform apply tfplan # applies the reviewed plan file, not a fresh -auto-approve

Caught by: a pipeline review, never validate/plan — both flags are valid CLI usage.

19. checkov-action@master — a mutable tag treated as pinned

Section titled “19. checkov-action@master — a mutable tag treated as pinned”
# .github/workflows/checkov.yml (fragment, not a collected fence)
- uses: bridgecrewio/checkov-action@master
with:
directory: .

Per GitHub’s own hardening guide: “Pinning an action to a full-length commit SHA is currently the only way to use an action as an immutable release.” @master (or @main, @v1) is a branch reference — it can point at a different commit tomorrow than it does today, with no diff in this repository’s own history to review. GitHub’s rationale, quoted directly: a bad actor compromising the action’s repository would need “a SHA-1 collision for a valid Git object payload” to defeat a pinned SHA — computationally infeasible — while a mutable tag or branch can simply be moved.

Fix:

# .github/workflows/checkov.yml (fragment, not a collected fence)
- uses: bridgecrewio/checkov-action@c98096a34955fc70f684e0c1ce43b4e1b2ea3f9 # v12.2919.0
with:
directory: .

Caught by: nothing in CI’s own output — the workflow runs identically either way, until the day it doesn’t. Only a dependency-pinning linter (zizmor, GitHub’s own Dependabot version-pinning nudge) or manual review catches it.

20. Pre-v1 Rego (deny[msg] { ... }) silently fails to load under OPA v1

Section titled “20. Pre-v1 Rego (deny[msg] { ... }) silently fails to load under OPA v1”
# entries/20-rego-v1/policy/deny_no_tags.rego — @expect-error rego_parse_error: `if` keyword is required before rule body
package main
deny[msg] {
resource := input.resource_changes[_]
resource.type == "terraform_data"
msg := sprintf("%s must set tags for cost allocation", [resource.address])
}

Symptom — real conftest test, current openpolicyagent/conftest, which defaults to Rego v1 syntax, against a real terraform show -json plan (an azurerm-provider config’s plan for a terraform_data marker resource — real azurerm plans need a real Azure authorizer this course never has, see entry 9, so the policy target here is the same provider-agnostic terraform_data resource used throughout this catalog):

Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/deny_no_tags.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/deny_no_tags.rego:3: rego_parse_error: `contains` keyword is required for partial set rules

The bracket-set rule shape (deny[msg] { ... }) that fills years of Rego tutorials AI training data draws from is exactly what OPA v1 (the default since OPA 1.0 / current conftest) rejects outright — deny becomes a policy fails to load at all error, not merely a rule that never fires.

Fix — real v1 syntax, same policy, run against the real plan:

entries/20-rego-v1-fixed/policy/deny_no_tags.rego
package main
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "terraform_data"
msg := sprintf("%s must set tags for cost allocation", [resource.address])
}
$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.json
FAIL - plan.json - main - terraform_data.app_marker must set tags for cost allocation
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

Caught by: conftest test itself, real and immediate — but as a load error that can look like a CI/tooling problem rather than what it actually is: outdated policy syntax.

21. A checkov skip comment naming the wrong (but real) check ID

Section titled “21. A checkov skip comment naming the wrong (but real) check ID”
entries/21-invented-rule-id/main.tf
resource "azurerm_storage_account" "logs" {
#checkov:skip=CKV_AZURE_35: public access is required for this static site bucket
name = "acmelogsstorage"
resource_group_name = "acme-rg"
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
public_network_access_enabled = true
}

Both CKV_AZURE_35 and CKV_AZURE_59 are real checkov check IDs — for different rules. CKV_AZURE_35 is “Ensure default network access rule for Storage Accounts is set to deny” (network ACL default action); CKV_AZURE_59 is “Ensure that Storage accounts disallow public access” (confirmed directly against checkov’s own installed source, checkov/terraform/checks/resource/azure/StorageAccountDefaultNetworkAccessDeny.py and StorageAccountDisablePublicAccess.py). The AI-generated skip comment names the network-ACL check to silence what is actually the public-access check — a real ID, aimed at the wrong rule.

Symptom — real, reproduced with the pinned bridgecrew/checkov image:

$ docker run --rm -v "$(pwd)":/tf bridgecrew/checkov -d /tf --compact --quiet
Check: CKV_AZURE_59: "Ensure that Storage accounts disallow public access"

The skip comment does not error, does not warn that its target ID doesn’t apply here — CKV_AZURE_59 still fails and still shows up in the report, exactly as if no skip comment were present at all, because the skip only ever matched CKV_AZURE_35 (a check that was never the one failing).

Fix: skip the ID that’s actually firing, and only if the exception is genuinely intended:

entries/21-invented-rule-id-fixed/main.tf
resource "azurerm_storage_account" "logs" {
#checkov:skip=CKV_AZURE_59: public access is required for this static site bucket
name = "acmelogsstorage"
resource_group_name = "acme-rg"
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
public_network_access_enabled = true
}

Caught by: nothing checkov itself reports — this is the most dangerous shape in this whole catalog: a suppression comment that reads as if it silences the finding below it, while silencing a completely unrelated, unrelated-in-this-run check instead. Only cross-checking the ID a skip comment names against checkov’s real, current check catalog catches it.

22. terraform.tfvars with a real secret, committed

Section titled “22. terraform.tfvars with a real secret, committed”
entries/22-tfvars-secret-committed/terraform.tfvars
db_password = "Sup3rSecret!2024"
api_token = "ghp_1234567890abcdefghijklmnopqrstuvwxyz"
entries/22-tfvars-secret-committed/.gitignore
.terraform/
*.tfstate

*.tfvars isn’t in .gitignore at all — a generic Terraform .gitignore template (see entry 4) protects state and the provider cache, but plenty of real-world templates never mention .tfvars, because *.tfvars.example/checked-in non-secret .tfvars files are a completely legitimate, common pattern. AI assistants generating a .gitignore from a generic prompt reproduce that same gap.

Fix:

entries/22-tfvars-secret-committed-fixed/.gitignore
.terraform/
*.tfstate
*.tfvars
!*.tfvars.example

…plus moving the actual secret out of any file at all — an environment variable (TF_VAR_db_password), a secrets manager reference, or a write-only argument (entry 23) — because a .gitignore fix after the fact does not remove the secret from git history already committed.

Caught by: git log -p -- '*.tfvars' on an existing repo, or a pre-commit secret scanner (gitleaks, trufflehog) — never validate/plan, which don’t inspect .gitignore at all.

23. sensitive = true hides the CLI, not the state file

Section titled “23. sensitive = true hides the CLI, not the state file”
entries/23-sensitive-state-plaintext/main.tf
resource "random_password" "db" {
length = 16
special = false
}
output "db_password" {
value = random_password.db.result
sensitive = true
}

Symptom — real, side by side:

$ terraform apply -auto-approve
Outputs:
db_password = <sensitive>
$ grep -A1 '"result"' terraform.tfstate
"result": "QxDS75iFGBWDNsqc",

sensitive = true redacts the CLI output and terraform show by default — it does not encrypt or omit the value from terraform.tfstate on disk. Anyone with read access to the state file (local disk, a storage account without strict RBAC, a CI log artifact that happens to cat the state) reads the real password in plaintext, “sensitive” label or not. random_password has no write-only argument to avoid this — but azurerm_postgresql_flexible_server does: administrator_password_wo + administrator_password_wo_version, confirmed directly against the installed hashicorp/azurerm 5.7.0 provider schema, which never gets written to state at all.

Fix: for values that genuinely never need to persist in state, use a write-only argument where the target resource supports one, and treat sensitive = true as a CLI-output control only — never as the security boundary. azurerm has real write-only arguments too — confirmed directly against the installed hashicorp/azurerm 5.7.0 provider schema (azurerm_key_vault_secret.value_wo, azurerm_mssql_server.administrator_login_password_wo, azurerm_postgresql_flexible_server.administrator_password_wo, among others):

entries/23-sensitive-state-plaintext-fixed/main.tf
resource "random_password" "db" {
length = 16
special = false
}
resource "azurerm_postgresql_flexible_server" "primary" {
name = "acme-primary-pg"
resource_group_name = "acme-prod-rg"
location = "eastus"
version = "16"
administrator_login = "psqladmin"
administrator_password_wo = random_password.db.result
administrator_password_wo_version = 1
sku_name = "B_Standard_B1ms"
}

Caught by: grep on a raw terraform.tfstate/terraform show -json — never the CLI’s own redacted output, which is exactly what makes this pattern easy to trust wrongly.

Why does entry 3's provider block inside a module validate fine on its own, and only fail once count = 2 is added to the module call?
Given var.override = null passed into a nullable = false variable with default = 3, what does this real output prove?
$ terraform apply -auto-approve
Changes to Outputs:
  + child_effective_count = 3
Why does entry 9 move lifecycle { prevent_destroy = true } from azurerm_resource_group.scratch to azurerm_key_vault.primary rather than just adding it to both?
Entry 20's pre-v1 Rego (deny[msg] { ... }) and entry 21's misdirected checkov skip comment (#checkov:skip=CKV_AZURE_35 aimed at a CKV_AZURE_59 finding) both represent broken policy checks — but they fail differently. How?
Why does entry 23 say sensitive = true is not a security boundary, given it does redact db_password from the real terraform apply output shown?