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 — this review reproduces catalog entries 5, 6, and 16-17 inside one coherent live tree, plus four more specific to a multi-environment layout. 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 S3 backend — per this course’s own convention, offline proof never touches real AWS.

# root.hcl, as generated -- realistic S3 backend (see review walk for why the proof tree below uses backend = "local" instead)
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "acme-tf-locks"
}
}
# 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"
account_id = "111111111111"
region = "us-east-1"
}
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
}
# live tree, as generated -- terragrunt.stack.hcl
unit "network" {
source = "./modules/network"
path = "network"
values = {
environment = "dev"
}
}
unit "app" {
source = "./modules/app"
path = "app"
values = {
environment = "dev"
}
}
# 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:

$ 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, with TG_* environment variable equivalents. A --terragrunt-*-prefixed flag is rejected outright, not silently ignored, which is the one piece of good news in this bug: a stale CI script fails its very first run rather than quietly behaving differently. --queue-include-dir (no terragrunt- prefix) does still work today, as a soft-deprecated alias for --filter — confirmed by running it directly — so the specific danger here is the fully pre-1.1 flag spelling, not the --queue-* family in general. Fix: drop the terragrunt- prefix from every flag: terragrunt run --all --non-interactive --no-color -- plan.

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; terragrunt hcl validate also prints a separate warning that using terragrunt.hcl as a root file “is an anti-pattern… no longer recommended.” The bare, no-argument call genuinely cannot find a root.hcl — it isn’t looking for one. Every one of the four units fails identically, all at once, which is at least loud and immediate. 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 2 units
Succeeded 1
Failed 1
ERROR 2 errors occurred:
* 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.
If this dependency is accessed before the outputs are ready (which can happen
during the planning phase of an unapplied stack), consider using mock_outputs: ...
* live/dev/app/terragrunt.hcl:14,12-22: Unknown variable; 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: not “apply excluded from the allow-list” but no mock_outputs declared at all. network’s own plan succeeds; app’s fails because it needs dependency.network.outputs.vpc_id and there is no real state, and nothing to fall back to. This is exactly the first run --all plan on a genuinely fresh environment — the situation mock_outputs exists to unblock. 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", account_id = "111111111111" — including inside live/prod/network/terragrunt.hcl.

🐛 Spot the bug

live/prod/network/terragrunt.hcl
locals {
environment = "dev"
account_id = "111111111111"
}
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 S3 backend targeting account_id = "111111111111" from both dev and prod, this deploys prod into dev’s AWS account, 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 (live/dev/env.hcl, live/prod/env.hcl), and have each network/terragrunt.hcl become identical to its sibling, reading include.env.locals.* instead of its own hardcoded locals — the fix in this specific tree is proven below.

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 = {
account_id = include.env.locals.account_id
}

Symptom — real terragrunt render:

$ terragrunt render --format json
Error: Unsupported attribute
on unit/terragrunt.hcl line 15:
15: account_id = include.env.locals.account_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.* inside this unit’s own HCL. 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 to read another environment’s outputs, instead of a Terragrunt dependency block pointed at ../../prod/network. Beyond the hard-fail proven here (state that was never applied), this pattern bypasses mock_outputs entirely, has no run --all ordering awareness (Terragrunt has no idea this Terraform-level read exists), and hardcodes a relative path to a sibling environment’s state file that breaks the moment either environment’s backend key changes. Fix: a proper cross-environment dependency block, or — if dev should never actually read prod’s state at all — deleting the reference entirely.

root.hcl: dynamodb_table for locking, no use_lockfile

Section titled “root.hcl: dynamodb_table for locking, no use_lockfile”
# excerpt, as generated -- entries/08-dynamodb-no-lockfile/root.hcl
remote_state {
backend = "s3"
config = {
bucket = "acme-terraform-state"
dynamodb_table = "acme-tf-locks"
}
}

Fix:

# excerpt, as generated -- entries/08-dynamodb-no-lockfile-fixed/root.hcl
remote_state {
backend = "s3"
config = {
bucket = "acme-terraform-state"
use_lockfile = true
}
}
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" "vpc" {
input = "vpc-${var.environment}"
}
output "vpc_id" {
value = terraform_data.vpc.id
}
modules/app/main.tf
variable "vpc_id" {
type = string
}
resource "terraform_data" "app" {
input = var.vpc_id
}
live/dev/env.hcl
locals {
environment = "dev"
account_id = "111111111111"
region = "us-east-1"
}
live/prod/env.hcl
locals {
environment = "prod"
account_id = "222222222222"
region = "us-east-1"
}
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 = { 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
}
live/prod/app/terragrunt.hcl
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
}

Note that live/dev/network/terragrunt.hcl and live/prod/network/terragrunt.hcl are now themselves byte-identical (as are the two app units) — the fix for entry 5 is not “make the files differ,” it’s the opposite: the units become identical, byte-for-byte, and only env.hcl differs per environment. That is the actual shape of the inheritance the original code was missing.

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

terragrunt render --format json from live/dev/network confirms include.env.locals.environment really resolves to "dev"; the identical command from live/prod/network resolves to "prod" — real, different values from two structurally identical unit files.

#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 (1 succeeded, 1 failed) on a fresh environment before the fix
5dev and prod network units with byte-identical locals (account_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 remote_state uses dynamodb_table, no use_lockfileuse_lockfile = true, dynamodb_table removed — docs-sourced, real S3 backend needs credentials this course never uses

Four of these eight bugs (1, 2, 3, 6) are hard, immediate failures — the CLI itself refuses to proceed, whether that’s an unknown command, a rejected flag, a function that can’t find its target file, or a reference to an include that was never exposed. One (4) is a hard failure but only on the very first run --all plan against a genuinely fresh environment — it would have looked fine forever in a demo where network always already has state. One (7) is a hard failure specific to state that was never applied, at the Terraform layer rather than Terragrunt’s. The remaining two (5, 8) never error at all: hcl validate/hcl format both pass a dev/prod copy-paste that would deploy into the wrong AWS account, and a deprecated-but-still-functional locking argument that only a docs read (or a since-decommissioned DynamoDB table) reveals.

  • “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 — every line is syntactically valid HCL, HCL validation has no concept of “this value should differ from that sibling file.”
  • “find_in_parent_folders() with no argument searches for whatever the actual root config is named.” Proven above: it searches specifically for a file named terragrunt.hcl, unconditionally — a root file genuinely named root.hcl (this course’s convention, and current Terragrunt guidance) is invisible to the bare call, producing a real “traversed all the way to the root” failure across every single unit in the tree at once.
  • “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 — mock_outputs is not solely an apply-time safety net, it is what makes a first-ever plan on an unapplied stack possible at all.
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"] -->|"8: dynamodb_table, no use_lockfile"| 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?
dev and prod network units are byte-identical, including account_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.account_id. What does real terragrunt render show?
A module uses data "terraform_remote_state" "prod_network" { config = { path = "../../../prod/network/terraform.tfstate" } } } to read another environment cross-tree, and that path has never been applied. What does real terraform plan show, and what does this bypass that a proper Terragrunt dependency block would not?