Terraform / HCL

Infrastructure as code.

What this is

Infrastructure as code. The BLOCKER tier is almost entirely things that are expensive or irreversible to get wrong in a plan: secrets committed in .tf/.tfvars, public exposure without a justification comment, a resource renamed without a moved {} block (plan shows destroy+create on live data), IAM scoped too broadly, unpinned provider/module sources. The HIGH tier is mostly configuration hygiene that prevents surprise diffs and gaps: count where for_each is correct, missing backup retention or encryption on new stateful resources.

How to onboard it

A repository picks up this stack by onboarding on a profile that includes it. redline init detects the profile from what is in the repository, so in most cases this is automatic:

terminal
$ npx redlinegate init  # detects the profile
$ npx redlinegate init --profile infra-terraform  # or name one

One profile pulls these rules in: infra-terraform.

Once onboarded, your files match this stack when they fit any of these globs:

  • **/*.tf
  • **/*.tfvars
  • **/*.hcl

How to use it — 26 rules

Nothing to run. Once your profile includes Terraform / HCL, redline init renders these rules into your repository's AI tooling and the reviewer applies them on every pull request. When a review comment cites one of these ids, this table is where to look up what it catches and why.

Rule idSeverityCatches
terraform/secrets-in-configBLOCKERSecrets in `.tf`/`.tfvars` — passwords, keys, tokens, connection strings. Reference Secrets Manager / SSM / vault data sources; mark variables `sensitive = true`.
terraform/public-exposureBLOCKERPublic exposure: `0.0.0.0/0` ingress on non-public ports, public S3 bucket ACLs/policies, `publicly_accessible = true` on databases — without an explicit justification comment and approval.
terraform/missing-moved-blockBLOCKERResource rename/move without `moved {}` block — plan shows destroy+create; data loss on stateful resources.
terraform/overly-broad-iamBLOCKEROverly broad IAM: `Action: "*"`, `Resource: "*"`, or `iam:PassRole` unscoped — least privilege, scoped ARNs.
terraform/prevent-destroy-removedBLOCKER`prevent_destroy` removed or lifecycle guards deleted on stateful resources (DBs, buckets, tables) without stated intent.
terraform/unpinned-sourceBLOCKERProvider or module source unpinned — exact version or bounded constraint (`~>`), never floating latest.
terraform/authoritative-iam-resourceBLOCKERAuthoritative IAM resource used for an additive grant. `aws_iam_policy_attachment` owns every attachment of that policy across the account, and `google_{project,folder,organization}_iam_policy` replaces the whole IAM policy — either one silently revokes access granted anywhere else, and the Google ones can lock operators out of the project. Use `aws_iam_role_policy_attachment` / `google_project_iam_member`. Not when the resource is the documented sole owner of that policy, with a comment saying so.
terraform/count-vs-for-eachHIGH`count` on identity-bearing resources where `for_each` is correct — index shifts destroy/recreate siblings.
terraform/hardcoded-env-valuesHIGHCross-environment values hardcoded in modules (account IDs, ARNs, CIDRs) — pass as variables.
terraform/missing-required-tagsHIGHMissing required tags on new resources (owner, cost-centre, environment — per org tagging policy).
terraform/wide-open-egressHIGHWide-open egress added without comment.
terraform/ambiguous-data-sourceHIGHData sources fetching by name/tag that may match multiple resources — brittle; use IDs where stable.
terraform/missing-backup-retentionHIGHNew stateful resource without backup/retention configuration (RDS retention, S3 versioning, DynamoDB PITR).
terraform/missing-encryptionHIGHEncryption not explicit on new data stores (at-rest KMS, in-transit enforced).
terraform/empty-list-equalityHIGHComparing a collection to `[]`. `var.x == []` is always false and `var.x != []` always true — `[]` is an empty tuple and `==` needs identical types — so `count = var.subnets == [] ? 0 : 1` creates the resource whether or not the list is empty. Use `length(var.x) == 0`.
terraform/ignore-changes-allHIGH`lifecycle { ignore_changes = all }`. Terraform stops proposing any update to the resource, so a later change to its arguments merges, plans as "No changes" and is never applied. List only the attributes something outside Terraform genuinely owns, with a comment naming that owner.
terraform/sg-inline-and-standalone-rulesHIGHA security group managed both inline and by standalone rule resources. Inline `ingress`/`egress` blocks re-assert their set on every apply and delete rules added by `aws_vpc_security_group_*_rule`/`aws_security_group_rule` for the same group, so the plan never converges and connectivity flaps. Pick one; new code uses the standalone rule resources only.
terraform/depends-on-instead-of-referenceHIGH`depends_on` where an attribute reference would express the dependency — above all on a `module`. It makes everything downstream "known after apply", so every plan shows updates or replacements and reviewers learn to ignore the diff. Reference the upstream attribute; keep `depends_on` for a genuinely hidden dependency, with a comment naming it.
terraform/remote-state-couplingHIGHA new `terraform_remote_state` consumer. It needs read access to the producer's whole state snapshot, secrets included, and breaks when the producer renames an output or moves its backend key. Publish the value deliberately (SSM parameter, `tfe_outputs`, a data source looked up by id) and read that.
terraform/provider-in-shared-moduleHIGHA `provider` block inside a reusable child module. Callers can no longer use `count`/`for_each`/`depends_on` on it, and removing the module call removes the provider its resources need to be destroyed, leaving orphaned infrastructure. Declare `required_providers` (with `configuration_aliases` if needed) and pass providers in from the root.
terraform/ignored-input-variableHIGHA module variable declared but never referenced. The caller sets it, Terraform accepts it, and nothing changes — `enable_versioning = true` that no resource reads leaves versioning off with no error. Wire it in or delete it.
terraform/map-duplicate-keysHIGHA duplicate key in a map or object literal. Terraform keeps the last value without an error, so `tags = { Environment = "prod", …, Environment = "dev" }` labels production `dev` and environment-scoped policies act on the wrong set. Remove the duplicate.
terraform/variable-metadataSUGGESTIONVariables missing `description` and `type`.
terraform/output-metadataSUGGESTIONOutputs missing `description`; sensitive outputs not marked `sensitive`.
terraform/prefer-jsonencodeSUGGESTIONInline JSON policies over `jsonencode()`/data-source policy documents.
terraform/repeated-literalSUGGESTIONRepeated literal in 3+ places — promote to local.

Expected output

A finding from this file, and every finding Redline produces, opens with a machine-readable first line — severity, then the rule id in brackets, then the problem in one line:

a finding from this file
Redline/BLOCKER [terraform/secrets-in-config]: <one-line problem>

Ids are aggregated per rule, which is how the organisation finds out which rules earn their place and which only generate noise — so a finding without a valid id cannot be measured and counts as untagged. Of the 26 rules here, 7 BLOCKER, 15 HIGH and 4 SUGGESTION. Only a BLOCKER must not merge; a SUGGESTION may be dismissed without justification, and is never upgraded to get attention.

If a rule here fires constantly on code your team has deliberately decided to allow, that is the signal to raise with the standards owner — the rule id is what makes that conversation measurable — not to argue it away comment by comment.

How to edit it

Rules are edited in standards/stacks/terraform.md and nowhere else. The rendered copies in AGENTS.md, .github/copilot-instructions.md and .github/instructions/ are generated and are overwritten by the next render. A change here propagates to every onboarded repository as a pull request, so treat it as a production change.

  1. Edit the markdownstandards/ is the only place a human edits a rule. Everything under AGENTS.md, .github/copilot-instructions.md and .github/instructions/ is rendered from it and is overwritten by the next render.
  2. node scripts/assign-rule-ids.mjsAssigns a permanent <stack>/<slug> id to any new rule bullet and rewrites the file in place. Do not invent an id by hand. CI runs the same script with --check and fails if a rule is missing one.
  3. node scripts/render-self.mjsRe-renders this repository's own artifacts from the edited source. CI runs it with --check, so stale checked-in output fails the build.
  4. Bump standards/manifest.json → versionRequired in the same pull request as the rule change. Sync pull requests quote the version, so a repository's rendered artifacts always name where they came from.
  5. Add a CHANGELOG.md entryAlso in the same pull request. A standards change with no measurement is an opinion — record the seed score alongside it.
  6. node scripts/validate.mjsThe bundle self-check CI runs: manifest integrity, well-formed rule ids, the severity output contract surviving your edit, glob portability.

Rule ids are permanent. Rewording a rule is fine and keeps its id; renaming or removing an id orphans every historical telemetry record that cited it.

The full file

standards/stacks/terraform.md · 62 lines · 5.7 KB
# Terraform / HCL Review Rules

## BLOCKER — request changes

- `terraform/secrets-in-config` — **Secrets in `.tf`/`.tfvars`** — passwords, keys, tokens, connection strings. Reference Secrets Manager / SSM / vault data sources; mark variables `sensitive = true`.
- `terraform/public-exposure` — **Public exposure**: `0.0.0.0/0` ingress on non-public ports, public S3 bucket ACLs/policies, `publicly_accessible = true` on databases — without an explicit justification comment and approval.
- `terraform/missing-moved-block` — **Resource rename/move without `moved {}` block** — plan shows destroy+create; data loss on stateful resources.
- `terraform/overly-broad-iam` — **Overly broad IAM**: `Action: "*"`, `Resource: "*"`, or `iam:PassRole` unscoped — least privilege, scoped ARNs.
- `terraform/prevent-destroy-removed` — **`prevent_destroy` removed** or lifecycle guards deleted on stateful resources (DBs, buckets, tables) without stated intent.
- `terraform/unpinned-source` — **Provider or module source unpinned** — exact version or bounded constraint (`~>`), never floating latest.
- `terraform/authoritative-iam-resource` — **Authoritative IAM resource used for an additive grant.**
  `aws_iam_policy_attachment` owns every attachment of that policy across the account, and
  `google_{project,folder,organization}_iam_policy` replaces the whole IAM policy — either one silently revokes
  access granted anywhere else, and the Google ones can lock operators out of the project. Use
  `aws_iam_role_policy_attachment` / `google_project_iam_member`. Not when the resource is the documented sole
  owner of that policy, with a comment saying so.

## HIGH

- `terraform/count-vs-for-each` — `count` on identity-bearing resources where `for_each` is correct — index shifts destroy/recreate siblings.
- `terraform/hardcoded-env-values` — Cross-environment values hardcoded in modules (account IDs, ARNs, CIDRs) — pass as variables.
- `terraform/missing-required-tags` — Missing required tags on new resources (owner, cost-centre, environment — per org tagging policy).
- `terraform/wide-open-egress` — Wide-open egress added without comment.
- `terraform/ambiguous-data-source` — Data sources fetching by name/tag that may match multiple resources — brittle; use IDs where stable.
- `terraform/missing-backup-retention` — New stateful resource without backup/retention configuration (RDS retention, S3 versioning, DynamoDB PITR).
- `terraform/missing-encryption` — Encryption not explicit on new data stores (at-rest KMS, in-transit enforced).
- `terraform/empty-list-equality` — **Comparing a collection to `[]`.** `var.x == []` is always false and `var.x
  != []` always true — `[]` is an empty tuple and `==` needs identical types — so `count = var.subnets == [] ? 0
  : 1` creates the resource whether or not the list is empty. Use `length(var.x) == 0`.
- `terraform/ignore-changes-all` — **`lifecycle { ignore_changes = all }`.** Terraform stops proposing any
  update to the resource, so a later change to its arguments merges, plans as "No changes" and is never applied.
  List only the attributes something outside Terraform genuinely owns, with a comment naming that owner.
- `terraform/sg-inline-and-standalone-rules` — **A security group managed both inline and by standalone rule
  resources.** Inline `ingress`/`egress` blocks re-assert their set on every apply and delete rules added by
  `aws_vpc_security_group_*_rule`/`aws_security_group_rule` for the same group, so the plan never converges and
  connectivity flaps. Pick one; new code uses the standalone rule resources only.
- `terraform/depends-on-instead-of-reference` — **`depends_on` where an attribute reference would express the
  dependency** — above all on a `module`. It makes everything downstream "known after apply", so every plan
  shows updates or replacements and reviewers learn to ignore the diff. Reference the upstream attribute; keep
  `depends_on` for a genuinely hidden dependency, with a comment naming it.
- `terraform/remote-state-coupling` — **A new `terraform_remote_state` consumer.** It needs read access to the
  producer's whole state snapshot, secrets included, and breaks when the producer renames an output or moves its
  backend key. Publish the value deliberately (SSM parameter, `tfe_outputs`, a data source looked up by id) and
  read that.
- `terraform/provider-in-shared-module` — **A `provider` block inside a reusable child module.** Callers can no
  longer use `count`/`for_each`/`depends_on` on it, and removing the module call removes the provider its
  resources need to be destroyed, leaving orphaned infrastructure. Declare `required_providers` (with
  `configuration_aliases` if needed) and pass providers in from the root.
- `terraform/ignored-input-variable` — **A module variable declared but never referenced.** The caller sets it,
  Terraform accepts it, and nothing changes — `enable_versioning = true` that no resource reads leaves
  versioning off with no error. Wire it in or delete it.
- `terraform/map-duplicate-keys` — **A duplicate key in a map or object literal.** Terraform keeps the last
  value without an error, so `tags = { Environment = "prod", …, Environment = "dev" }` labels production `dev`
  and environment-scoped policies act on the wrong set. Remove the duplicate.

## SUGGESTION

- `terraform/variable-metadata` — Variables missing `description` and `type`.
- `terraform/output-metadata` — Outputs missing `description`; sensitive outputs not marked `sensitive`.
- `terraform/prefer-jsonencode` — Inline JSON policies over `jsonencode()`/data-source policy documents.
- `terraform/repeated-literal` — Repeated literal in 3+ places — promote to local.