Skip to content

DRY Configuration with include

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.

Last lesson’s vpc/terragrunt.hcl opened with this block, and it deserves a proper explanation now:

prod/us-east-1/vpc/terragrunt.hcl
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:

prod/us-east-1/region.hcl
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:

prod/us-east-1/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
locals {
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//vpc?ref=v1.4.0"
}
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"]
root.hcl and region.hcl combining into one unit's config

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:

root.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
prod/us-east-1/region.hcl
locals {
aws_region = "us-east-1"
}
prod/us-east-1/vpc/terragrunt.hcl
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"
}
prod/us-east-1/vpc/main.tf
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 -- plan
STDOUT terraform: region=us-east-1 cidr=10.0.0.0/16

A 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.

When reviewing generated multi-include configs, check for:

  • A second include block with no expose = true at all, then code elsewhere that tries to reference include.region.locals.aws_region — proven above, expose is what makes include.NAME a readable value in this unit’s config; without it, that reference fails to resolve.
  • merge_strategy left at its default (deep merge) on an intermediate include that was only meant to contribute locals — a region.hcl that later grows a remote_state or generate block (perhaps copy-pasted from root.hcl during a refactor) would then deep-merge those blocks into the unit too, silently overriding what the root include 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, not local.region_vars.aws_region), and code that forgets the extra .locals hop gets a null/error instead of the value.
  • Hardcoded relative paths (../../../root.hcl) in place of find_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

prod/us-east-1/vpc/terragrunt.hcl
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).

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.

ChoiceBenefitCost
include + find_in_parent_folders over a hardcoded relative pathSurvives 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_mergeScoped sharing to part of the tree, values reachable by name (include.region.locals.aws_region), proven aboveOne more block per unit to keep consistent; forgetting expose (proven above) silently breaks any reference to it
read_terragrunt_config() over a second includeReads any Terragrunt file, even one not meant to act as a peer config, without an include block at allReturn 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 fileCan 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
  • “include always deep-merges everything from the target file.” Proven above: merge_strategy = "no_merge" on the region include means only its locals values are exposed, not deep-merged as blocks — the default merge behavior (used for root) 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 real backend.tf/provider.tf): expose only makes the parsed config readable as include.NAME.FIELD inside 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 .locals on whatever variable you assign it to; an exposed include reaches the same data at include.NAME.locals.KEY — similar shape, different starting point, and neither one omits the .locals hop.
  • “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.
What does the include block with find_in_parent_folders actually do?
What is read_terragrunt_config used for with an intermediate file like region.hcl?
The inputs block in a unit terragrunt.hcl is the Terragrunt equivalent of what?
Why does find_in_parent_folders keep working after a unit is moved deeper into a new subdirectory?
A unit has two include blocks, "root" and "region", neither with expose = true. Code in the unit references include.region.locals.aws_region. What actually happens, proven above?
What is the real difference between the value read_terragrunt_config(find_in_parent_folders("region.hcl")) returns and what an exposed include "region" reference gives you?