Skip to content

DRY Configuration with include

An include block plus find_in_parent_folders() lets a unit pull in everything a shared config file already defines, and locals combined with read_terragrunt_config() extends that same trick to any number of intermediate files sitting between the root and a unit.

The include block: inheriting root.hcl without repeating it

Section titled “The include block: inheriting root.hcl without repeating it”

The previous lesson showed root.hcl declaring remote_state and a provider generate block once, at the top of the tree. A unit gets access to both of those without copying a single line, through an include block:

vnet/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}

find_in_parent_folders("root.hcl") walks up the directory tree, starting from the unit’s own directory, until it finds a file named root.hcl. It does not matter how many directories deep the unit lives — one level under the root or five — the function keeps climbing until it locates the file, so moving a unit to a different depth in the tree never breaks the reference. The label on the include block, "root" here, is just a name Terragrunt uses internally to track this particular inclusion; a unit can have more than one include block, each with its own label, each pointing at a different file.

Once included, every block root.hcl defines — remote_state, the provider generate block, and anything else placed there — behaves as if it had been written directly inside the unit’s own terragrunt.hcl. That is the entire mechanism: one include block replaces what would otherwise be dozens of repeated lines across every unit in the tree.

Sharing granular config downward with locals and read_terragrunt_config

Section titled “Sharing granular config downward with locals and read_terragrunt_config”

root.hcl is not the only place shared configuration can live. A large tree often has a middle layer too — for example, one subscription.hcl file per Azure subscription, sitting above every unit that belongs to that subscription, holding values specific to it:

subscriptions/sub-networking/subscription.hcl
locals {
subscription_id = "11111111-1111-1111-1111-111111111111"
environment = "networking"
}

A unit underneath that subscription reads it with read_terragrunt_config(), wrapped in a locals block of its own:

subscriptions/sub-networking/vnet/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
locals {
subscription_vars = read_terragrunt_config(find_in_parent_folders("subscription.hcl"))
}
inputs = {
subscription_id = local.subscription_vars.locals.subscription_id
address_space = ["10.1.0.0/16"]
tags = {
environment = local.subscription_vars.locals.environment
}
}

read_terragrunt_config() parses the file it is given and hands back a map of everything defined inside it, so local.subscription_vars.locals.subscription_id reaches straight into the locals block of subscription.hcl. Notice this is a different mechanism from include — include inherits blocks like remote_state wholesale, while read_terragrunt_config() gives a unit fine-grained access to specific values from a file it chooses to read, without inheriting the whole thing. That distinction is what makes it useful for an intermediate file: root.hcl still applies to every unit in the entire tree via include, while subscription.hcl only needs to be read by the handful of units that actually live under that one subscription.

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. In plain Terraform, you hand a root module its variable values with a .tfvars file or -var flags on the command line. Terragrunt’s inputs block does the same job, except the values live directly inside the unit’s own HCL instead of a separate file:

inputs = {
address_space = ["10.0.0.0/16"]
location = "westeurope"
}

Every key in inputs must match a variable the underlying Terraform module actually declares — Terragrunt passes each one straight through as if it had been supplied with -var. Because inputs is ordinary HCL, it can reference locals, the result of read_terragrunt_config(), or later in this module, another unit’s outputs — none of which a static .tfvars file could ever do.

flowchart TD
  root["root.hcl (remote_state, provider generate)"]
  sub["subscription.hcl (locals: subscription_id, environment)"]
  unit["vnet/terragrunt.hcl"]
  root -->|include find_in_parent_folders root.hcl| unit
  sub -->|read_terragrunt_config find_in_parent_folders subscription.hcl| unit
  unit --> inputs["inputs = merge of root, subscription, and unit values"]
root.hcl, an intermediate subscription.hcl, and a 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 subscription.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 subscription.hcl only contributes its own locals rather than being deep-merged as 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"
}
}
subscriptions/sub-networking/subscription.hcl
locals {
subscription_id = "11111111-1111-1111-1111-111111111111"
environment = "networking"
}
subscriptions/sub-networking/vnet/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
expose = true
}
include "subscription" {
path = find_in_parent_folders("subscription.hcl")
expose = true
merge_strategy = "no_merge"
}
terraform {
source = "."
}
inputs = {
subscription_id = include.subscription.locals.subscription_id
address_space = ["10.1.0.0/16"]
}
subscriptions/sub-networking/vnet/main.tf
terraform {
required_providers {
local = { source = "hashicorp/local", version = "~> 2.5" }
}
}
variable "subscription_id" { type = string }
variable "address_space" { type = list(string) }
resource "local_file" "vnet" {
filename = "${path.module}/vnet.txt"
content = "subscription=${var.subscription_id} address_space=${join(",", var.address_space)}\n"
}

Real terragrunt render --format json, trimmed:

$ terragrunt render --format json
{
"inputs": {
"address_space": ["10.1.0.0/16"],
"subscription_id": "11111111-1111-1111-1111-111111111111"
},
"remote_state": { "backend": "local", "config": { "path": ".../terraform.tfstate" }, ... },
"terraform": { "source": "." }
}

inputs.subscription_id reads the real value — pulled from include.subscription.locals.subscription_id, which only exists because expose = true was set on the subscription include, combined with merge_strategy = "no_merge" keeping its locals scoped to include.subscription.locals instead of merging into this unit’s own top-level locals. And a real plan proves the value reaches the module, not just the rendered config:

$ terragrunt run --non-interactive -- plan
STDOUT terraform: subscription=11111111-1111-1111-1111-111111111111 address_space=10.1.0.0/16

A unit under a different sub-compute/ directory with its own subscription.hcl defining a different subscription_id would resolve to that value instead — same root.hcl, same unit code, different subscription.hcl per subtree.

When reviewing generated multi-include configs, check for:

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

🐛 Spot the bug

subscriptions/sub-networking/vnet/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
include "subscription" {
path = find_in_parent_folders("subscription.hcl")
}
terraform {
source = "."
}
inputs = {
subscription_id = include.subscription.locals.subscription_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.subscription.locals.subscription_id 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 above: add expose = true to both blocks (and merge_strategy = "no_merge" to the non-root one).

include with find_in_parent_folders is a dynamic, position-independent version of copy-paste — proven above via a real render that shows a three-line unit ending up with a fully resolved remote_state block and a subscription_id input it never typed out itself. expose is the difference between “merged in as blocks” (the default) and “also readable as a value” (include.NAME.FIELD) — proven above, without it the subscription include’s locals are inert as far as inputs referencing them is concerned.

ChoiceBenefitCost
A second include (subscription) with expose + no_mergeScoped sharing to part of the tree, values reachable by name, 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 without an include block at allReturn value nests under .locals — an easy, silent mistake to get the accessor path wrong
  • “include always deep-merges everything from the target file.” Proven above: merge_strategy = "no_merge" on the subscription include means only its locals values are exposed, not deep-merged as blocks.
  • “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 inside HCL evaluation — nothing is written to disk.
What does an include block with find_in_parent_folders do?
What is read_terragrunt_config used for with an intermediate file such as subscription.hcl?
The inputs block in a unit terragrunt.hcl is the Terragrunt equivalent of what
Why might a tree use an intermediate subscription.hcl in addition to root.hcl
A unit has two include blocks, "root" and "subscription", neither with expose = true. Code references include.subscription.locals.subscription_id. What happens, proven above?
What does a real terragrunt render show for inputs.subscription_id when a unit uses include "subscription" { expose = true, merge_strategy = "no_merge" }?