In a nutshell
If you have ever used make, you already understand Terragrunt’s run --all. A Makefile does not run your build steps in the order you typed them — it reads the dependencies you declared (“the binary needs the object files, the object files need the source”), works out a valid order itself, and runs independent steps in parallel. run --all is make for infrastructure. You never write “apply the network, then the cluster, then the database.” You declare that the cluster depends on the network, and Terragrunt derives the order from the whole web of dependencies — a DAG, a directed acyclic graph — and walks it for you.
The pieces map cleanly onto that mental model. Each leaf folder with a terragrunt.hcl is a unit — one Terraform/OpenTofu module, one node in the graph, one isolated state file. A dependency block is an edge: it says “this unit needs that one first,” and it also carries data across (the network’s vpc_id flows into the cluster). Terragrunt reads every unit, wires the edges, and produces the graph. Then run --all apply walks it forward (dependencies before dependents); run --all destroy walks it backward (tear down the cluster before the network it sits in). At scale you narrow the walk to only the units a pull request actually touched, and you cap how many run at once so you do not melt the cloud provider’s API.
That is the whole idea: declare the edges, let Terragrunt derive and walk the graph. Everything below is how to declare those edges precisely, how to make a plan survive before anything exists (mock_outputs), how to run only what changed, and where this machine stops scaling.
Level: Advanced · Time: ~30 min
Before this lesson, be comfortable with the DRY building blocks it assumes — include, remote_state generation, find_in_parent_folders, and the live/modules split. If any of those are fuzzy, read Terragrunt fundamentals and DRY multi-account environments first, then come back.
After this lesson you will be able to:
- Lay out a monorepo where the directory path is the deployment identity, and model dependencies with
dependency(data + order) versusdependencies(order only). - Make a full-stack
plansucceed on a greenfield repo usingmock_outputsand the allowlist that keeps mocks out ofapply. - Drive
run --allandrun --graphto apply, destroy, and tear down a single unit’s downstream cone in the correct order. - Run only the affected units on each PR, in parallel, bounded so you do not exhaust provider rate limits — and know why a shallow CI checkout silently breaks it.
- Decide when a monorepo
run --allhas hit its ceiling and Terragrunt stacks are the next move.
Terragrunt reads every unit, derives the acyclic dependency graph from the config_path edges, then run --all applies units in dependency order (and destroys in reverse) — parallelised, and in CI narrowed to only the units a change touched.
A two-account Terragrunt repo is easy. Forty accounts across three regions, with two hundred units whose apply order is dictated by a dependency graph nobody can hold in their head, is a different machine. The wrapper that removed your backend.tf duplication is now the thing standing between a one-line PR and a thirty-minute serialized apply that fails on unit 137 and leaves the run half-applied.
This article is about that machine: how Terragrunt builds the dependency DAG, how run --all and run --graph traverse it, how to make plans survive a greenfield where downstream outputs do not yet exist, and — the part that actually matters at scale — how to run only the units a change touched, in parallel, safely, in CI. It assumes you already know include, remote_state generation, and the live/modules split. If you do not, start with the DRY multi-environment article and come back.
A note on CLI syntax. Terragrunt v0.88.0 redesigned the CLI.
terragrunt run-all applyis nowterragrunt run --all apply;graph-dependenciesis nowdag graph;--terragrunt-include-diris now--queue-include-dir;--terragrunt-non-interactiveis now--non-interactive. The legacy forms still work as deprecated aliases, so older pipelines will not break, but every command below uses the current syntax. If your CI logs warn about deprecated commands, that is what they mean.
1. Structure the live tree so the path is the identity
The hierarchy is account / region / environment / component, and it is not cosmetic. Terragrunt derives the state key from path_relative_to_include(), the provider role from the account file you are standing under, and the DAG from the config_path references between sibling directories. The directory layout is the deployment topology.
infra/
modules/ # versioned, reusable TF/OpenTofu modules
live/
root.hcl # backend + provider generation, version pins
_envcommon/ # per-component config shared across all envs
network.hcl
eks.hcl
prod/
account.hcl # account_id, account_name
us-east-1/
region.hcl # aws_region
platform/ # the "environment" layer
network/
terragrunt.hcl
eks/
terragrunt.hcl
rds/
terragrunt.hcl
eu-west-1/
region.hcl
platform/
network/
eks/
staging/
account.hcl
us-east-1/ ...
Two structural rules pay off at scale:
- One module instantiation per leaf directory. A leaf with a
terragrunt.hclis a unit — Terragrunt’s atomic node in the DAG. Never put two modules in one directory; you lose the ability to plan, target, and roll back them independently. _envcommon/for component-level DRY. The EKS inputs that never differ between staging and prod (addon versions, IRSA wiring, log retention) live in_envcommon/eks.hcland are pulled in with a secondinclude. Only the genuinely environment-specific values (cluster name, node counts) stay in the leaf. This is what keeps the promotion diff to a handful of lines across two hundred units.
2. Keep the root DRY with locals and read_terragrunt_config
The root config is read by every unit, so it carries the expensive-to-repeat facts exactly once: backend, provider, and the version pins that keep a 200-unit run reproducible.
# live/root.hcl
locals {
account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
account_id = local.account_vars.locals.account_id
aws_region = local.region_vars.locals.aws_region
}
# Pin the toolchain. A 200-unit run is only reproducible if every unit
# runs the same OpenTofu and Terragrunt versions.
terraform_version_constraint = ">= 1.9.0, < 2.0.0"
terragrunt_version_constraint = ">= 0.88.0"
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-tfstate-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = local.aws_region
encrypt = true
use_lockfile = true # S3-native locking; no DynamoDB table needed
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<-EOF
provider "aws" {
region = "${local.aws_region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/terraform-exec"
}
default_tags {
tags = { ManagedBy = "terragrunt", Account = "${local.account_vars.locals.account_name}" }
}
}
EOF
}
terraform_version_constraint makes Terragrunt fail fast if the binary on the runner drifts from the pinned range, instead of letting a newer OpenTofu silently rewrite your state format mid-run. Pin the Terragrunt binary itself in CI tooling (a mise/asdf .tool-versions file or a container tag) — the terragrunt_version_constraint attribute is a guardrail, not a version manager.
3. The dependency graph: dependency vs dependencies
Two blocks build the DAG, and the distinction is load-bearing.
dependencies(plural) declares ordering only. It is a list of paths that must be applied before this unit. No data crosses the edge.dependency(singular) declares ordering and data flow. It reads the target unit’s outputs and exposes them asdependency.<name>.outputs.<key>.
You almost always want dependency — if a unit needs ordering, it usually needs an output too. Reach for dependencies only for pure sequencing with no data (for example, “do not touch the app tier until the IAM bootstrap unit has run”).
# live/prod/us-east-1/platform/eks/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
include "envcommon" {
path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/eks.hcl"
expose = true # make its locals readable here
}
dependency "network" {
config_path = "../network"
mock_outputs = {
vpc_id = "vpc-mock00000000000"
private_subnet_ids = ["subnet-mock1", "subnet-mock2", "subnet-mock3"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
mock_outputs_merge_strategy_with_state = "shallow"
}
# Pure ordering, no data: wait for the org-wide IAM baseline.
dependencies {
paths = ["../../../_baseline/iam"]
}
inputs = {
cluster_name = "prod-platform"
vpc_id = dependency.network.outputs.vpc_id
subnet_ids = dependency.network.outputs.private_subnet_ids
}
Terragrunt reads every terragrunt.hcl under the run root, resolves each config_path, and assembles a directed acyclic graph. For plan/apply it walks the graph so dependencies run before dependents; for destroy it walks it in reverse, tearing down dependents first. You never write the order. A cycle (A depends on B depends on A) is a hard error at graph-construction time, which is exactly when you want to find it.
4. Mock outputs: surviving plan-time and greenfield applies
This is the single most misunderstood mechanic in Terragrunt, and the one that breaks naive CI.
When you plan the EKS unit but network has never been applied, network has no outputs. Reading dependency.network.outputs.vpc_id would fail and abort the plan — so on a fresh repo you could never produce a full-stack plan. mock_outputs supplies placeholder values so plan, validate, and init proceed against fakes.
The mock_outputs_allowed_terraform_commands allowlist is the safety interlock. It must exclude apply and destroy. With the allowlist above, an apply that cannot find real outputs will fail rather than feed a fake subnet ID into a real cluster. That failure is correct: it means you tried to apply a dependent before its dependency, and Terragrunt’s run --all ordering exists precisely so you never hit it in practice.
mock_outputs_merge_strategy_with_state controls what happens once the dependency has partial real state — common when you add a new output to an already-applied module:
| Strategy | Behavior |
|---|---|
no_merge (default) |
If real state exists, use it as-is and ignore mocks. A newly-added output that the applied state lacks will be missing, failing the plan. |
shallow |
Real outputs win; mocks fill only top-level keys the state does not yet have. The usual choice. |
deep_map_only |
Like shallow, but recurses into map-typed outputs, filling absent keys inside maps. |
Use
shallowas your default. The failure mode ofno_merge— add an output to a module, and every downstreamplanbreaks until you re-apply the dependency first — is a needless ordering constraint on a read-only operation.shallowlets the plan proceed on a mock for the one new key while using real values for everything else.
A separate knob, skip_outputs = true, tells Terragrunt to never call terragrunt output on the dependency (it still enforces ordering). Do not combine it with mock_outputs expecting “mocks only when real outputs are absent”: skip_outputs means “always mock,” mock_outputs means “mock only as a fallback.” They answer different questions.
5. Orchestrate with run --all and run --graph
run --all is the workhorse: it discovers every unit under the current directory, builds the DAG, and executes your command in topological order, parallelizing independent units.
# Stand up an entire region in dependency order.
cd infra/live/prod/us-east-1
terragrunt run --all plan
terragrunt run --all apply --non-interactive
Two operational truths:
- A greenfield
run --all planis approximate, not byte-exact. Downstream units plan against mocked outputs, so their plans show placeholder ARNs and counts. Read it as a sanity check on intent and ordering, not as the literal diff thatapplywill produce. The real plan for a downstream unit is only exact after its dependency has applied. run --all applyauto-approves by default. Across many units there is no sane way to interactively confirm each one, so Terragrunt adds-auto-approve. If that makes you nervous,--no-auto-approverestores per-unit confirmation (rarely what you want in CI, frequently what you want for a hand-driven prod teardown).
For destroys, the graph runs in reverse — and this is where run --graph earns its place. run --all destroy from a region root tears down everything under it. When you want to destroy one unit and everything that depends on it (its downstream cone), without touching unrelated units, use run --graph:
# Destroy the network unit AND every unit that depends on it,
# in the correct reverse order. Run from inside the target unit.
cd infra/live/prod/us-east-1/platform/network
terragrunt run --graph destroy
run --graph is anchored to the current unit and traverses the dependency edges out from it; run --all is anchored to a directory and processes everything beneath it. Knowing which you mean is the difference between deleting a VPC’s dependents cleanly and deleting an entire region.
Inspect the graph itself before trusting any of this:
cd infra/live/prod/us-east-1
terragrunt dag graph | dot -Tsvg > dag.svg # Graphviz DOT to a diagram
6. Selective execution: only the units a change touched
At 200 units, run --all plan over the whole repo on every PR is minutes of wasted compute and a wall of noise. The goal is to run only the affected units.
Terragrunt gives you two mechanisms. The blunt one is glob inclusion:
# Plan only the EKS units across every prod region.
terragrunt run --all plan --queue-include-dir "prod/*/platform/eks"
# Plan everything in us-east-1 except RDS.
terragrunt run --all plan \
--queue-include-dir "prod/us-east-1/*" \
--queue-exclude-dir "prod/us-east-1/platform/rds"
The sharper one is change-aware and is what you actually want in CI. --filter-affected targets the units modified between the default branch and HEAD:
# Plan only units whose code changed vs the default branch — and,
# because it respects the DAG, the dependents of those units too.
terragrunt run --all plan --filter-affected
There is a subtlety the blunt globs miss: a change to a shared file (_envcommon/eks.hcl, or a local module under modules/) affects every unit that reads it, even though no leaf terragrunt.hcl changed. --queue-include-units-reading catches exactly that class:
# If _envcommon/eks.hcl changed, plan every unit that includes/reads it.
terragrunt run --all plan \
--queue-include-units-reading "_envcommon/eks.hcl"
The three
--queue-*flags above are now aliases for the newer--filterquery language, so current docs may show--filter; the queue-prefixed forms remain valid and read more clearly for directory-shaped selection. Two formerly-common flags are now deprecated because their behavior is the default:--queue-strict-include(inclusion is strict now) and--queue-exclude-external(external dependencies are excluded by default).
7. CI: detect affected units and parallelize safely
The naive pipeline runs run --all over the whole repo and serializes. The scalable one computes the affected set, plans it on PRs, and applies it on merge, bounded by parallelism so you do not exhaust provider rate limits or the runner.
# .github/workflows/terragrunt.yml
name: terragrunt
on:
pull_request:
push:
branches: [main]
jobs:
terragrunt:
runs-on: ubuntu-latest
permissions:
id-token: write # OIDC; no long-lived AWS keys
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # --filter-affected needs full history to diff
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::222222222222:role/ci-terraform
aws-region: us-east-1
- uses: gruntwork-io/terragrunt-action@v3
with:
tofu_version: 1.9.0
tg_version: 0.88.0
tg_dir: infra/live/prod
# PRs plan the affected set; pushes to main apply it.
tg_command: >-
run --all
${{ github.event_name == 'pull_request' && 'plan' || 'apply' }}
--filter-affected
--non-interactive
--parallelism 8
--queue-ignore-errors
The flags that make this safe at scale:
fetch-depth: 0—--filter-affecteddiffs against the default branch, which requires real git history. A shallow checkout silently makes it find nothing (and plan nothing, which looks like success).--parallelism 8caps concurrent units. The DAG width can be dozens of independent units; without a cap you will hit AWS API throttling and OOM the runner. Tune to your account’s rate limits; 4-10 is a sane band.--non-interactiveforces non-prompting behavior — mandatory in CI, where a hung prompt is a hung job.--queue-ignore-errorson the plan job surfaces every broken unit in one run instead of aborting on the first. You get the full list of failures per PR rather than fixing them one slow round-trip at a time.
There is one trap worth stating plainly: --queue-ignore-errors does not mean “apply what you can and skip the rest” in a way that is safe for apply. On apply, a failed dependency means its dependents should not proceed — they would apply against stale or mock data. Keep --queue-ignore-errors for plan/validate; on the apply job, prefer the default fail-fast behavior so a failed network unit stops its EKS dependent rather than applying it blind.
Pin both binaries in the action (tofu_version, tg_version) so the runner cannot drift from your *_version_constraint pins and fail the whole run on a version check. For environments beyond a sandbox, also pin every module source to a tag — source = "git::...//eks?ref=v1.5.0" — so a plan today and an apply on merge run identical module code. Promotion then becomes a reviewed one-line ref= bump per environment.
Verify
Confirm the orchestration behaves before you trust it on prod.
cd infra/live/prod/us-east-1
# 1. The DAG is acyclic and ordered as you expect.
terragrunt dag graph | dot -Tsvg > /tmp/dag.svg
# network has no inbound edges; eks and rds depend on it.
# 2. A full-stack validate touches no cloud state but exercises every unit.
terragrunt run --all validate --non-interactive
# 3. Change detection selects the right set. From a feature branch:
git checkout -b verify/affected
touch platform/eks/terragrunt.hcl # simulate an EKS-only change
terragrunt run --all plan --filter-affected --non-interactive
# Expect: eks (and any dependents) planned; network and rds skipped.
# 4. Shared-file fan-out works.
terragrunt run --all plan \
--queue-include-units-reading "$(git rev-parse --show-toplevel)/infra/live/_envcommon/eks.hcl" \
--non-interactive
# Expect: every unit that includes _envcommon/eks.hcl appears.
# 5. State keys are isolated per unit.
aws s3 ls s3://acme-tfstate-222222222222/prod/us-east-1/ --recursive
# Expect distinct keys: .../platform/network/terraform.tfstate, .../platform/eks/...
You are checking four properties: the graph is acyclic and ordered correctly, --filter-affected plans only what changed plus its dependents, a shared-file edit fans out to every reader, and each unit owns a distinct state key.
Checklist
Where this approach stops scaling
run --all over a single repo has a ceiling. Two patterns push it out. First, partition the run root: never run run --all from the repo root in production — anchor it at an account or region so the DAG and blast radius stay bounded. Second, when units genuinely form a deployable bundle (a whole environment promoted at once), evaluate Terragrunt stacks (terragrunt.stack.hcl), which compose units into a higher-level node you version and run as one. Stacks are newer and some flag interactions still have rough edges, so validate on a non-critical environment first.
The discipline underneath all of it is the same one that makes the live/modules split worth maintaining: the directory path is the identity, the DAG is derived not authored, and every selective-execution flag just runs a subset of that derived graph. Get the graph right and run --all is a detail. Get it wrong and no flag will save the apply.
Going deeper
The seven sections above are the working machine. This section is the underside — how the graph is actually evaluated, the flags whose names just changed, and the sharp edges that only show up at a few hundred units.
How dependency outputs are actually resolved (and why a big plan is slow)
When a unit reads dependency.network.outputs.vpc_id, Terragrunt has to get that value. By default it does the obvious thing: it runs the dependency’s output command — effectively an init plus terragrunt output -json against the network unit — and caches the result for the run. That is fine for ten units. At two hundred, it is the dominant cost of a run --all plan: every edge triggers an init and an output fetch, most of them for units that did not change.
There are two levers, and production repos use both:
- Fetch outputs straight from remote state. Instead of running the dependency’s
output, Terragrunt can read the outputs directly out of the dependency’s state object in the backend — noinit, no child process per edge. Historically this was--terragrunt-fetch-dependency-output-from-state; the CLI redesign is dropping the--terragrunt-prefix across the board, so confirm your version’s exact flag name againstterragrunt --help. It trades a small staleness window (you read the last applied outputs, not a fresh re-evaluation) for a large speedup. - Do not evaluate untouched units at all.
--filter-affected(below) removes them from the queue, so their outputs are never fetched in the first place.
skip_outputs = true is the third knob and it is often misread. It tells Terragrunt to never fetch real outputs for a dependency — it always uses the mocks (or nothing) — while still enforcing the ordering edge. It answers a different question from mock_outputs:
| Setting | When outputs are mocked | Ordering still enforced? |
|---|---|---|
mock_outputs only |
Only as a fallback, when real outputs are absent (greenfield / plan-time) | Yes |
skip_outputs = true |
Always — real outputs are never read | Yes |
skip_outputs = true + mock_outputs |
Always uses the mocks (never real state) | Yes |
Reach for skip_outputs when a unit needs the ordering edge but genuinely never consumes the dependency’s data. Most of the time, though, dependencies (plural) is the cleaner expression of “run after this, take nothing from it.”
Two parallelisms, multiplied
--parallelism (the redesign’s name for the old --terragrunt-parallelism) bounds concurrent units — how many nodes of the DAG Terragrunt runs at once. It is not the same knob as Terraform/OpenTofu’s own -parallelism, which bounds concurrent resource operations inside a single unit (default 10). They multiply:
| Knob | Scope | Default | Set with |
|---|---|---|---|
Terragrunt --parallelism |
Concurrent units across the DAG | Unbounded (all ready units) | terragrunt run --all apply --parallelism 8 |
OpenTofu -parallelism |
Concurrent resource ops within one unit | 10 | terragrunt run --all apply -parallelism=5 |
--parallelism 8 with the default -parallelism 10 means up to eighty concurrent cloud API calls. That is how a “just plan the region” job trips AWS throttling and OOMs a 7 GB runner even though each individual unit looks modest. Terragrunt’s own flag is double-dash (--parallelism); OpenTofu’s is single-dash (-parallelism), and Terragrunt forwards single-dash args straight to each unit’s binary — so the two never collide. Cap the unit-level number first (it has the bigger blast radius on rate limits); drop the resource-level number only if a single fat unit is the culprit.
Destroy order and the reverse-dependency traps
plan/apply walk the DAG so dependencies come first; destroy walks it in reverse so dependents come first. You never want to delete a VPC while an EKS cluster still lives inside it, and the reverse walk guarantees you do not. Three traps hide in that reversal:
- A data-only
dependencystill creates a destroy edge. Ifeksreads one output fromnetwork, that is a graph edge, sonetworkwill not be destroyed untileksis gone — even if you thought the relationship was “just an output.” That is usually what you want; it becomes a surprise when a long-dead reference keeps a unit un-destroyable. Prune staledependencyblocks. - External dependencies are excluded by default. A
dependencywhoseconfig_pathpoints outside the current run root is not pulled into arun --all destroy— the old--queue-exclude-externalflag is deprecated precisely because exclusion is now the default. Safe (you will not accidentally destroy the shared IAM baseline from a region teardown), but a trap if you expected the external unit to go too. It will not; destroy it explicitly. run --all destroyauto-approves, and it is anchored to a directory. From a region root it tears down everything beneath it, dependents-first, without a per-unit prompt. When you mean “this unit and its downstream cone only,” you wantrun --graph destroyfrom inside the target unit — anchored to the node, not the directory. Confusing the two is the difference between deleting a subnet’s dependents and deleting a region.
Selective execution: the flag renames, in one place
The redesign renamed most of the selection flags. Same behaviour, new spelling — older pipelines keep working on deprecated aliases, but current docs and warnings use the new names:
| Current | What it selects | Legacy alias |
|---|---|---|
--queue-include-dir |
Units matching a glob are included | --terragrunt-include-dir |
--queue-exclude-dir |
Units matching a glob are excluded | --terragrunt-exclude-dir |
--parallelism |
Concurrent-unit cap | --terragrunt-parallelism |
--non-interactive |
Never prompt | --terragrunt-non-interactive |
--queue-ignore-errors |
Keep going past a failed unit | --terragrunt-ignore-dependency-errors |
--queue-include-units-reading |
Units that read a given file | (newer; no alias) |
--filter-affected |
Units changed vs the default branch, plus dependents | (newer; no alias) |
The one that is easy to get wrong is the shared-file case. --filter-affected and a leaf-directory diff both look at which units changed. But a change to _envcommon/eks.hcl or a module under modules/ changes no leaf terragrunt.hcl — yet it affects every unit that reads it. --queue-include-units-reading "_envcommon/eks.hcl" is the flag that catches exactly that fan-out. A CI that only diffs leaf directories will silently skip the units a shared-config edit actually broke.
--filter-affectedand the--queue-*flags are the readable, directory-shaped front end of a newer--filterquery language; recent docs may show the--filterform. Change-detection features have moved quickly across Terragrunt releases — pin your Terragrunt version in CI and confirm the exact flag againstterragrunt --helpfor that version rather than assuming.
State isolation is the whole point
Every unit owns a distinct state key (${path_relative_to_include()}/terraform.tfstate). That is not a filing convention — it is the blast-radius boundary and the concurrency boundary at once:
- Blast radius. A botched
applytoplatform/rdscan only corruptplatform/rds’s state. The VPC two directories over is untouchable from here because it is a different state object with a different lock. - Concurrency. Per-unit state means per-unit locks. Two independent units (say
eksinus-east-1andeksineu-west-1) can apply at the same time underrun --allbecause they are locking different objects. Collapse them into one giant state and every apply serialises behind one lock — you lose the parallelism the DAG was built to exploit. - Least privilege. Because the state key is path-derived, you can scope a CI role to a key prefix (
prod/us-east-1/*) so the region’s pipeline physically cannot write another region’s state.
The anti-pattern is “one state per environment.” It feels simpler until the first time a routine add-on change plans to touch the VPC, or two engineers’ applies deadlock on the one lock. Per-unit state is more files and exactly the right number of blast radii.
The direction of travel: Terragrunt stacks
run --all over a monorepo is a graph of units. When a set of units is really one deployable thing — a whole environment you stand up, promote, and tear down together — driving them as loose leaves gets awkward. Terragrunt stacks (terragrunt.stack.hcl) compose units into a higher-level node you version and run as one:
# live/prod/us-east-1/terragrunt.stack.hcl
unit "network" {
source = "${get_repo_root()}/units/network"
path = "platform/network"
}
unit "eks" {
source = "${get_repo_root()}/units/eks"
path = "platform/eks"
values = {
# values flow into the unit, replacing hand-written dependency wiring
vpc_from = "platform/network"
}
}
terragrunt stack generate expands that into a .terragrunt-stack/ tree of real units, which then run through the same DAG machinery you already know. The win is composition and reuse — one stack definition instantiated per region or per account instead of copy-pasted leaf folders. Stacks are newer and some flag interactions still have rough edges, so validate them on a non-critical environment before you bet a promotion pipeline on them. The dedicated Terragrunt stacks deep dive covers unit, stack, and values in full.
Practice challenges
Work these against the tree from Section 1 (infra/live/prod/us-east-1/platform/{network,eks,rds}). They escalate from a single block to a scale-time judgement call. Try each before opening the solution.
1. Wire a dependency so a greenfield plan survives (beginner)
eks needs the VPC id and private subnet ids from network, but on a fresh repo network has never been applied. Write the dependency "network" block that lets terragrunt plan succeed now yet refuses to feed fakes into an apply.
<details> <summary>Solution</summary>
dependency "network" {
config_path = "../network"
mock_outputs = {
vpc_id = "vpc-mock00000000000"
private_subnet_ids = ["subnet-mock1", "subnet-mock2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan", "init"]
mock_outputs_merge_strategy_with_state = "shallow"
}
Why: the allowlist excludes apply/destroy, so a real apply that cannot find outputs fails instead of provisioning a cluster against a fake subnet; shallow lets a later-added output land without breaking every downstream plan.
</details>
2. Pick the right block for order-without-data (beginner → intermediate)
The app tier must not run until an org-wide IAM baseline unit has applied, but it consumes none of that unit’s outputs. Which block, and why not the other one?
<details> <summary>Solution</summary>
dependencies {
paths = ["../../../_baseline/iam"]
}
Why: dependencies (plural) declares ordering only — no outputs are fetched or exposed. Using dependency (singular) here would make Terragrunt fetch outputs you never use, adding an init+output per run for nothing.
</details>
3. Stand up a whole region, bounded (intermediate)
Apply every unit under us-east-1 in dependency order, without interactive prompts, no more than six units at a time.
<details> <summary>Solution</summary>
cd infra/live/prod/us-east-1
terragrunt run --all apply --non-interactive --parallelism 6
Why: run --all discovers the units and derives the order; --non-interactive is mandatory in any unattended context; --parallelism 6 caps the DAG width so independent units do not collectively trip provider API throttling.
</details>
4. Catch a shared-file fan-out (intermediate → advanced)
You edited _envcommon/eks.hcl. No leaf terragrunt.hcl changed. Plan every unit that would be affected.
<details> <summary>Solution</summary>
terragrunt run --all plan \
--queue-include-units-reading "_envcommon/eks.hcl" --non-interactive
Why: a leaf-directory diff (or plain --filter-affected) sees no changed unit and plans nothing — a false green. --queue-include-units-reading selects every unit whose config reads the shared file, which is the real affected set.
</details>
5. Destroy one unit’s downstream cone, not the region (advanced)
Tear down network and everything that depends on it, in the correct reverse order, without touching unrelated units in the region.
<details> <summary>Solution</summary>
cd infra/live/prod/us-east-1/platform/network
terragrunt run --graph destroy
Why: run --graph is anchored to the current unit and traverses its downstream cone; run --all destroy from the region root would be anchored to the directory and tear down the entire region. The reverse walk destroys dependents before network itself.
</details>
6. Cut a 20-minute run --all plan at 200 units (advanced)
Nothing downstream changed, yet a whole-repo run --all plan takes twenty minutes — most of it Terragrunt fetching dependency outputs. Name the two independent levers that cut it, and the CI setting one of them requires.
<details> <summary>Solution</summary>
terragrunt run --all plan --filter-affected— only plans units changed vs the default branch plus their dependents, so untouched units are never evaluated. Requiresfetch-depth: 0(full git history) or it diffs against nothing and plans nothing.- Fetch dependency outputs from remote state instead of running each dependency’s
output(the--terragrunt--prefixed flag is being renamed in the redesign — confirm it withterragrunt --help), removing an init+output child process per edge.
Why: the per-edge output fetch, not the plans themselves, is the dominant cost of a wide run --all; skipping untouched units and reading outputs straight from state attack that cost from two directions.
</details>
Common beginner mistakes
- “
dependenciesgives me the other unit’s outputs.” It does not. Pluraldependenciesis ordering only; no data crosses the edge. If you referencedependency.<name>.outputs.*, you need the singulardependencyblock. Right model: singular = data + order, plural = order alone. - “
mock_outputskick in whenever real outputs are missing, including on apply.” Only within the allowlist.mock_outputs_allowed_terraform_commandsmust excludeapplyanddestroy, so an apply that cannot find real outputs fails rather than provisioning against fakes. That failure is the feature — it means you tried to apply a dependent before its dependency. - “A greenfield
run --all planshows the exact diffapplywill make.” No. Downstream units plan against mocked outputs, so their plans carry placeholder ARNs and counts. Read a greenfield plan as a check on intent and ordering; the exact diff for a downstream unit exists only after its dependency has applied. - “
--queue-ignore-errorsis fine everywhere — it just keeps going.” Safe onplan/validate, where surfacing every broken unit in one run is exactly what you want. Dangerous onapply: a failed dependency means its dependents must not proceed, or they apply against stale/mock data. Keep it off the apply job and let it fail fast. - “A shallow CI checkout is fine; it is faster.”
--filter-affecteddiffs against the default branch, which needs real history. A shallow checkout makes it find nothing — so it plans nothing, which looks like a passing build. Alwaysfetch-depth: 0on the change-detection job. - “
run --all destroyfrom the repo root just cleans up my sandbox.” It destroys everything beneath that directory, dependents-first, auto-approved. Anchor destroys deliberately:run --graph destroyfrom inside a unit for its cone, orrun --all destroyfrom an account/region root when you truly mean the whole subtree — never the repo root in anything you care about. - “One state file per environment is simpler than one per unit.” It is simpler right up to the first shared lock and the first over-broad plan. Per-unit state is the blast radius and the concurrency unit: separate states let independent units apply in parallel and stop a bad apply at one unit’s boundary. Collapsing them serialises every run behind one lock and lets a typo touch the VPC.
Glossary
- Unit — a single leaf directory containing one
terragrunt.hclthat instantiates one Terraform/OpenTofu module. The atomic node of the dependency graph; owns exactly one state file. - Stack — a set of units composed and run as one higher-level node, defined in
terragrunt.stack.hclwithunitblocks.terragrunt stack generateexpands it into real units. - DAG (directed acyclic graph) — the dependency graph Terragrunt derives from the
config_pathedges between units. “Acyclic” because a cycle (A needs B needs A) is an unresolvable error. dependencyblock (singular) — declares an edge that carries ordering and data: it fetches the target unit’s outputs and exposes them asdependency.<name>.outputs.<key>.dependenciesblock (plural) — declares an edge that carries ordering only; a list ofpathsthat must apply first, with no data crossing.mock_outputs— placeholder output values a dependency lends its dependents soplan/validate/initcan run before the dependency has ever been applied (greenfield or plan-time).mock_outputs_allowed_terraform_commands— the allowlist of commands for which mocks are permitted. Excludesapply/destroyso real operations never run against fakes.mock_outputs_merge_strategy_with_state— how mocks combine with partial real state:no_merge(default; real state wins wholesale),shallow(mocks fill only absent top-level keys),deep_map_only(recurses into maps).shallowis the usual choice.skip_outputs— whentrue, Terragrunt never fetches the dependency’s real outputs (always mock or none) while still enforcing the ordering edge.run --all— discovers every unit beneath the current directory, builds the DAG, and runs a command in topological order, parallelising independent units. (Wasrun-all.)run --graph— runs a command across the current unit and its downstream cone — the units reachable by following dependency edges out from here — in dependency order.dag graph— emits the dependency graph in Graphviz DOT for inspection (| dot -Tsvg). (Wasgraph-dependencies.)--filter-affected— selects only units changed versus the default branch, plus their dependents. Needs full git history (fetch-depth: 0).--queue-include-dir/--queue-exclude-dir— glob-based inclusion/exclusion of units from the run queue. (Was--terragrunt-include-dir/--terragrunt-exclude-dir.)--queue-include-units-reading— selects every unit whose config reads a given file — the fix for shared-file (_envcommon/,modules/) fan-out that leaf diffs miss.--parallelism— caps concurrent units across the DAG (not resources within a unit). (Was--terragrunt-parallelism.)--queue-ignore-errors— continue past a failing unit instead of aborting the queue. Use on plan/validate to surface all failures; avoid on apply.path_relative_to_include()— the unit’s path relative to itsincluded root; the basis for the per-unit state key, so the directory path is the state identity.find_in_parent_folders()— walks up the tree to locate a file (root.hcl,account.hcl,region.hcl), the mechanism behind DRY root inheritance._envcommon/— a convention directory holding per-component config shared across environments, pulled into leaf units with a secondincludeto keep promotion diffs tiny.- Topological order — an ordering of the DAG in which every unit comes after all its dependencies; the order
run --all applyuses (and reverses fordestroy). - Downstream cone — from a given unit, the set of all units that depend on it, directly or transitively; what
run --graphtargets.