Skip to content

How to read Terraform you did not write

Every earlier module in this course taught you to write Terraform and Terragrunt. This module teaches the other half: reading configuration someone — or something — else wrote, and deciding whether it is actually correct. An AI assistant hands you a module, a terragrunt.hcl, a CI pipeline that looks exactly like the examples you have been studying, uses resource types and arguments that read as fluent and current, and — most of the time — terraform validate and terraform plan both come back clean. That last part is the trap this module exists to close: of the 23 patterns the next lesson catalogs, only a minority fail validate/plan/terragrunt outright. The rest are schema-valid, plan-clean configuration that fails later — at a real apply against a real cloud account, at the next plan after someone edits an unrelated list, or never, while quietly doing the wrong thing (protecting the wrong resource, deploying prod against dev’s account, shipping a secret nobody reviewed).

Three training-data problems compound this. First, Terraform’s provider ecosystem genuinely changes shape release over release — aws_s3_bucket.acl was the way to set an ACL for years and is now deprecated in favor of aws_s3_bucket_acl, terragrunt run-all/plan-all were the only commands that existed before Terragrunt 1.1’s CLI redesign — and a huge fraction of public training data predates each change while still reading as completely fluent, current-looking HCL. Second, count is the first iteration construct most Terraform tutorials teach, so AI assistants reach for it by default even where for_each is the only safe choice — a list can be reordered, count cannot tell the difference between “reorder” and “replace.” Third, the fastest way past a permission or destroy error is almost always the broadest fix — -auto-approve genuinely removes the friction, prevent_destroy on some resource genuinely makes an error message go away — at the cost of removing the one safeguard that mattered. The six steps below are the order that actually surfaces these failures, and every later lesson in this module drills one part of it.

flowchart TD
  A["1. Providers &amp; versions<br/>pinned with ~&gt;? lock file committed?"] --> B["2. Backend &amp; state<br/>real locking? remote_state points at applied state?"]
  B --> C["3. Variables &amp; locals<br/>nullable, ephemeral, validation blocks"]
  C --> D["4. Resources &amp; lifecycle<br/>count vs for_each, prevent_destroy on what?"]
  D --> E["5. Modules<br/>provider blocks, source ref, depends_on"]
  E --> F["6. Terragrunt live tree<br/>run --all order, hardcoded inputs per env"]
  F --> G["7. CI &amp; policy<br/>-auto-approve, unpinned actions, Rego v1, real rule IDs"]
Reading order for Terraform/Terragrunt you did not write

Before anything else, check whether the provider constraint actually protects you, and whether the lock file that pins the real resolved version is even committed — because a floating constraint changes what “the same configuration” resolves to on two different days, on two different machines, with zero diff to review.

  • What to look for: ~> or >= in required_providers? Is .terraform.lock.hcl committed, or gitignored alongside .terraform/? Does any resource use an argument the installed provider’s own schema marks deprecated: true?
  • The question to ask: if a teammate ran terraform init today versus a month from now, would they resolve the identical provider version — and would terraform validate -json come back with zero warnings, not just zero errors?
  • Which lesson explains it: the AI Bug Catalog’s Versions & providers group (entries 1–3) for the real .terraform.lock.hcl resolution and the real, schema-sourced deprecation warnings; Installing & Project Structure for required_providers basics.

Once the provider is pinned, check what actually locks and stores state — because terraform_remote_state and a floating backend-locking argument both fail in ways validate cannot see from the consuming configuration alone.

  • What to look for: does the backend’s locking mechanism match what this Terraform/backend version actually supports (S3’s use_lockfile vs the deprecated dynamodb_table)? Does every terraform_remote_state data source point at a key/path that has actually been applied? Is state ever used to separate environments via terraform workspace where a completely separate backend config would be safer?
  • The question to ask: if this exact backend config ran init against a brand-new bucket right now, would locking actually work — and if the state this reads via terraform_remote_state doesn’t exist yet, does the plan fail loud, or does something silently proceed with defaults?
  • Which lesson explains it: the AI Bug Catalog’s State & backend group (entries 4–6) for the real gitignored-lockfile drift and the real “Unable to find remote state” hard-fail; The State File and Why Terragrunt for both mechanisms in full depth.

A variable block that looks like ordinary input validation can silently change what value actually reaches a resource — nullable and ephemeral both affect behavior in ways no error message calls out.

  • What to look for: does a nullable = false variable’s default silently stand in for an explicit null override, with nothing in the plan output calling that out? Does an ephemeral variable ever carry a literal secret default, which still leaks into a saved plan’s show -json output even though it never reaches state?
  • The question to ask: if this exact variable were passed null from a caller, would the effective value actually become null — or silently become the default, with the CLI output identical either way?
  • Which lesson explains it: the AI Bug Catalog’s Variables group (entries 7–8) for the real side-by-side nullable apply output and the real ephemeral-default leak in show -json.

A resource block can be perfectly valid HCL and still be wrong in ways that only show up on the next plan, or the day someone actually needs the lifecycle block to do what its name implies.

  • What to look for: is count used over a list that could ever be reordered, where for_each is the only safe choice? Does lifecycle { prevent_destroy = true } sit on the resource that actually matters, or on something disposable nearby? Does for_each ever derive its keys from another managed resource’s computed attribute? Is a hallucinated argument (copied from a similar-but-different resource type) present anywhere?
  • The question to ask: if the middle item were removed from this exact list right now, would terraform plan show a clean delete of one item — or a cascade of replacements shifted by position? And if this resource were destroyed right now, does prevent_destroy actually block it, or only look like it does from a different resource’s error message?
  • Which lesson explains it: the AI Bug Catalog’s Resources & lifecycle group (entries 9–13) for the real plan -destroy proof that prevent_destroy protects only the resource it’s declared on, and the real count index-shift plan.

A module call can hide a provider configuration or an unpinned source that only becomes a problem the moment a caller needs count, for_each, or a second environment.

  • What to look for: does a reusable module declare its own provider block, making it incompatible with count/for_each/depends_on on the calling side? Does a git::/registry source omit ?ref=/a version constraint, floating on the default branch? Does a depends_on on a module call exist to paper over what’s actually a genuine reference cycle?
  • The question to ask: does this module’s source resolve to the exact same commit/version every time it’s initialized, on any machine, on any day — and if a caller needed to loop this module with count, would that even work?
  • Which lesson explains it: the AI Bug Catalog’s Modules group (entries 14–15) for the real “Module is incompatible with count, for_each, and depends_on” error and the real reference-cycle error no depends_on can suppress; Writing a Reusable Module and Module Composition & Sources for both mechanisms in full.

terragrunt.hcl files are copy-pasted between environments constantly — checking whether that copy actually changed the values that matter is the single highest-leverage read in this whole module.

  • What to look for: does a command AI wrote use run-all/plan-all — both removed in the Terragrunt 1.1 CLI redesign? Do dev and prod (or any two environments) terragrunt.hcl files carry the identical account_id/region/subscription/project value in their inputs, where a real difference is required?
  • The question to ask: does terragrunt hcl validate passing on both environment files actually prove they deploy against different accounts — or only that both are syntactically valid HCL?
  • Which lesson explains it: the AI Bug Catalog’s Terragrunt group (entries 16–17) for the real “unknown command” rejection and the real byte-identical dev/prod copy-paste that hcl validate cannot catch; Terragrunt HCL & the Root Config and The Multi-Environment Problem for the mechanism proven in full.

Last, check whether the pipeline that actually applies this configuration has a real approval gate, a real policy check, and dependencies it can trust — because every earlier reading step can be perfect and still ship through a pipeline that removes the one safeguard that mattered.

  • What to look for: does -auto-approve run against a fresh plan rather than a saved, reviewed one? Is a third-party GitHub Action pinned to a commit SHA, or floating on @master/@main? Does a Rego policy use pre-v1 deny[msg] { ... } syntax that fails to even load under current OPA/conftest? Does a .tflint.hcl/checkov config reference a rule ID that doesn’t actually exist in that linter’s real rule catalog?
  • The question to ask: if this exact pipeline ran right now, is there a point between “plan” and “real infrastructure changes” where a human or a policy gate could actually stop it — and does every check in this pipeline provably run at all, or does one silently do nothing?
  • Which lesson explains it: the AI Bug Catalog’s CI & policy group (entries 18–21) for the real side-by-side -auto-approve proof, the real Rego v1 parse error, and the real tflint run where an invented rule name silently checked nothing; Verification Tools for every tool in this list run for real.
  • The AI Bug Catalog — 23 specific patterns, each with the config AI typically writes, the real validate/plan/test/terragrunt/tflint/checkov/conftest output it produces, and the fix.
  • Review Checklist — the same 23 patterns as a one-page, eight-group checklist, each line linking the lesson that proves it.
  • Verification Tools — what terraform fmt -check, validate, plan -detailed-exitcode, show -json + jq, terraform test with mock_provider, tflint, checkov, conftest, terragrunt hcl validate/render/run --all plan, and this course’s own harness each actually prove.
  • Three worked reviews — Worked Review 1, Worked Review 2, Worked Review 3 — applying this reading order to full, AI-generated configuration sets (a network+compute module, a multi-environment Terragrunt live tree, and a CI pipeline with policy checks). These three lessons are written and maintained separately from this one; if a link below 404s while that work is still in progress, the reading order and the catalog above stand on their own regardless.
Reading order for Terraform/Terragrunt you did not write:
1. Providers & versions → pinned with ~>? lock file committed? any deprecated args?
2. Backend & state → real locking mechanism? does remote_state point at applied state?
3. Variables & locals → does nullable/ephemeral actually behave the way its name implies?
4. Resources & lifecycle → count where for_each belongs? prevent_destroy on what, really?
5. Modules → provider block inside? source pinned? does depends_on hide a cycle?
6. Terragrunt live tree → run --all, not run-all/plan-all? do dev and prod actually differ?
7. CI & policy → -auto-approve on a fresh plan? actions pinned? policy syntax current?
"terraform validate and plan both came back clean" answers almost none of these — most of
this module's 23-entry catalog is schema-valid, plan-clean configuration that is simply wrong.
  • “If validate and plan both succeed, it’s correct.” Proven throughout this module’s catalog: of 23 patterns, only a handful are rejected outright by validate/plan/terragrunt at all. The rest are syntactically and semantically valid HCL that Terraform has no way to flag, because nothing in the language can tell “the wrong resource is protected” from “a resource is protected.”
  • “AI-generated Terraform just needs a newer prompt.” The problem isn’t staleness alone — dynamodb_table and terragrunt run-all are specifically fluent-looking, syntactically valid configuration that happens to target a shape a current provider/CLI version has moved past, sometimes with a warning, sometimes with none at all.
  • “A silent failure is rarer than a rejected apply, so it matters less.” The opposite is closer to true in this catalog: prevent_destroy on the wrong resource, a .tflint.hcl rule name that silently checks nothing, or prod deploying against dev’s account_id all ship with zero errors, zero warnings, and zero indication anything is wrong — until the specific moment something depends on the exact behavior that’s wrong.
  • “-auto-approve is just a CI convenience for skipping a prompt.” It removes the one point in the entire pipeline where a saved, reviewed plan could be checked against reality before anything real changes — the fix is a separate plan -out + policy gate + apply <planfile>, not simply tolerating the flag because CI can’t type yes.
Why does this module check providers & versions before checking anything about resources or modules?
Given this real plan -destroy output for a stack where only a disposable aws_iam_role carries prevent_destroy = true, what does it actually prove about the aws_db_instance in the same stack?
Plan: 0 to add, 0 to change, 2 to destroy.
Why does this module treat 'the pipeline uses -auto-approve, but that's just a CI thing' as a CI & policy reading-order concern rather than routine pipeline hygiene?
Which step in this reading order specifically asks whether a copy-pasted terragrunt.hcl's inputs actually differ between dev and prod?
Why does this module say most of its 23 catalog entries are MORE concerning than the ones validate/plan reject outright, rather than less?