DEV Community

James Joyner for The Copilot Stack

Posted on Originally published at thecopilotstack.com

Reviewing Generated Terraform: The Destroy Count, count vs for_each, and a Comment in the Wrong Place

Terraform is the worst place to accept generated code uncritically, because it is the one where "wrong" can mean a deleted database rather than a failing test.

It is also a place where Copilot is genuinely useful — provider schemas are exactly the kind of thing nobody should be memorising. The trick is knowing which parts of the output are checkable and which parts are judgement.

Here is what I check, and the two gotchas that have actually bitten me.

terraform validate does not mean safe

terraform validate checks your configuration against the provider schema. Types line up, required arguments are present, references resolve.

It says precisely nothing about whether the infrastructure you described is a good idea. A bucket open to the world, a security group admitting 0.0.0.0/0, an IAM policy with Action: "*" — all valid configuration. All pass validate without a murmur.

Run a policy scanner too. tflint, checkov, tfsec — pick one and put it in CI. Same distinction as schema-versus-policy everywhere else: one tool checks the shape, the other checks the idea.

The number to read in a plan is the destroy count

Not the create count. Not the change count.

Plan: 3 to add, 1 to change, 2 to destroy.
Enter fullscreen mode Exit fullscreen mode

And the phrase to search the plan output for is forces replacement.

A replacement is a delete followed by a create. For a stateless resource that is a rolling update. For an RDS instance, an EBS volume or anything else holding data, it is exactly what it sounds like. forces replacement on a stateful resource is the line that should stop the review.

This is the single highest-value habit in working with generated Terraform, and it takes about four seconds.

count versus for_each: the one that destroys things quietly

count    = length(var.names)   # positional
for_each = toset(var.names)    # keyed
Enter fullscreen mode Exit fullscreen mode

With count, resources are tracked by index. Remove the second item from a three-item list and Terraform does not "delete the second one" — it re-indexes. Item three shifts into slot two. The plan shows a modify of slot two and a destroy of slot three.

With for_each, resources are tracked by key. Remove an item and exactly that item is destroyed.

Use for_each for collections. Reserve count for genuine on/off conditionals. Copilot reaches for count roughly every time, because count is older and more of the corpus uses it.

Validation blocks are free and nobody writes them

variable "instance_count" {
  type        = number
  description = "Number of application instances."

  validation {
    condition     = var.instance_count > 0 && var.instance_count <= 20
    error_message = "instance_count must be between 1 and 20."
  }
}
Enter fullscreen mode Exit fullscreen mode

An unvalidated ranged variable is a typo that reaches apply. This is the kind of boilerplate Copilot writes well and instantly, and it will not write it unprompted — ask for validation blocks on every bounded variable and you get them.

Suppressing a scanner finding: put the comment inside the block

This one is pure trivia until it wastes an afternoon.

resource "aws_s3_bucket" "logs" {
  # checkov:skip=CKV_AWS_18:Access logging would recurse on the log bucket itself
  bucket = "example-logs"
}
Enter fullscreen mode Exit fullscreen mode

Checkov attributes findings to a line range, so a skip comment placed above the resource block is outside the range and is silently ignored. The scan keeps failing, the comment looks right, and you start doubting the tool.

Inside the block. Always with a reason — a bare suppression is a finding somebody decided not to think about.

Credit where due: Checkov's CKV_AWS_41 catches hardcoded credentials in .tf files, which is more than most linters manage for secrets.

Never let a credential into a .tf file

Not as a default, not as a .tfvars committed "temporarily". And remember that a secret referenced anywhere in a configuration ends up in state, in plaintext — so state gets a remote backend with encryption and locking, and *.tfstate goes in .gitignore next to *.tfvars.

The rules worth committing

- Every variable has a type, a description, and a validation block where 
  the value is bounded.
- for_each for collections; count only for on/off conditionals.
- No hardcoded region, account id, or ARN — variables or data sources.
- Secrets come from the secret manager. Never a variable default, never 
  a committed .tfvars.
- Security groups name a source. 0.0.0.0/0 needs a comment justifying it.
Enter fullscreen mode Exit fullscreen mode

Four lines in .github/copilot-instructions.md change every future suggestion in the repository. That is a better return than any amount of prompt refinement, because it supplies context the model did not have rather than asking it to try harder.


Fuller version, including the plan-review checklist and the policy-scanner setup, at GitHub Copilot for Terraform. There is a hands-on lab that walks through writing a validated module without ever running apply, and a starter repository to go with it.

Top comments (0)