DRY Configuration with include
The idea in one sentence
Section titled “The idea in one sentence”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:
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:
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:
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"]
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:
remote_state { backend = "local" generate = { path = "backend.tf" if_exists = "overwrite_terragrunt" } config = { path = "${get_terragrunt_dir()}/terraform.tfstate" }}locals { subscription_id = "11111111-1111-1111-1111-111111111111" environment = "networking"}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"]}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 -- planSTDOUT terraform: subscription=11111111-1111-1111-1111-111111111111 address_space=10.1.0.0/16A 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.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated multi-include configs, check for:
- A second
includeblock with noexpose = true, then code that referencesinclude.subscription.locals.subscription_id— proven above,exposeis what makesinclude.NAMEa readable value; without it, that reference fails to resolve. merge_strategyleft at its default (deep merge) on an intermediate include meant only to contributelocals— asubscription.hclthat later grows a strayremote_stateblock would deep-merge it into every unit, silently overriding the root’s.read_terragrunt_config()called without the extra.localsaccessor — 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
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).
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
A second include (subscription) with expose + no_merge | Scoped sharing to part of the tree, values reachable by name, 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 without an include block at all | Return value nests under .locals — an easy, silent mistake to get the accessor path wrong |
Common Misconceptions
Section titled “Common Misconceptions”- “include always deep-merges everything from the target file.” Proven above:
merge_strategy = "no_merge"on thesubscriptioninclude means only itslocalsvalues 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 realbackend.tf/provider.tf):exposeonly makes the parsed config readable inside HCL evaluation — nothing is written to disk.