Worked review 2: a multi-environment Terragrunt live tree
The feature
Section titled “The feature”The prompt was: “Set up a Terragrunt live tree with dev and prod environments, each with a network unit and an app unit that depends on it.” What follows is the kind of first draft an AI assistant hands back for exactly that prompt — a shared root.hcl, four terragrunt.hcl units that all look individually reasonable, and a CI snippet that reads like every Terragrunt tutorial published before the 1.1 CLI redesign. It has 8 real bugs; check the AI bug catalog for the general pattern behind each one. Seven of these eight are identical, provider-agnostic Terragrunt-core mechanics to both sibling reviews’ worked review 2 — proven fresh here, in this repo, against google — plus one genuinely GCS-specific backend bug in place of AWS’s dynamodb_table/use_lockfile pair and azurerm’s missing use_azuread_auth. Every claim below was proven with real terragrunt hcl validate/render/run --all/find --dag against Terragrunt 1.1.0 and Terraform 1.16.4, with backend = "local" standing in for the real GCS backend — per this course’s own convention, offline proof never touches real GCP.
The generated code
Section titled “The generated code”# root.hcl, as generated -- realistic GCS backend (see review walk for why the proof tree below uses backend = "local" instead)remote_state { backend = "gcs"
generate = { path = "backend.tf" if_exists = "overwrite" }
config = { bucket = "acme-terraform-state" project = "acme-prod-123456" location = "US" }}# live tree, as generated -- live/dev/network/terragrunt.hcl and live/prod/network/terragrunt.hcl (byte-identical)include "root" { path = find_in_parent_folders()}
locals { environment = "dev" project = "acme-dev-123456" region = "us-central1"}
terraform { source = "${get_terragrunt_dir()}/../../../modules/network"}
inputs = { environment = local.environment}# live tree, as generated -- live/dev/app/terragrunt.hcl and live/prod/app/terragrunt.hcl (byte-identical)include "root" { path = find_in_parent_folders()}
dependency "network" { config_path = "../network"}
terraform { source = "${get_terragrunt_dir()}/../../../modules/app"}
inputs = { vpc_id = dependency.network.outputs.vpc_id}# ci/deploy.sh, as generated -- fragment, not a collected fence#!/usr/bin/env bashset -euo pipefail
cd live/devterragrunt run-all plan --terragrunt-non-interactiveterragrunt plan-all --terragrunt-non-interactive --queue-include-dir networkThe review walk
Section titled “The review walk”CI script: run-all/plan-all — removed in the 1.1 CLI redesign
Section titled “CI script: run-all/plan-all — removed in the 1.1 CLI redesign”Symptom — real, run directly (identical mechanics to both sibling reviews, since this is Terragrunt-core, not provider-specific):
$ terragrunt plan-all --non-interactive --no-colorERROR unknown command: "plan-all". Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- plan-all ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command
$ terragrunt run-all plan --non-interactive --no-colorERROR unknown command: "run-all". Terragrunt no longer forwards unknown commands by default. Use 'terragrunt run -- run-all ...' or a supported shortcut. Learn more: https://docs.terragrunt.com/migrate/cli-redesign/#use-the-new-run-command🐛 Spot the bug
terragrunt run-all plan --terragrunt-non-interactiveterragrunt plan-all --terragrunt-non-interactive --queue-include-dir networkShow the answer
Same bug as AI bug catalog entry 16 — both commands are all over pre-1.1 tutorials and AI training data, and both were replaced by the 1.1 CLI redesign’s run --all. Fix: terragrunt run --all --non-interactive -- plan.
CI script: pre-1.1 --terragrunt-*-prefixed flags
Section titled “CI script: pre-1.1 --terragrunt-*-prefixed flags”Symptom — real, run directly against the corrected run --all command:
$ terragrunt run --all --terragrunt-non-interactive --no-color -- planERROR flag `-terragrunt-non-interactive` is not a Terragrunt flag. If this is an OpenTofu/Terraform flag, use `--` to forward it (e.g., `terragrunt run -- <command> -terragrunt-non-interactive`).🐛 Spot the bug
terragrunt run --all --terragrunt-non-interactive --no-color -- planShow the answer
Every Terragrunt flag lost its terragrunt- prefix in the same CLI redesign that replaced run-all. A --terragrunt-*-prefixed flag is rejected outright, not silently ignored — a stale CI script fails its very first run rather than quietly behaving differently. Fix: drop the terragrunt- prefix from every flag.
Every unit: find_in_parent_folders() with no argument, on a root file named root.hcl
Section titled “Every unit: find_in_parent_folders() with no argument, on a root file named root.hcl”# excerpt, as generated -- entries/03-find-in-parent-no-arg/unit/terragrunt.hclinclude "root" { path = find_in_parent_folders()}Symptom — real terragrunt hcl validate across the whole 4-unit tree, all four units failing the same way at once:
$ terragrunt hcl validate --working-dir .Error: Error in function call
on live/dev/network/terragrunt.hcl line 2, in include "root": 2: path = find_in_parent_folders()
Call to function "find_in_parent_folders" failed: ParentFileNotFoundError:Could not find a terragrunt.hcl in any of the parent folders oflive/dev/network/terragrunt.hcl. Cause: Traversed all the way to the root..
The include configuration in live/dev/app/terragrunt.hcl must specify a 'path' parameter... (identical for the other 3 units)4 HCL validation error(s) found🐛 Spot the bug
include "root" {path = find_in_parent_folders()}Show the answer
find_in_parent_folders() called with zero arguments defaults to searching for a file literally named terragrunt.hcl — the pre-1.1 convention. This course’s root config, like most current Terragrunt guidance, is named root.hcl specifically to avoid the file being mistaken for a unit config of its own. Fix: find_in_parent_folders("root.hcl"), naming the actual file.
app units: a dependency block with no mock_outputs at all
Section titled “app units: a dependency block with no mock_outputs at all”# excerpt, as generated -- entries/04-no-mock-outputs/app/terragrunt.hcldependency "network" { config_path = "../network"}Symptom — real terragrunt run --all plan against the whole tree, fresh, network never applied:
$ terragrunt run --all --non-interactive --no-color -- plan❯❯ Run Summary 4 units Succeeded 2 Failed 2
ERROR ... resolving dependency "network" outputs: live/dev/network/terragrunt.hcl isa dependency of live/dev/app/terragrunt.hcl but detected no outputs. Either the targetmodule has not been applied yet, or the module has no outputs.... There is no variable named "dependency".🐛 Spot the bug
dependency "network" {config_path = "../network"}Show the answer
See Dependencies Between Units for the full mechanism — this is that lesson’s mock_outputs_allowed_terraform_commands guardrail with the underlying problem one step earlier: no mock_outputs declared at all. Fix: add mock_outputs = { vpc_id = "mock-vpc-id" } and mock_outputs_allowed_terraform_commands = ["plan"].
network units: dev and prod carrying byte-identical locals
Section titled “network units: dev and prod carrying byte-identical locals”Symptom — real, on the actual generated files:
$ diff live/dev/network/terragrunt.hcl live/prod/network/terragrunt.hcl(no output -- the files are byte-identical)Both files declare environment = "dev", project = "acme-dev-123456" — including inside live/prod/network/terragrunt.hcl.
🐛 Spot the bug
locals {environment = "dev"project = "acme-dev-123456"}Show the answer
Same bug as AI bug catalog entry 17 — terragrunt hcl validate/hcl format --check both pass; nothing about this is invalid HCL, and with backend = "local" for this course’s offline proof nothing fails at plan either. Against a real GCS backend targeting project = "acme-dev-123456" from both dev and prod, this deploys prod into dev’s GCP project, silently. diff on the two files is the only thing that catches a copy-paste this clean. Fix: move the values that must differ per environment into a per-environment env.hcl, and have each network/terragrunt.hcl become identical to its sibling, reading include.env.locals.* instead of its own hardcoded locals.
A second include for shared values, declared without expose = true
Section titled “A second include for shared values, declared without expose = true”# excerpt, as generated -- entries/06-include-no-expose/unit/terragrunt.hclinclude "env" { path = find_in_parent_folders("env.hcl") merge_strategy = "no_merge"}
inputs = { project = include.env.locals.project}Symptom — real terragrunt render:
$ terragrunt render --format jsonError: Unsupported attribute
on unit/terragrunt.hcl line 15: 15: project = include.env.locals.project
This object does not have an attribute named "env".🐛 Spot the bug
include "env" {path = find_in_parent_folders("env.hcl")merge_strategy = "no_merge"}Show the answer
Same mechanism as Dry Configuration with Include — expose = true is what makes an include’s parsed content readable as include.NAME.*. Fix: add expose = true to the env include block.
A module reads another environment’s state directly with terraform_remote_state
Section titled “A module reads another environment’s state directly with terraform_remote_state”# entries/07-remote-state-cross-env/main.tf -- @expect-error No stored state was found for the given workspace in the given backend.data "terraform_remote_state" "prod_network" { backend = "local" config = { path = "../../../prod/network/terraform.tfstate" }}Symptom — real terraform plan:
Error: Unable to find remote state
with data.terraform_remote_state.prod_network, on main.tf line 1, in data "terraform_remote_state" "prod_network": 1: data "terraform_remote_state" "prod_network" {
No stored state was found for the given workspace in the given backend.🐛 Spot the bug
data "terraform_remote_state" "prod_network" {backend = "local"config = { path = "../../../prod/network/terraform.tfstate" }}Show the answer
Same mechanism as AI bug catalog entry 6, applied to exactly the shape a Terragrunt live tree tempts an AI assistant into: reaching for a raw Terraform terraform_remote_state data source instead of a Terragrunt dependency block. This bypasses mock_outputs entirely and has no run --all ordering awareness at all. Fix: a proper cross-environment dependency block, or delete the reference entirely.
root.hcl: GCS remote_state with no prefix — two units silently share one state object
Section titled “root.hcl: GCS remote_state with no prefix — two units silently share one state object”# root.hcl (excerpt)remote_state { backend = "gcs" config = { bucket = "acme-terraform-state" project = "acme-prod-123456" location = "US" }}Symptom — real, terragrunt run -- init -backend=false run against this exact root.hcl from two different units, network and app (this course’s convention for proving generate output — no real GCS bucket needed, only the generated backend.tf content itself):
$ cd network && terragrunt run --non-interactive --no-color -- init -backend=false && cat backend.tf# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEaterraform { backend "gcs" { bucket = "acme-terraform-state" }}
$ cd ../app && terragrunt run --non-interactive --no-color -- init -backend=false && cat backend.tf# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEaterraform { backend "gcs" { bucket = "acme-terraform-state" }}🐛 Spot the bug
remote_state {backend = "gcs"config = { bucket = "acme-terraform-state" project = "acme-prod-123456" location = "US"}}Show the answer
Both generated backend.tf files are byte-identical, proven above with two real, separate init -backend=false runs — bucket = "acme-terraform-state" and nothing else. project/location control bucket auto-creation, not the backend block’s own state addressing, and no prefix was ever set to give each unit its own path the way path_relative_to_include() does for key (AWS) or key (azurerm). Without it, network and app silently target the exact same state object at the bucket root — the second unit to run apply would read, and overwrite, the first unit’s state. Fix: add prefix = "${path_relative_to_include()}/terraform.tfstate" to remote_state.config, exactly like the sibling reviews’ key.
The corrected code
Section titled “The corrected code”remote_state { backend = "local" generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}variable "environment" { type = string}resource "terraform_data" "vpc" { input = "vpc-${var.environment}"}output "vpc_id" { value = terraform_data.vpc.id}variable "vpc_id" { type = string}resource "terraform_data" "app" { input = var.vpc_id}locals { environment = "dev" project = "acme-dev-123456" region = "us-central1"}locals { environment = "prod" project = "acme-prod-654321" region = "us-central1"}include "root" { path = find_in_parent_folders("root.hcl")}
include "env" { path = find_in_parent_folders("env.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "${get_terragrunt_dir()}/../../../modules/network"}
inputs = { environment = include.env.locals.environment}include "root" { path = find_in_parent_folders("root.hcl")}
include "env" { path = find_in_parent_folders("env.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "${get_terragrunt_dir()}/../../../modules/network"}
inputs = { environment = include.env.locals.environment}include "root" { path = find_in_parent_folders("root.hcl")}
dependency "network" { config_path = "../network"
mock_outputs = { vpc_id = "mock-vpc-id" } mock_outputs_allowed_terraform_commands = ["plan"]}
terraform { source = "${get_terragrunt_dir()}/../../../modules/app"}
inputs = { vpc_id = dependency.network.outputs.vpc_id}include "root" { path = find_in_parent_folders("root.hcl")}
dependency "network" { config_path = "../network"
mock_outputs = { vpc_id = "mock-vpc-id" } mock_outputs_allowed_terraform_commands = ["plan"]}
terraform { source = "${get_terragrunt_dir()}/../../../modules/app"}
inputs = { vpc_id = dependency.network.outputs.vpc_id}Real proof, the whole tree at once:
$ terragrunt hcl validate --working-dir .(no output -- 0 errors)
$ diff live/dev/network/terragrunt.hcl live/prod/network/terragrunt.hcl(no output -- now correctly identical; env.hcl differs instead)
$ terragrunt run --all --non-interactive --no-color --working-dir . -- plan❯❯ Run Summary 4 units Succeeded 4
$ terragrunt find --dag --working-dir .live/dev/networklive/prod/networklive/dev/applive/prod/appWhat changed and why
Section titled “What changed and why”| # | Bug | Fix |
|---|---|---|
| 1 | run-all/plan-all in the CI script | run --all — real “unknown command” rejection for both, before the fix |
| 2 | --terragrunt-non-interactive and other pre-1.1-prefixed flags | Bare --non-interactive etc. — real “not a Terragrunt flag” rejection before the fix |
| 3 | find_in_parent_folders() with no argument, root file named root.hcl | find_in_parent_folders("root.hcl") — real “Could not find a terragrunt.hcl” across all 4 units before the fix |
| 4 | dependency "network" with no mock_outputs at all | mock_outputs + mock_outputs_allowed_terraform_commands = ["plan"] — real run --all plan failure on a fresh environment before the fix |
| 5 | dev and prod network units with byte-identical locals (project included) | Values that must differ moved to env.hcl per environment; the unit files become identical instead — real diff proved the bug and the fix |
| 6 | A second include "env" with no expose = true | expose = true added — real “This object does not have an attribute named env” before the fix |
| 7 | terraform_remote_state reading another environment’s state directly from inside a module | Replaced with a proper dependency block (or removed) — real “No stored state was found” before the fix |
| 8 | root.hcl GCS remote_state with no prefix | prefix = "${path_relative_to_include()}/terraform.tfstate" added — proven by two real, separate units generating byte-identical backend.tf (a real state-collision risk) |
Mental Model
Section titled “Mental Model”Four of these eight bugs (1, 2, 3, 6) are hard, immediate failures at the CLI/HCL-evaluation layer, identical in mechanism to both sibling reviews. One (4) is a hard failure but only on the very first run --all plan against a genuinely fresh environment. One (7) is a hard failure specific to state that was never applied, at the Terraform layer. The remaining two (5, 8) never error at all — and entry 8 is the one bug in this whole lesson that is genuinely GCS-specific: instead of a deprecated dynamodb_table argument (AWS) or a backend silently missing identity-based auth (azurerm), the equivalent silent gap here is two structurally different units generating the exact same backend configuration — a real state-collision risk that hcl validate has no way to notice, since both generated files are individually well-formed.
Common Misconceptions
Section titled “Common Misconceptions”- “If
terragrunt hcl validatepasses, a multi-environment tree is actually safe to deploy.” Proven above:hcl validateon the two byte-identicalnetworkunits returns zero errors — HCL validation has no concept of “this value should differ from that sibling file,” and the same is true of two units generating an identicalbackend.tf. - “A
dependencyblock withoutmock_outputsonly breaksapply, neverplan.” Proven above: on a completely fresh environment where the dependency has never been applied even once,run --all planitself fails. - “
project/locationin a GCSremote_state.configscope state per unit, the waykey/prefixdo elsewhere.” Proven above: two structurally different units (network,app) generated byte-identicalbackend.tffiles differing only in nothing at all —project/locationcontrol bucket auto-creation, never state addressing; onlyprefixdoes that, and it was never set.
flowchart TD CI["ci/deploy.sh"] -->|"1: run-all/plan-all removed"| CI CI -->|"2: --terragrunt-* flags rejected"| CI CI --> H["terragrunt hcl validate"] H -->|"3: find_in_parent_folders() no arg"| H H -->|"6: include without expose"| H H --> R["terragrunt run --all plan"] R -->|"4: dependency, no mock_outputs -- fresh env"| R R --> M["module-level terraform plan"] M -->|"7: terraform_remote_state cross-env"| M D["diff dev vs prod files"] -->|"5: byte-identical locals"| D L["root.hcl remote_state, generated backend.tf"] -->|"8: no prefix -- state collision"| L