Skip to content

DRY Configuration with include

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:

network/terragrunt.hcl
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.hcl

From 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:

prod/project.hcl
locals {
project_id = "acme-prod-123456"
region = "us-central1"
}

Every unit under prod/ reads it the same way:

prod/network/terragrunt.hcl
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"]
root.hcl, an intermediate project.hcl, and a leaf unit combining both

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:

root.hcl
remote_state {
backend = "local"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
path = "${get_terragrunt_dir()}/terraform.tfstate"
}
}
prod/project.hcl
locals {
project_id = "acme-prod-123456"
region = "us-central1"
}
prod/network/terragrunt.hcl
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
}
prod/network/main.tf
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 -- plan
STDOUT terraform: project=acme-prod-123456 region=us-central1

🐛 Spot the bug

prod/network/terragrunt.hcl
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.

  • A second include block with no expose = true, then code that references include.project.locals.project_id — proven above, expose is what makes include.NAME a readable value.
  • merge_strategy left at its default (deep merge) on an intermediate include meant only to contribute locals — a project.hcl that later grows a stray remote_state block would deep-merge it into every unit.
  • read_terragrunt_config() called without the extra .locals accessor — the return value nests under .locals, and code that forgets that hop gets a null instead of the value.

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.

ChoiceBenefitCost
A second include (project) with expose + no_mergeScoped sharing, values reachable by name, proven aboveForgetting expose (proven above) silently breaks any reference to it
read_terragrunt_config() over a second includeReads any Terragrunt file without an include block at allReturn value nests under .locals — an easy, silent mistake
  • “include always deep-merges everything from the target file.” Proven above: merge_strategy = "no_merge" on the project include means only its locals values are exposed.
  • “expose = true copies the file’s content physically, like generate does.” Proven above by contrast with generate: expose only makes the parsed config readable — nothing is written to disk.
What does an include block with find_in_parent_folders actually do?
What is read_terragrunt_config paired with a locals block typically used for?
The inputs block in a unit terragrunt.hcl is the Terragrunt equivalent of what?
What happens if find_in_parent_folders cannot locate the named file anywhere above the unit?
A unit has two include blocks, "root" and "project", neither with expose = true. Code references include.project.locals.project_id. What happens, proven above?
What does a real terragrunt render show for inputs.project_id when a unit uses include "project" { expose = true, merge_strategy = "no_merge" }?