DRY Configuration with include
The idea in one sentence
Section titled “The idea in one sentence”A unit’s terragrunt.hcl uses an include block plus find_in_parent_folders() to pull in everything root.hcl already defines, and can layer in smaller intermediate config files the same way, so none of that shared setup is ever typed twice.
The include block: inheriting root.hcl without repeating it
Section titled “The include block: inheriting root.hcl without repeating it”The previous lesson showed a unit’s terragrunt.hcl referencing root.hcl in three lines, without explaining how that reference works. The mechanism is the include block:
include "root" { path = find_in_parent_folders("root.hcl")}include "root" tells Terragrunt to read the file at path and merge its remote_state, generate, and any other top-level blocks into this unit’s own config, as though the unit had written all of that itself. The label "root" is just a name for this particular include block — a unit could in principle have more than one include, each pulling in a different file, and the label is how you would tell them apart. Without this block, every unit would need its own copy of the remote_state and generate blocks from the previous lesson, which is exactly the repetition Terragrunt is meant to remove.
find_in_parent_folders: walking up the tree to find the file
Section titled “find_in_parent_folders: walking up the tree to find the file”find_in_parent_folders("root.hcl") does not assume any fixed distance between a unit and the shared config above it. It starts in the unit’s own directory and walks upward, one parent directory at a time, until it finds a file named root.hcl, then returns the path to it. That means a unit two levels deep and a unit five levels deep can both write the exact same include block and still find the correct file:
acme-infra/├── root.hcl└── prod/ ├── project.hcl └── network/ └── terragrunt.hclFrom prod/network/terragrunt.hcl, find_in_parent_folders("root.hcl") climbs past prod/ and lands on acme-infra/root.hcl. If no file with that name exists anywhere above the unit, Terragrunt raises an error rather than silently proceeding without it — a missing root.hcl is treated as a configuration mistake, not something to skip past.
locals and read_terragrunt_config: sharing config between levels
Section titled “locals and read_terragrunt_config: sharing config between levels”include is built for one specific job: inheriting the single shared root.hcl. Plenty of config is not truly global, though — it might apply to just one GCP project’s worth of units, sitting one level below the root. For that, a locals block combined with read_terragrunt_config() lets any unit read a smaller, more targeted file and pull specific values out of it. Continuing the layout above, prod/project.hcl holds values specific to that one GCP project:
locals { project_id = "acme-prod-123456" region = "us-central1"}Every unit under prod/ reads it the same way:
include "root" { path = find_in_parent_folders("root.hcl")}
locals { project_vars = read_terragrunt_config(find_in_parent_folders("project.hcl"))}
inputs = { project_id = local.project_vars.locals.project_id region = local.project_vars.locals.region cidr_block = "10.0.0.0/16"}read_terragrunt_config() parses the target file and hands back its contents as a value, so local.project_vars.locals.project_id reaches into that file’s own locals block to pull out project_id. Nothing stops a directory tree from having several such files at different levels — a project.hcl per GCP project, an env.hcl per environment — each read the same way, each avoiding one more piece of copy-pasted config.
inputs: the Terragrunt equivalent of a .tfvars file
Section titled “inputs: the Terragrunt equivalent of a .tfvars file”The inputs block seen in both examples above is how a unit’s terragrunt.hcl supplies variable values to the underlying Terraform module, the same job a .tfvars file would normally do. Every key inside inputs is passed through to Terraform as a variable value for the module that unit’s terraform block points at — no separate .tfvars file needs to exist in the module at all. This is what makes combining inherited values with unit-specific ones so direct: project_id and region above come from project.hcl by way of local.project_vars, while cidr_block is written directly in the unit, and both land in the same inputs map with no special syntax to mix the two.
flowchart TD root["root.hcl (remote_state + generate)"] project["prod/project.hcl (locals: project_id, region)"] unit["prod/network/terragrunt.hcl"] root -->|include + find_in_parent_folders| unit project -->|read_terragrunt_config + find_in_parent_folders| unit unit --> inputsBlock["inputs: project_id, 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() above is one way to read project.hcl; a second, more direct technique is a second include block, using expose = true on both so their contents become readable as include.NAME.FIELD, and merge_strategy = "no_merge" on the non-root include so project.hcl only contributes its own locals:
remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}locals { project_id = "acme-prod-123456" region = "us-central1"}include "root" { path = find_in_parent_folders("root.hcl") expose = true}
include "project" { path = find_in_parent_folders("project.hcl") expose = true merge_strategy = "no_merge"}
terraform { source = "."}
inputs = { project_id = include.project.locals.project_id region = include.project.locals.region}terraform { required_providers { local = { source = "hashicorp/local", version = "~> 2.5" } }}variable "project_id" { type = string }variable "region" { type = string }resource "local_file" "network" { filename = "${path.module}/network.txt" content = "project=${var.project_id} region=${var.region}\n"}Real terragrunt render --format json, trimmed:
$ terragrunt render --format json{ "inputs": { "project_id": "acme-prod-123456", "region": "us-central1" }, "remote_state": { "backend": "local", "config": { "path": ".../terraform.tfstate" }, ... }, "terraform": { "source": "." }}inputs.project_id reads the real value — pulled from include.project.locals.project_id, which only exists because expose = true was set on the project include. A real plan proves the value reaches the module:
$ terragrunt run --non-interactive -- planSTDOUT terraform: project=acme-prod-123456 region=us-central1🐛 Spot the bug
include "root" {path = find_in_parent_folders("root.hcl")}
include "project" {path = find_in_parent_folders("project.hcl")}
terraform {source = "."}
inputs = {project_id = include.project.locals.project_id}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.project.locals.project_id does not resolve to anything usable, and this config fails at evaluation instead of silently reading a wrong value.
Reading AI-generated code
Section titled “Reading AI-generated code”- A second
includeblock with noexpose = true, then code that referencesinclude.project.locals.project_id— proven above,exposeis what makesinclude.NAMEa readable value. merge_strategyleft at its default (deep merge) on an intermediate include meant only to contributelocals— aproject.hclthat later grows a strayremote_stateblock would deep-merge it into every unit.read_terragrunt_config()called without the extra.localsaccessor — the return value nests under.locals, and code that forgets that hop gets a null instead of the value.
Mental Model
Section titled “Mental Model”include with find_in_parent_folders is a dynamic, position-independent version of copy-paste. expose is the difference between “merged in as blocks” (the default) and “also readable as a value” (include.NAME.FIELD) — proven above.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
A second include (project) with expose + no_merge | Scoped sharing, values reachable by name, proven above | Forgetting expose (proven above) silently breaks any reference to it |
read_terragrunt_config() over a second include | Reads any Terragrunt file without an include block at all | Return value nests under .locals — an easy, silent mistake |
Common Misconceptions
Section titled “Common Misconceptions”- “include always deep-merges everything from the target file.” Proven above:
merge_strategy = "no_merge"on theprojectinclude means only itslocalsvalues are exposed. - “expose = true copies the file’s content physically, like generate does.” Proven above by contrast with
generate:exposeonly makes the parsed config readable — nothing is written to disk.