Skip to content

Worked review 2: a multi-environment Terragrunt live tree

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, a terragrunt.stack.hcl, 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 the AWS sibling review’s worked review 2 — proven fresh here, in this repo, against azurerm — plus one genuinely azurerm-specific backend bug in place of AWS’s dynamodb_table/use_lockfile pair. 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 azurerm backend — per this course’s own convention, offline proof never touches real Azure.

# root.hcl, as generated -- realistic azurerm backend (see review walk for why the proof tree below uses backend = "local" instead)
remote_state {
backend = "azurerm"
generate = {
path = "backend.tf"
if_exists = "overwrite"
}
config = {
resource_group_name = "terraform-state-rg"
storage_account_name = "mycompanytfstate"
container_name = "tfstate"
key = "${path_relative_to_include()}/terraform.tfstate"
}
}
# 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"
subscription_id = "00000000-0000-0000-0000-000000000000"
location = "East US"
}
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 = {
vnet_id = dependency.network.outputs.vnet_id
}
# ci/deploy.sh, as generated -- fragment, not a collected fence
#!/usr/bin/env bash
set -euo pipefail
cd live/dev
terragrunt run-all plan --terragrunt-non-interactive
terragrunt plan-all --terragrunt-non-interactive --queue-include-dir network

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 the AWS sibling review, since this is Terragrunt-core, not provider-specific):

$ terragrunt plan-all --non-interactive --no-color
ERROR 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-color
ERROR 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-interactive
terragrunt plan-all --terragrunt-non-interactive --queue-include-dir network
Show 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. This script never ships silently; it fails on the very first CI run, loudly. 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 -- plan
ERROR 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 -- plan
Show the answer

Every Terragrunt flag lost its terragrunt- prefix in the same CLI redesign that replaced run-all — --non-interactive, --parallelism, --queue-* are all bare now. 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.hcl
include "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 of
live/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
The include configuration in live/dev/network/terragrunt.hcl must specify a 'path' parameter
... (identical for live/prod/network and live/prod/app)
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. The bare, no-argument call genuinely cannot find a root.hcl — it isn’t looking for one. 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.hcl
dependency "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 is
a dependency of live/dev/app/terragrunt.hcl but detected no outputs. Either the target
module 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. network’s own plan succeeds; app’s fails because it needs dependency.network.outputs.vnet_id and there is no real state, nothing to fall back to — exactly the first run --all plan on a genuinely fresh environment. Fix: add mock_outputs = { vnet_id = "mock-vnet-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", subscription_id = "00000000-0000-0000-0000-000000000000" — including inside live/prod/network/terragrunt.hcl.

🐛 Spot the bug

live/prod/network/terragrunt.hcl
locals {
environment = "dev"
subscription_id = "00000000-0000-0000-0000-000000000000"
}
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 azurerm backend and a real subscription_id targeting dev’s subscription from both dev and prod, this deploys prod into dev’s Azure subscription, 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.hcl
include "env" {
path = find_in_parent_folders("env.hcl")
merge_strategy = "no_merge"
}
inputs = {
subscription_id = include.env.locals.subscription_id
}

Symptom — real terragrunt render:

$ terragrunt render --format json
Error: Unsupported attribute
on unit/terragrunt.hcl line 15:
15: subscription_id = include.env.locals.subscription_id
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.*. Without it, the include still merges normally for a root include (which is why root’s remote_state keeps working everywhere in this tree), but a second, non-root include with no expose leaves include.env simply absent as a reference target. 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 pointed at ../../prod/network. Beyond the hard-fail proven here, this bypasses mock_outputs entirely and has no run --all ordering awareness at all — Terragrunt has no idea this Terraform-level read exists. Fix: a proper cross-environment dependency block, or delete the reference entirely.

root.hcl: azurerm remote_state with no use_azuread_auth

Section titled “root.hcl: azurerm remote_state with no use_azuread_auth”
# root.hcl (excerpt)
remote_state {
backend = "azurerm"
config = {
resource_group_name = "terraform-state-rg"
storage_account_name = "mycompanytfstate"
container_name = "tfstate"
}
}

Symptom — real, terragrunt run -- init -backend=false against this exact root.hcl (this course’s convention for proving generate output — no real Azure Storage account needed, only the generated backend.tf content itself):

$ cat backend.tf
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "azurerm" {
container_name = "tfstate"
key = "vnet/terraform.tfstate"
resource_group_name = "terraform-state-rg"
storage_account_name = "mycompanytfstate"
}
}

🐛 Spot the bug

remote_state {
backend = "azurerm"
config = {
resource_group_name = "terraform-state-rg"
storage_account_name = "mycompanytfstate"
container_name = "tfstate"
}
}
Show the answer

Every argument in config is emitted into the generated backend.tf verbatim — proven above, real output, no use_azuread_auth anywhere in it. That means Terraform authenticates to the storage account’s data plane with a shared account key or SAS token by default, a long-lived secret that has to be distributed to every machine that runs init, unlike the identity-based auth this course’s Remote Backends and Locking lesson establishes as the recommended pattern. Fix: add use_azuread_auth = true (and, for CI, use_oidc = true) to the remote_state.config map, authenticating with Microsoft Entra ID instead.

root.hcl
remote_state {
backend = "local"
generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }
config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }
}
modules/network/main.tf
variable "environment" {
type = string
}
resource "terraform_data" "vnet" {
input = "vnet-${var.environment}"
}
output "vnet_id" {
value = terraform_data.vnet.id
}
modules/app/main.tf
variable "vnet_id" {
type = string
}
resource "terraform_data" "app" {
input = var.vnet_id
}
live/dev/env.hcl
locals {
environment = "dev"
subscription_id = "00000000-0000-0000-0000-000000000000"
location = "East US"
}
live/prod/env.hcl
locals {
environment = "prod"
subscription_id = "11111111-1111-1111-1111-111111111111"
location = "East US"
}
live/dev/network/terragrunt.hcl
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
}
live/prod/network/terragrunt.hcl
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
}
live/dev/app/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
dependency "network" {
config_path = "../network"
mock_outputs = { vnet_id = "mock-vnet-id" }
mock_outputs_allowed_terraform_commands = ["plan"]
}
terraform {
source = "${get_terragrunt_dir()}/../../../modules/app"
}
inputs = {
vnet_id = dependency.network.outputs.vnet_id
}
live/prod/app/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
dependency "network" {
config_path = "../network"
mock_outputs = { vnet_id = "mock-vnet-id" }
mock_outputs_allowed_terraform_commands = ["plan"]
}
terraform {
source = "${get_terragrunt_dir()}/../../../modules/app"
}
inputs = {
vnet_id = dependency.network.outputs.vnet_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/network
live/prod/network
live/dev/app
live/prod/app
#BugFix
1run-all/plan-all in the CI scriptrun --all — real “unknown command” rejection for both, before the fix
2--terragrunt-non-interactive and other pre-1.1-prefixed flagsBare --non-interactive etc. — real “not a Terragrunt flag” rejection before the fix
3find_in_parent_folders() with no argument, root file named root.hclfind_in_parent_folders("root.hcl") — real “Could not find a terragrunt.hcl” across all 4 units before the fix
4dependency "network" with no mock_outputs at allmock_outputs + mock_outputs_allowed_terraform_commands = ["plan"] — real run --all plan failure on a fresh environment before the fix
5dev and prod network units with byte-identical locals (subscription_id 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
6A second include "env" with no expose = trueexpose = true added — real “This object does not have an attribute named env” before the fix
7terraform_remote_state reading another environment’s state directly from inside a moduleReplaced with a proper dependency block (or removed) — real “No stored state was found” before the fix
8root.hcl azurerm remote_state with no use_azuread_authuse_azuread_auth = true added — proven by the real generated backend.tf never mentioning it, defaulting to shared-key auth

Four of these eight bugs (1, 2, 3, 6) are hard, immediate failures at the CLI/HCL-evaluation layer, identical in mechanism to the AWS sibling review. 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 azurerm-specific rather than reused from the AWS sibling: instead of a deprecated dynamodb_table argument (AWS), the equivalent silent gap here is a real, generated backend.tf that simply never mentions the auth mechanism this course’s own state-management lesson recommends.

  • “If terragrunt hcl validate passes, a multi-environment tree is actually safe to deploy.” Proven above: hcl validate on the two byte-identical network units returns zero errors — HCL validation has no concept of “this value should differ from that sibling file.”
  • “A dependency block without mock_outputs only breaks apply, never plan.” Proven above: on a completely fresh environment where the dependency has never been applied even once, run --all plan itself fails.
  • “A backend’s generate block only matters for locking and state location, not auth.” Proven above: the real generated backend.tf for this tree’s root.hcl has zero mention of use_azuread_auth — the exact same generate mechanism that correctly scopes each unit’s key will just as faithfully emit a backend that authenticates with a long-lived shared key if that is simply what the config map says, or doesn’t say.
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: use_azuread_auth missing"| L
Where each bug sits in this live tree's evaluation path
find_in_parent_folders() is called with no argument in a tree whose root config is named root.hcl. What does real terragrunt hcl validate show?
A fresh environment where network has never been applied, and app has a dependency "network" block with no mock_outputs at all. What does real terragrunt run --all plan show?
The real generated backend.tf for this tree's root.hcl never mentions use_azuread_auth. What does this prove about the resulting authentication, and what does the generate mechanism's behavior here have in common with the AWS sibling review's dynamodb_table entry?
dev and prod network units are byte-identical, including subscription_id. What does terragrunt hcl validate show, and what actually catches this?
A second include "env" block is declared with merge_strategy = "no_merge" but no expose = true, and a later inputs line reads include.env.locals.subscription_id. What does real terragrunt render show?