Use Terragrunt
Optional: hand this page to your coding agentThe steps work by hand too.Show the whole prompt
Read https://intentius.io/terragucci/guides/use-terragrunt/.
Run `npx terragucci init --dry-run --json`, list the units and waves it found,
add a `terragrunt:` block only where the page says one is needed, and open a
pull request.
Never apply, approve (a pull request review or `terragucci approve`), override a policy denial (`terragucci override`), use `--mode apply`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.Result
Section titled “Result”A pipeline that checks with terragrunt hcl fmt and hcl validate and plans the units a pull request reaches. It applies a wave at a time behind the same gate as plain roots.
Prerequisites
Section titled “Prerequisites”| You need | Why |
|---|---|
A Terragrunt repo with a root.hcl, terragrunt.hcl or terragrunt.stack.hcl |
init detects Terragrunt from it |
| Terragrunt 1.1 or later | the oldest version terragucci supports |
| terragucci installed (Get your first plan note) | init writes the pipeline |
-
Run init.
Terminal window npx terragucci init --dry-runEach unit
terragrunt findlists is a root, so.terragrunt-filtersis honoured.rootsdoes not apply here, andinitrefuses it: leave units out withterragrunt.exclude. The units of an explicit stack are found too (Explicit stacks).When Terragrunt is not on the path,
initcuts the waves from the units’dependencyanddependenciespaths, and the apply jobs check them withterragrunt find. -
Name the tool Terragrunt runs underneath with
binary:tofu,terraformorchoudoufu.binary: tofuWith
choudoufuthe jobs run in the choudoufu image and install Terragrunt beside it. -
Tune it if you need to. A
terragrunt:block sets the version, parallelism, roles by unit path, excluded units and how dependents are planned (terragucci.yml keys).terragrunt:exclude: ["live/sandbox/**"]dependents: follow -
Write the pipeline.
Terminal window npx terragucci initCommit the pipeline, and under
approval: sealedchant.workspace.json, and open a pull request. Undersealed, set up the signers file before the first wave waits, as Approve a waiting wave describes.
Differences
Section titled “Differences”| What | How |
|---|---|
| Which units a change reaches | Terragrunt’s own change detection, plus files a module reads with file(), modules called from inside modules, and stack templates |
| Units that depend on a changed unit | a later wave than the unit they read from |
mock_outputs |
a unit whose plan would read mock values waits for its upstream to apply, so no approval covers a made-up value. In a pull request it plans on its upstream’s planned outputs instead, when they are known (below) |
| Each wave | one dependency layer, canary layers first, in its own apply job. One terragrunt run --all saves the plans and a second applies them. The last job also runs any layer added since init |
| The gate | as for plain roots, one gate per wave; the set digest covers the units whose plan changes a resource or an output |
| Credentials | a plan role and an apply role chosen by the unit’s path; a unit that sets its own iam_role keeps it |
/terragucci apply |
on a merged pull request (GitHub, Forgejo, and GitLab with comments: set) reruns its waves from the merge commit behind the same gate, with these refusals; it approves nothing |
| When a change applies | after it merges, by default. With apply.when: pull-request (GitHub, Forgejo, and GitLab with comments: set), /terragucci apply on the approved open pull request applies its waves of units from the head: Apply a pull request before it merges |
| Applying from other branches | apply.branches maps a branch to unit globs: a push to it applies those units alone, in their own layer waves behind the gate, and the default branch leaves them alone |
| Locks before merge | on units: a changed unit and the units whose dependency blocks name it; a change outside every unit’s directory locks every unit (Locks) |
cost |
each unit’s saved plan is priced, and approve_above holds a wave as it holds a wave of roots (Estimate the cost of a change) |
steps |
each moment runs once around the wave’s run --all, in every unit the step’s roots globs match; after: init is refused, since Terragrunt inits each unit inside the plan (Run steps around a stage) |
waves.jobs |
a wide wave’s job plans every unit and decides the gate; each share job plans and applies its own units with run --all --filter (A wide wave across jobs) |
| Dependency graph and blast radius | each unit, its wave and its dependency edges are in the run view and on the estate page’s graph; the plan note’s blast radius lists the units that depend on a changed unit (Blast radius in the plan note) |
| State versions | each unit’s state version is recorded after its wave applies, its backend read from the unit’s remote_state block (Find the state version an apply left) |
| A failed format check on a branch | the fmt job runs terragrunt hcl fmt and the binary’s fmt, and pushes the commit to the branch |
| Drift | the drift job reports drift by unit; its pull request writes a drifted value into the unit’s own inputs in terragrunt.hcl where its module reads that variable, and respond.drift: attribute names who changed it (Drift) |
| Generated backend and provider files | generate writes terragucci.hcl at the repo’s root, whose remote_state and generate blocks give each unit that includes it its backend, providers and versions, and the check job holds it to terragucci.yml |
| Exporting a state version | terragucci state export <unit> prepares the unit through Terragrunt and exports a version of the state its remote_state block names, behind the same approval (Export a state version) |
| Moving resources between units | a migration file names units as it names roots; Terragrunt prepares each unit, and the move goes through the same gate (Move resources between roots) |
| A version per unit | a unit’s .opentofu-version or .terraform-version, a version map glob, or an exact terragrunt_version_constraint in its own terragrunt.hcl picks its releases; each is installed in the job, and a wave runs as one run --all per pair of releases (A version per root) |
A held wave of units waits as a wave of roots does. It names its units and set digest and prints the approve command:


Explicit stacks
Section titled “Explicit stacks”The units a terragrunt.stack.hcl generates go through every stage as units the repo holds do. Each job runs terragrunt stack generate before it reads them, so keep .terragrunt-stack/ in .gitignore.
unit "base" {
source = "${get_repo_root()}/catalog/units/base"
path = "base"
values = { rev = "1" }
}
unit "top" {
source = "${get_repo_root()}/catalog/units/top" # its dependency block reads ../base
path = "top"
}init finds live/stk/.terragrunt-stack/base in wave 1 and live/stk/.terragrunt-stack/top in wave 2.
| Stage | With an explicit stack |
|---|---|
init and every job |
runs terragrunt stack generate first, so Terragrunt 1.1 or later must be on the path; without it init stops with an error naming the stack file |
| Check | generates the units, then terragrunt hcl validate --inputs checks them, so a unit whose template reads a value the stack file does not set fails the check, naming the generated unit |
| Which units a change reaches | a changed value in the stack file plans the unit it feeds; a changed unit template plans the units generated from it; their dependents wait as usual |
| Plan note and report | the note lists each stack file and the units it generates; roots[].terragrunt.stack is the stack’s directory and stack_file its terragrunt.stack.hcl |
| Drift | generates the units in a fresh checkout and reports drift by generated unit. The drift pull request leaves a generated unit’s drift in the issue, naming its stack file, since that unit’s terragrunt.hcl is not in the repo |
generate |
keys terragucci.hcl by the paths the stack file’s unit blocks generate, and requires each unit template in the repo to include it. A stack block (a nested stack) stops generate with an error, since only Terragrunt can list its units |
Ephemeral copies, state export, migrations |
generate the stack before they prepare a generated unit |
| Locks before merge | a change to a stack file or a template is outside every unit’s directory, so it locks every unit |
Layer-by-layer apply
Section titled “Layer-by-layer apply”terragucci applies a change one dependency layer at a time, and every layer applies only the plans a person approved.
- The layer’s wave plans its units with one
terragrunt run --all, saving each unit’s plan. Units of later layers are not planned for real yet; the pull request only previewed them. - Next comes the set digest over the saved plans that change a resource or an output, and the gate holds the wave until an approval of that digest.
- A second
run --allapplies exactly those saved plans. Nothing is planned again between the approval and the apply. - The next layer’s wave then plans against the state the layer before it left, and waits at its own gate. Its approval covers that real plan, not the preview made before its upstream applied.
What you might expect from run --all apply |
What a terragucci wave does |
|---|---|
| every unit applies at once | one dependency layer per wave, each behind its own gate |
| a plan made at apply time | the saved plans the approval names, applied as they were |
| a downstream unit planned against values its upstream has not produced yet | planned against the state its upstream left; a plan that would read mock_outputs fails its wave |
Later layers
Section titled “Later layers”A pull request’s plan covers the units of every layer the change reaches. When a unit’s dependency block reads a unit planned in an earlier layer of the same plan, it is planned on that plan’s outputs instead of the upstream’s last applied ones. terragucci gives Terragrunt those values as the answer to the binary’s output -json for the upstream, so Terragrunt itself evaluates the unit’s dependency blocks, includes and inputs.
| A unit that reads | In the pull request’s plan |
|---|---|
| outputs its upstream’s plan knows | planned on those values; the note lists it under the units planned on another’s planned outputs, and the report’s roots[].reads says planned |
| an output its upstream knows only once it applies | not planned. The note names the output and the wave that settles it: reads `out` of live/app, unknown until wave 2 applies. Its wave plans again once that wave applied, and the note gives no digest for it |
| an upstream that has no plan in this run: it failed, or waits for its own upstream | not planned, and the note says which upstream |
| an upstream whose plan changes no output, or that the change does not reach | planned on the upstream’s applied outputs, which stand |
No unit is planned on a placeholder for an unknown value or on mock_outputs. A unit that reads its upstream without asking the binary (as Terragrunt does with TG_DEPENDENCY_FETCH_OUTPUT_FROM_STATE) gets no preview; the note says so.
With a base branch the plan also previews, layer by layer, the dependents of the units the change reaches; these provisional plans are outside every digest. Under dependents: follow the dependents are only listed, as planned once what they wait for applies.
After the merge each wave plans again on the state the earlier waves left, and its gate binds that plan. When a unit’s plan differs from the preview in the merged pull request’s plan note, the wave names each resource and attribute that moved before it prints its approve command:
wave 2 of 3: pull request 12 previewed live/app on the planned outputs of the waves before; this unit plans differently now:
wave 2 of 3: live/app: terraform_data.this (update): input differs from the previewThe wave’s report keeps the comparison in waves[].preview, and its note shows it.
Terragrunt Scale
Section titled “Terragrunt Scale”import terragrunt-scale reads Gruntwork Pipelines’ .gruntwork/*.hcl and each unit’s gruntwork.hcl, and writes each environment’s roles as terragrunt.credentials.
npx terragucci import terragrunt-scaleenvironment "prod" {
filter {
paths = ["live/prod/*"]
}
authentication {
aws_oidc {
account_id = aws.accounts.all.prod.id
plan_iam_role_arn = "arn:aws:iam::${aws.accounts.all.prod.id}:role/pipelines-plan"
apply_iam_role_arn = "arn:aws:iam::${aws.accounts.all.prod.id}:role/pipelines-apply"
}
}
}becomes, with prod’s id from accounts.yml:
terragrunt:
credentials:
"live/prod/**":
plan: arn:aws:iam::222222222222:role/pipelines-plan
apply: arn:aws:iam::222222222222:role/pipelines-applyA role built with a function is listed as not mapped. A filter path that matches no unit is named. The legacy .gruntwork/config.yml names no roles, so the import refuses it.
| Setting | Terragrunt Scale | terragucci |
|---|---|---|
| Roles | an environment’s filter.paths and aws_oidc roles; a unit’s gruntwork.hcl |
terragrunt.credentials: each path and the units below it, mapped to its plan and apply role; a unit’s own roles come first, by its path |
| Accounts | aws.accounts and its accounts.yml |
read for the account ids the role ARNs name |
| No authentication | authentication {} |
no key: a unit no glob matches runs with the runner’s credentials |
| Other clouds | azure_oidc, gcp_oidc, custom |
none by unit path: oidc.azure or oidc.gcp sets one pair for every unit |
| Binary | repository.tf_binary |
binary |
| Other settings | repository, annotation |
not mapped: set env, steps or terragrunt.exclude by hand |
- The same shop on Terragrunt runs this on an example.
- The generated pipeline shows how roles follow unit paths.
These docs count page views and clicks with PostHog. They set no cookies, store nothing in your browser, and send nothing when your browser asks not to be tracked.