Skip to content

Verification tools

Every tool below was actually run against real Terraform 1.16.4 / Terragrunt 1.1.0 configuration with the pinned hashicorp/aws 6.66.0 provider and the pinned ghcr.io/terraform-linters/tflint, bridgecrew/checkov, and openpolicyagent/conftest Docker images this course uses — never against real AWS (every provider config in this course uses access_key = "mock" + skip_credentials_validation = true, and terraform plan never contacts AWS at all with those set). This is the ladder in the order it actually catches things, cheapest and fastest first.

flowchart TD
  A["fmt -check<br/>formatting only"] --> B["validate<br/>schema + type errors + deprecation warnings"]
  B --> C["plan -detailed-exitcode<br/>real diff against real (mock) provider schema"]
  C --> D["show -json + jq<br/>machine-readable plan, what CI/policy actually reads"]
  D --> E["terraform test + mock_provider<br/>assertions against a plan, no real infra"]
  E --> F["tflint / checkov<br/>real rule catalogs, cloud-specific findings"]
  F --> G["conftest<br/>custom Rego policy against show -json"]
  G --> H["terragrunt hcl validate/render/run --all<br/>the live tree, offline"]
The verification ladder — what each rung catches

terraform fmt -check — formatting, not correctness

Section titled “terraform fmt -check — formatting, not correctness”
$ terraform fmt -check -diff main.tf

Exits non-zero and prints a diff if formatting is off; fixes nothing itself unless run without -check. Catches zero semantic bugs from this module’s catalog — a perfectly formatted file can still hardcode a wrong account ID. It’s the cheapest possible CI gate, and the one this course’s own harness runs first, before anything else, over every fence.

terraform validate — schema, types, and (crucially) warnings

Section titled “terraform validate — schema, types, and (crucially) warnings”
$ terraform validate -json

Catches: entry 3 (module + count incompatibility), entry 11 (for_each on an unknown value), entry 13 (hallucinated argument), entry 15 (reference cycle) — all real, hard failures. Also catches entry 2 (deprecated arguments) — but only as a warning, which a CI step that checks terraform validate; echo $? alone silently ignores. The real, useful CI check greps the JSON:

Terminal window
terraform validate -json | jq -e '[.diagnostics[] | select(.severity=="warning")] | length == 0'

validate never contacts a real provider API — it checks the configuration against the provider’s schema, downloaded once at init, entirely offline.

terraform plan -detailed-exitcode — a real diff, not just a syntax check

Section titled “terraform plan -detailed-exitcode — a real diff, not just a syntax check”
$ terraform plan -detailed-exitcode

Exit code 0 = no changes, 1 = error, 2 = changes present — the exit code CI actually branches on, instead of parsing plan text. With this course’s mock AWS credentials, plan genuinely computes a real diff against the real provider schema for most resource types (proven throughout the AI Bug Catalog — entries 7, 9, 10, 11 are all real plan output, zero AWS network calls). Catches: entry 10’s count index-shift replacement, entry 9’s prevent_destroy scope, entry 7’s nullable substitution — none of which validate alone can see, because they only show up as a diff, not a schema violation.

terraform show -json + jq — what CI and policy tools actually read

Section titled “terraform show -json + jq — what CI and policy tools actually read”
$ terraform plan -out=plan.out && terraform show -json plan.out > plan.json
$ jq '.resource_changes[] | select(.change.actions[0] == "delete") | .address' plan.json

This is the schema conftest/OPA (below) and this course’s own harness read — never plan’s human-readable text. Proven directly in entry 8: an ephemeral variable’s literal default shows up in this exact JSON’s variables block even though it’s correctly kept out of terraform.tfstate. Any secret-shaped string an archived show -json plan artifact might leak is caught here, not in the CLI’s redacted output.

# tests/example.tftest.hcl (fragment — see modules/testing-modules for a full worked example)
mock_provider "aws" {}
run "plan_only" {
command = plan
assert {
condition = aws_instance.app.instance_type == "t3.micro"
error_message = "unexpected instance_type"
}
}

terraform test (1.6+) with mock_provider (1.7+) runs full plan-time assertions with zero real provider calls at all — useful for azurerm/google resources where a real plan needs a real subscription/project (see Testing Modules for aws’s own full worked example, including override_resource). This is the only rung on this ladder that works identically well across all three clouds regardless of what a real plan can reach.

tflint — real, current findings against a real rule catalog

Section titled “tflint — real, current findings against a real rule catalog”
$ docker run --rm -v "$(pwd)":/data -v <plugin-cache>:/root/.tflint.d -w /data ghcr.io/terraform-linters/tflint --init
$ docker run --rm -v "$(pwd)":/data -v <plugin-cache>:/root/.tflint.d -w /data ghcr.io/terraform-linters/tflint

Real output against a bare aws_instance with no required_providers/required_version block:

2 issue(s) found:
Warning: Missing version constraint for provider "aws" in `required_providers` (terraform_required_providers)
Warning: terraform "required_version" attribute is required (terraform_required_version)

Catches genuinely current findings — real rule IDs proven against the pinned tflint-ruleset-aws 0.40.0 docs. Does not catch entry 21 (an invented rule name in .tflint.hcl): tflint doesn’t error on a config referencing a rule that doesn’t exist, it just never applies it, and the run above reports success on everything the config intended to check. --init must persist its downloaded plugin across runs — mount a real volume for ~/.tflint.d, not a fresh one per docker run --rm, or every run re-downloads and a stale cache silently masks plugin-install failures.

$ docker run --rm -v "$(pwd)":/tf bridgecrew/checkov -d /tf --compact --quiet

Real output against a bare aws_s3_bucket:

terraform scan results:
Passed checks: 9, Failed checks: 7, Skipped checks: 0
Check: CKV_AWS_144: "Ensure that S3 bucket has cross-region replication enabled"
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"
Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"
...

Same caveat as tflint: a checkov check ID that doesn’t exist (a common AI-hallucination shape across all three clouds — see this course’s Azure sibling’s CKV_AZURE_59 vs CKV_AZURE_35 catalog entry) is silently never evaluated, not rejected as invalid config.

conftest — custom Rego policy against show -json

Section titled “conftest — custom Rego policy against show -json”
$ docker run --rm -v "$(pwd)":/project openpolicyagent/conftest test --policy policy plan.json

Reads exactly the show -json output from the rung above — proven throughout entry 20’s real FAIL/PASS output. Current conftest defaults to Rego v1 syntax (import rego.v1, contains/if): pre-v1 deny[msg] { ... } fails to even load, with a real rego_parse_error, not a rule that silently never fires.

terragrunt hcl validate / render / run --all plan — the live tree, offline

Section titled “terragrunt hcl validate / render / run --all plan — the live tree, offline”
$ terragrunt hcl validate --working-dir live/prod/vpc # recursive HCL syntax check
$ terragrunt render --format json --working-dir live/dev/vpc # resolved config, offline
$ terragrunt run --all plan --working-dir . # dependency-ordered, offline with mock_outputs

hcl validate only checks that the HCL parses — proven directly in entry 17, it passes on two terragrunt.hcl files that are byte-identical copy-pastes deploying against the wrong account. render resolves every include/local/function call to its final value without ever calling terraform — the fastest way to actually see whether two environments’ resolved inputs differ. run --all plan requires a root.hcl (or a --terragrunt flag on this course’s own harness) and runs every unit in real dependency order — run-all/plan-all (entry 16) are both unknown commands as of the 1.1 CLI redesign; this is the real, current replacement.

This course’s own harness: tools/verify-snippets.mjs

Section titled “This course’s own harness: tools/verify-snippets.mjs”

Every hcl fence in this course whose first line is # <path> ending .tf/.hcl/.tftest.hcl/.tfvars is collected as a real file at that path (relative to its lesson) and run through terraform init -backend=false + validate + fmt -check, plus test/plan/terragrunt hcl validate/render under the --test/--plan/--terragrunt flags. Honestly, two real limitations:

  • -backend=false always. Any backend "s3"/"azurerm"/"gcs" block a lesson declares is never actually initialized — this course never has real backend credentials, so entry 5’s backend-locking claims are docs-sourced, not harness-run (see that entry for the exact citation).
  • azurerm/google cannot plan offline for most resources. This harness always tries terraform plan and pattern-matches known offline-auth-failure phrases to print not possible offline: <reason> instead of a raw stack trace — real for those two providers, not a workaround. aws’s mock credentials (skip_credentials_validation, skip_requesting_account_id, skip_metadata_api_check, skip_region_validation) are what let aws plan proceed fully offline where azurerm/google generally cannot.

A fence whose first line contains @expect-error (used throughout the AI Bug Catalog for every deliberately-broken snippet) is deliberately skipped by this harness — the lesson prose carries the real, already-captured error instead of asking the harness to re-derive it on every run.

The verification ladder, cheapest and fastest first:
1. fmt -check → formatting only, zero semantic coverage
2. validate → schema/type errors + warnings (check warnings explicitly!)
3. plan -detailed-exitcode → a real diff — count/for_each churn, lifecycle scope, nullable substitution
4. show -json + jq → what CI/policy tools actually read, not plan's human text
5. test + mock_provider → plan-time assertions, zero real provider calls, works for any cloud
6. tflint / checkov → real rule catalogs — but silent on an invented rule ID
7. conftest → custom policy against show -json — v1 Rego syntax required
8. terragrunt hcl validate/render/run --all → the live tree, offline, dependency-ordered
Passing every rung proves the configuration is well-formed and internally consistent.
It does not prove prevent_destroy protects the right resource, or that dev and prod
actually deploy against different accounts — those need a human reading the checklist.
  • “terraform validate passing means the config is correct.” It means the config is schema-valid. Proven throughout the catalog: 15 of 23 entries validate and plan cleanly.
  • “A green tflint/checkov run means every intended check actually ran.” Proven directly in entry 21: an invented rule ID produces the identical “success” output as a correctly-named one — the tool has no way to tell “this rule doesn’t exist” from “this rule found nothing to flag.”
  • “-backend=false is just a harness limitation, not a real concern.” It’s also the honest state of most CI validate/plan steps before a real apply stage — which is exactly why entry 5’s backend-locking drift is invisible to validate/plan in real pipelines too, not just this course’s harness.
Why does this lesson say terraform validate passing is not enough on its own, even though it does catch real errors like entry 3's module+count conflict?
Why can aws's terraform plan run fully offline in this course while azurerm/google generally cannot, per the harness's own documented limitation?
What does a real tflint run against a .tflint.hcl referencing a rule name that doesn't exist in the real ruleset actually report?