Playbook library
Terraform & infrastructure as code
Terraform review has an asymmetry no application code has: the cost of missing one line is a destroyed volume or a database open to the internet. Generated HCL is fluent and plausible, and the dangerous parts — a missing backend, an unpinned module, an open ingress CIDR — look exactly like the safe parts.
- Written for
- Platform and infrastructure teams reviewing Terraform or OpenTofu pull requests.
- Not covered
- These rules read the configuration, not the plan output and not your cloud account. They cannot tell you whether the change is safe against the state file that already exists — that is what a reviewed plan is for.
10 rules6 blocking2 warning2 info
01 Remote, locked, encrypted state
errorEvery root module declares a remote backend with state locking and encryption at rest. A root module with no `backend` block, or with `local`, is rejected. Local state means two concurrent applies overwrite each other's view of reality, and the loser's resources become orphans nobody can find.
Blocks: state corruption is the one Terraform failure with no clean recovery.
Scope**/backend.tf**/main.tfenvironments/**
Tagsterraformstate
02 Pin every provider and module
error`required_providers` pins with `~>` or `=`, and every `module` block pins a registry `version` or a git commit SHA. `ref = "main"` and unconstrained providers are rejected. An unpinned upgrade turns an unrelated one-line PR into an unplanned migration, discovered at apply time in whichever environment ran first.
Blocks: it makes every future apply non-reproducible, including the rollback.
Scope**/*.tf
Tagsterraformreproducibility
03 No secrets in variables, locals or defaults
errorSecrets come from a secret-manager data source or a provider-native reference. A literal credential, or a `variable` with a secret `default`, is rejected. Variables that carry secrets are declared `sensitive = true`. Values land in the state file and in plan output — and plan output is routinely posted as a PR comment.
Blocks: the secret is disclosed to everyone with repository or state access.
Scope**/*.tf**/*.tfvars
Tagsterraformsecuritysecrets
04 No `local-exec` or `remote-exec` provisioners
errorResources do not carry `provisioner` blocks. Use a startup script, a baked image, or a real configuration tool instead. A provisioner runs only at create time, is invisible to `plan`, and a failing one taints the resource — so the next apply destroys and recreates it, which on a database or a disk means the data, not just the resource.
Blocks: a tainted stateful resource is destroyed on the next apply, and nothing in the plan announced it.
Scope**/*.tf
Tagsterraformsafetylifecycle
05 Stateful resources keep deletion protection
errorDatabases, buckets holding customer data, and persistent disks set the provider's deletion protection (`deletion_protection`, `prevent_destroy`, retention policy) and a backup retention greater than zero. Removing or disabling it in the same PR as another change is rejected — it ships as its own PR, deliberately.
Blocks: this flag is the last thing standing between a bad plan and permanent loss.
Scope**/*.tf
Tagsterraformsafetydata
06 No open ingress without a stated reason
errorSecurity group, firewall and network ACL rules must not allow `0.0.0.0/0` or `::/0` on anything other than 80 and 443, and must not attach a public IP to a database or cache. If an exception is genuinely required, a comment directly above the rule names the reason and the owner. Port 22 and 5432 open to the world is how databases end up in a public scan index within hours.
Blocks: exposure is immediate and internet-wide the moment it applies.
Scope**/*.tf
Tagsterraformsecuritynetwork
07 Encryption and public-access blocks are explicit
warningBuckets and volumes declare encryption at rest and block public access explicitly, even when the provider default already does it. The default varies by provider, region and account creation date — the explicit block is what stays true after a migration and what an auditor can actually read.
Warns: usually redundant with a modern default, so blocking would be noise — but the explicit line is what survives.
Scope**/*.tf
Tagsterraformsecuritycompliance
08 Use `for_each`, not `count`, for named collections
warningCollections of distinct resources use `for_each` with a stable key. `count` over a list is rejected wherever the elements have identity. Removing one element renumbers every index after it, so Terraform destroys and recreates resources that did not change — which is invisible in the diff and obvious only in the plan.
Warns: it is a correctness trap, but existing `count` usage is expensive to migrate.
Scope**/*.tf
Tagsterraformcorrectness
09 Variables declare type, description and validation
infoEvery `variable` block has a `type` and a `description`, plus a `validation` block for enumerations, CIDRs and size limits. An untyped or unvalidated variable fails at apply time, halfway through a change, instead of at plan time.
Informational: it improves the failure mode rather than preventing the failure.
Scope**/variables.tf**/*.tf
Tagsterraformmaintainability
10 Names and tags follow the convention
infoResource names include environment and workload (`{env}-{service}-{role}`) and every resource inherits the shared tag set (owner, service, cost-center) via `default_tags` or `local.tags`. Untagged resources are unattributable on the bill and unfindable during an incident, and nobody ever goes back to tag them.
Informational: pure hygiene, but the kind that compounds across hundreds of resources.
Scope**/*.tf
Tagsterraformfinopsconventions
Before you import all of it
Severity is the lever that decides whether people keep the gate on. Only error blocks a merge — bring these in as warnings first, look at what actually fires on a few weeks of real pull requests, and promote the rules that earned it. And rewrite the scope globs: the paths above are examples, and a rule only runs against files its globs match.
Rules go into Normik one at a time — through the playbook form, or by letting a coding agent propose them over MCP from an existing repository. There is no bulk markdown import: the copy button above is for your AGENTS.md, for a PR where the team argues about the list, and to keep beside you while you enter the rules you actually kept.
Every rule here is judged the way Normik judges it: on the lines the pull request adds. Nothing in this playbook depends on the PR description, on a file the diff does not touch, or on the history of a file — those are real review standards, but a gate that cannot see the evidence would either stay silent or guess.
The anatomy of a rule, how severity and globs behave, versioning and the AGENTS.md export are all in the playbook documentation.