DRY Configuration with include
The idea in one sentence
Section titled “The idea in one sentence”The include block is how a unit’s terragrunt.hcl pulls in everything defined in root.hcl — or in any intermediate config file — without retyping any of it.
include and find_in_parent_folders
Section titled “include and find_in_parent_folders”Last lesson’s vpc/terragrunt.hcl opened with this block, and it deserves a proper explanation now:
include "root" { path = find_in_parent_folders("root.hcl")}find_in_parent_folders("root.hcl") walks up the directory tree from wherever the current unit lives, one directory at a time, until it finds a file literally named root.hcl. include "root" then merges everything that file defines into the current unit’s configuration, as though it had been typed there directly — the remote_state block, the generated backend.tf, the provider generate block, all of it.
Why the search matters: it means the exact same include line works whether a unit sits one directory below root.hcl or five directories deep. Move a unit into a new subdirectory for a reorganization, and this line keeps working unchanged — it never hard-codes a relative path like ../../../root.hcl that would break the moment the tree is reshaped.
locals and read_terragrunt_config for one level down
Section titled “locals and read_terragrunt_config for one level down”include is not the only inheritance mechanism, and it is not always the right one — sometimes you want to share a smaller, more specific value with only part of the tree, not the whole thing. Picture an intermediate region.hcl file sitting one level below the true root, covering just the units that belong to a specific AWS region:
locals { aws_region = "us-east-1"}A unit under that region’s directory reads it with read_terragrunt_config(), combined with the same find_in_parent_folders() you already know:
include "root" { path = find_in_parent_folders("root.hcl")}
locals { region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))}
terraform {}
inputs = { aws_region = local.region_vars.locals.aws_region cidr_block = "10.0.0.0/16"}read_terragrunt_config() parses another Terragrunt config file and hands back its contents as a value, and that file’s own locals block ends up nested under .locals on the result — hence local.region_vars.locals.aws_region. A unit under prod/ap-southeast-1/ would have its own region.hcl defining aws_region = "ap-southeast-1", while every unit anywhere in the tree still shares the same root.hcl for its backend and provider. Same root, different region per subtree, without a single unit hard-coding which region it belongs to inline.
inputs: the Terragrunt equivalent of a tfvars file
Section titled “inputs: the Terragrunt equivalent of a tfvars file”Every example so far has ended with an inputs = { ... } block, and it deserves its own explanation. This is how a unit supplies its underlying Terraform module’s input variables — functionally the same job a .tfvars file does for a plain Terraform root module, except expressed directly as Terragrunt HCL rather than as a separate file Terraform has to be told to load. Each key in inputs maps to a variable the module declares, and Terragrunt passes the whole block through to Terraform as -var values under the hood.
Because inputs is regular HCL rather than a static .tfvars file, it can reference anything else in the same config — local.region_vars.locals.aws_region above is one example, and the next lesson introduces referencing another unit’s outputs the same way.
flowchart TD root["root.hcl (remote_state + generate)"] region["prod/us-east-1/region.hcl (locals.aws_region)"] unit["prod/us-east-1/vpc/terragrunt.hcl"] root -->|include + find_in_parent_folders| unit region -->|read_terragrunt_config + find_in_parent_folders| unit unit --> inputs["inputs = aws_region, cidr_block"]
Proof: two include blocks with expose = true, real terragrunt render
Section titled “Proof: two include blocks with expose = true, real terragrunt render”read_terragrunt_config() is one way to pull in region.hcl’s data; a second, more direct technique is a second include block, using expose = true on both so their contents become readable as include.NAME.FIELD in this unit’s own config, and merge_strategy = "no_merge" on the non-root include so region.hcl only contributes its own data (its locals) rather than being deep-merged as if it were a second root config:
remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}locals { aws_region = "us-east-1"}include "root" { path = find_in_parent_folders("root.hcl") expose = true}
include "region" { path = find_in_parent_folders("region.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "."}
inputs = { aws_region = include.region.locals.aws_region cidr_block = "10.0.0.0/16"}terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}variable "aws_region" { type = string }variable "cidr_block" { type = string }resource "local_file" "vpc" { filename = "${path.module}/vpc.txt" content = "region=${var.aws_region} cidr=${var.cidr_block}\n"}Real terragrunt render --format json from inside prod/us-east-1/vpc/, trimmed:
$ terragrunt render --format json{ "inputs": { "aws_region": "us-east-1", "cidr_block": "10.0.0.0/16" }, "remote_state": { "backend": "local", "config": { "path": ".../terraform.tfstate" }, ... }, "terraform": { "source": "." }}inputs.aws_region reads "us-east-1" — pulled from include.region.locals.aws_region, which only exists because expose = true was set on the region include, combined with merge_strategy = "no_merge" keeping region.hcl’s locals scoped to include.region.locals instead of merging into this unit’s own top-level locals. Meanwhile remote_state.backend is "local", inherited from the root include exactly as in every earlier lesson. And a real plan proves the value actually reaches the module, not just the rendered config:
$ terragrunt run --non-interactive -- planSTDOUT terraform: region=us-east-1 cidr=10.0.0.0/16A unit under prod/ap-southeast-1/ with its own region.hcl defining aws_region = "ap-southeast-1" would resolve to that value instead — same root.hcl, same unit code, different region.hcl per subtree, with the include "region" block itself unchanged (it always says find_in_parent_folders("region.hcl"), and which physical region.hcl that resolves to depends purely on where the unit sits).
read_terragrunt_config vs a second include: when each fits
Section titled “read_terragrunt_config vs a second include: when each fits”Both mechanisms genuinely read another Terragrunt config file’s contents into the current unit — the difference is what you get back and how you access it. read_terragrunt_config(find_in_parent_folders("region.hcl")) (shown earlier in this lesson) returns the entire parsed file as a value assigned to a locals key you name yourself (local.region_vars.locals.aws_region above) — useful when you want to read a file that is not meant to be included as a peer config (for instance, reading a sibling unit’s config to extract one value, not to merge its blocks). A second include block with expose = true (proven above) is the more direct fit specifically for a file that is meant to act like a lightweight second root — its locals become include.NAME.locals.KEY with no extra naming step, and (without merge_strategy = "no_merge") it can also contribute other real blocks the same way the root include does.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated multi-include configs, check for:
- A second
includeblock with noexpose = trueat all, then code elsewhere that tries to referenceinclude.region.locals.aws_region— proven above,exposeis what makesinclude.NAMEa readable value in this unit’s config; without it, that reference fails to resolve. merge_strategyleft at its default (deep merge) on an intermediate include that was only meant to contributelocals— aregion.hclthat later grows aremote_stateorgenerateblock (perhaps copy-pasted fromroot.hclduring a refactor) would then deep-merge those blocks into the unit too, silently overriding what therootinclude already set, depending on include order.read_terragrunt_config()called without wrapping the result correctly — the return value nests under.locals(proven above:local.region_vars.locals.aws_region, notlocal.region_vars.aws_region), and code that forgets the extra.localshop gets a null/error instead of the value.- Hardcoded relative paths (
../../../root.hcl) in place offind_in_parent_folders("root.hcl")— works today, breaks silently (wrong file, or no file) the moment a unit moves to a different nesting depth, with no error until something downstream reads a plainly wrong value.
🐛 Spot the bug
include "root" {path = find_in_parent_folders("root.hcl")}
include "region" {path = find_in_parent_folders("region.hcl")}
terraform {source = "."}
inputs = {aws_region = include.region.locals.aws_region}Show the answer
Neither include block sets expose = true. Proven above, expose is exactly what makes an include’s contents readable through the include.NAME reference — without it, include.region.locals.aws_region does not resolve to anything usable, and this config fails at evaluation instead of silently reading a wrong value. The fix is the version shown earlier in this lesson: add expose = true to both blocks (and merge_strategy = "no_merge" to the non-root one, so region.hcl only contributes its locals rather than being deep-merged as a second root).
Mental Model
Section titled “Mental Model”include with find_in_parent_folders is a dynamic, position-independent version of copy-paste: whatever root.hcl (or region.hcl) contains gets merged into the current unit’s evaluated config, proven above via a real render that shows the unit’s own three-line terragrunt.hcl ending up with a fully resolved remote_state block and an aws_region input it never typed out itself. expose is the difference between “merged in as blocks” (the default, how root’s remote_state reaches every unit) and “also readable as a value” (include.NAME.FIELD) — proven above, without it the region include’s locals are inert as far as inputs referencing include.region.locals.aws_region is concerned.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
include + find_in_parent_folders over a hardcoded relative path | Survives a unit being moved to a different nesting depth, proven by the mechanism itself (search, not a fixed path) | A typo in the target filename fails only when Terragrunt actually walks the tree looking for it — no compile-time check |
A second include (region) with expose + no_merge | Scoped sharing to part of the tree, values reachable by name (include.region.locals.aws_region), proven above | One more block per unit to keep consistent; forgetting expose (proven above) silently breaks any reference to it |
read_terragrunt_config() over a second include | Reads any Terragrunt file, even one not meant to act as a peer config, without an include block at all | Return value nests under .locals (proven above) — an easy, silent mistake to get the accessor path wrong |
inputs as real HCL instead of a static .tfvars file | Can reference local.*/include.* values computed elsewhere in the same config (proven above: include.region.locals.aws_region) | Less portable than a plain .tfvars if a config ever needs to run under bare Terraform without Terragrunt |
Common Misconceptions
Section titled “Common Misconceptions”- “include always deep-merges everything from the target file.” Proven above:
merge_strategy = "no_merge"on theregioninclude means only itslocalsvalues are exposed, not deep-merged as blocks — the default merge behavior (used forroot) is a choice, not the only option. - “expose = true copies the file’s content physically, like generate does.” Proven above by contrast with
generate(previous lessons, writes a realbackend.tf/provider.tf):exposeonly makes the parsed config readable asinclude.NAME.FIELDinside HCL evaluation — nothing is written to disk. - “read_terragrunt_config() and a second include block return data in the same shape.” Proven above:
read_terragrunt_config()’s result nests the file’s locals under.localson whatever variable you assign it to; anexposed include reaches the same data atinclude.NAME.locals.KEY— similar shape, different starting point, and neither one omits the.localshop. - “find_in_parent_folders needs to know how many directories to walk up.” Proven above: it works identically whether the unit is one level or five levels below
root.hcl— it searches, it does not count.