Skip to content

Terragrunt HCL and the Root Config

A Terragrunt unit is one deployable piece of infrastructure with its own terragrunt.hcl and its own Terraform state, and a shared root.hcl file above it holds the configuration every unit needs without any of them repeating it.

In the Modules module you saw that a Terraform root module is just the directory you run terraform apply in directly. Terragrunt reuses that exact idea and gives it its own name: a unit. A unit is a directory containing its own terragrunt.hcl file, wrapping one Terraform root module, tracked by its own separate state file. A vpc unit, an ec2 unit, and an rds unit are three independent units — three directories, three terragrunt.hcl files, three state files — even though in a single-Terraform-project world you might have been tempted to manage all three resources from one root module and one shared state file.

Splitting infrastructure into units this way is a deliberate trade: smaller, independently applied pieces of state mean a mistake in one unit cannot corrupt or lock the state of another, and a plan for ec2 does not have to evaluate every resource inside vpc and rds just to show you what changed. The cost is that units now need a way to share the configuration they all still have in common — which is exactly what root.hcl is for.

Picture a directory layout with one config file at the very top of an account or environment tree, and a terragrunt.hcl file inside every unit directory beneath it. That top-level file used to be named terragrunt.hcl too, since Terragrunt never required any particular name for it. That naming still works today, and plenty of existing repositories and tutorials still use it, but it is ambiguous the moment someone says “check terragrunt.hcl” — do they mean the shared root file, or the specific unit you happen to be standing in.

The current recommended convention names that shared parent file root.hcl instead, precisely to remove that ambiguity: root.hcl is unmistakably the one shared file at the top, and terragrunt.hcl is unambiguously a specific unit’s own config. This course uses root.hcl throughout.

remote_state and generate: one backend, not copy-pasted per unit

Section titled “remote_state and generate: one backend, not copy-pasted per unit”

Every unit needs a Terraform backend — in an AWS setup, almost always an S3 bucket for the state file. Declaring that backend by hand inside every single unit is exactly the kind of repetition Terragrunt exists to remove. Instead, root.hcl declares it once with a remote_state block, and its generate sub-block tells Terragrunt to write out a backend.tf file into each unit right before Terraform runs there:

root.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}

path_relative_to_include() gives each unit its own unique key inside the shared bucket, based on where that unit sits relative to the file that includes this one, so the vpc unit and the ec2 unit automatically land at different state paths without either of them writing that path out literally. if_exists = "overwrite_terragrunt" tells Terragrunt it is safe to overwrite a backend.tf it generated on a previous run.

A top-level generate block works the same way for provider configuration — commonly placed right alongside remote_state in root.hcl:

generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "us-east-1"
}
EOF
}

Both blocks produce ordinary .tf files — backend.tf and provider.tf — sitting next to the actual Terraform code Terragrunt runs. Terraform itself never knows those files were generated; from its perspective they are just configuration that happened to already be there.

The terraform block’s source: pointing a unit at its module

Section titled “The terraform block’s source: pointing a unit at its module”

remote_state and the provider generate block live in root.hcl because every unit shares them. What differs per unit is which actual Terraform module it runs, and that lives in the unit’s own terragrunt.hcl, inside a terraform block:

vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//vpc?ref=v1.4.0"
}
inputs = {
cidr_block = "10.0.0.0/16"
}

source can point at a local relative path during development or, as above, a Git URL pinned to a tag — the same module addressing you already know from a Terraform module block’s source argument, just expressed at the unit level instead of inside main.tf. The include line is what pulls in everything root.hcl defined — the full mechanics of include are the subject of the next lesson; for now, just notice that this three-line unit config gets a working S3 backend and AWS provider for free, without repeating a single line of either.

flowchart TD
  root["root.hcl (remote_state + generate)"]
  root --> vpc["vpc/terragrunt.hcl"]
  root --> ec2["ec2/terragrunt.hcl"]
  root --> rds["rds/terragrunt.hcl"]
  vpc --> vpcmod["terraform.source: modules//vpc"]
  ec2 --> ec2mod["terraform.source: modules//ec2"]
  rds --> rdsmod["terraform.source: modules//rds"]
  vpc -.->|inherits generated backend.tf + provider.tf| root
  ec2 -.->|inherits generated backend.tf + provider.tf| root
  rds -.->|inherits generated backend.tf + provider.tf| root
One root.hcl feeding several units, each pointing at its own module

Proof: terragrunt render resolving find_in_parent_folders for real

Section titled “Proof: terragrunt render resolving find_in_parent_folders for real”

The claims above are not asserted — they come from a real Terragrunt 1.1.0 unit tree. root.hcl at the top of a scratch tree:

root.hcl
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "us-east-1"
}
EOF
}

And vpc/terragrunt.hcl, using find_in_parent_folders("root.hcl") — Terragrunt walks up from the current unit directory looking for a file with that exact name, so vpc/ never has to hardcode how many directories separate it from the root:

vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "."
}
inputs = {
cidr_block = "10.0.0.0/16"
}

Running terragrunt render --format json from inside vpc/ (no apply, no network — render only parses and evaluates HCL) prints the fully resolved config Terragrunt is about to hand to Terraform, real output trimmed:

$ terragrunt render --format json
{
"remote_state": {
"backend": "s3",
"config": {
"bucket": "acme-terraform-state",
"encrypt": true,
"key": "vpc/terraform.tfstate",
"region": "us-east-1",
"use_lockfile": true
},
"generate": { "if_exists": "overwrite_terragrunt", "path": "backend.tf" }
},
"generate": {
"provider": {
"contents": "provider \"aws\" {\n region = \"us-east-1\"\n}\n",
"if_exists": "overwrite_terragrunt",
"path": "provider.tf"
}
},
"inputs": { "cidr_block": "10.0.0.0/16" },
"terraform": { "source": "." }
}

The proof is in "key": "vpc/terraform.tfstate" — path_relative_to_include() evaluated to the literal string "vpc" because that’s where this unit sits relative to the root.hcl it includes, and it got interpolated into the key with zero hardcoding on the unit’s side. An ec2/terragrunt.hcl unit with the exact same three-line body would resolve to "key": "ec2/terraform.tfstate" instead — same root.hcl, different key, purely from directory position.

generate does not call AWS. It writes an ordinary .tf file into the unit’s working copy before Terraform ever runs. Real content, captured from Terragrunt’s own .terragrunt-cache working directory (never initialized against a real bucket — this course has no AWS credentials, and this lesson proves the file, not a live backend):

backend.tf
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "s3" {
bucket = "acme-terraform-state"
encrypt = true
key = "vpc/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}

That # Generated by Terragrunt comment and signature line are real — Terragrunt writes them so a later run can recognize a file it owns and safely if_exists = "overwrite_terragrunt" it, without ever touching a backend.tf a human wrote by hand under a different name or signature.

remote_state auto-create, and why it differs per cloud

Section titled “remote_state auto-create, and why it differs per cloud”

remote_state does more than generate a backend file — for a subset of backends, Terragrunt will also create the storage resource itself the first time a unit runs, if it doesn’t already exist. Per the official backend docs (“Create remote state resources automatically”), this differs by cloud in a way worth being precise about, not hand-waved:

  • S3 (this course) — if the bucket named in config doesn’t exist, Terragrunt creates it with versioning, server-side encryption, and access logging enabled, no extra flag required. use_lockfile = true (shown above) is S3-native locking via conditional writes, added in Terraform 1.10 as experimental and stable since Terraform 1.11 — it needs no separate DynamoDB table. The older path, dynamodb_table = "...", still works and Terragrunt auto-creates that table too (primary key LockID, encrypted); the docs explicitly show both configured together during a migration window, since both locks must succeed before an operation proceeds.
  • Azure (azurerm) — also supported, but with a real version boundary worth calling out precisely: quoting the Terragrunt experiments registry directly, “Terragrunt manages lifecycle operations for the Azure Storage (azurerm) remote state backend, matching what it already does for S3 buckets and GCS buckets: Bootstrap: creates the resource group (when allowed), storage account, and blob container backing the state… Delete… Migrate…” — but on the Terragrunt 1.1.0 installed for this course, that bootstrap/delete/migrate lifecycle is still gated: “azurerm backend … <Before version="v1.2.0"> (experimental; bootstrap, delete, and migrate require the azure-backend experiment)” per docs.terragrunt.com/features/state-backend. It graduated to on-by-default in Terragrunt 1.2.0. So on 1.1.0, auto-creating the storage account needs --experiment azure-backend explicitly — without it, remote_state for azurerm still writes backend.tf via generate exactly like S3/GCS (proven: this course’s own terragrunt render/terraform init against an azurerm block reaches a real Azure authentication error, not an “experiment not enabled” error), but it will not attempt to create the storage account for you.
  • GCP (gcs) — supported, and requires two extra keys the S3 backend doesn’t need: quoting the docs directly, “If the bucket you specify in remote_state.config doesn’t already exist, Terragrunt will create it automatically, with versioning enabled. For this to work correctly you must also specify project and location keys in remote_state.config, so Terragrunt knows where to create the bucket.” Omit either one and auto-create cannot happen — GCS bucket names are a flat global namespace with no notion of “current project” the way an S3 bucket’s owning account is implicit, so Terragrunt has nowhere to infer them from.

When reviewing generated root.hcl/unit configs, check for:

  • A hardcoded key in remote_state.config instead of "${path_relative_to_include()}/terraform.tfstate" — proven below, this makes every unit that includes the same root silently share one state file, and a second unit’s apply can destroy the first unit’s resources without ever raising an error about it.
  • generate blocks missing if_exists = "overwrite_terragrunt" — the default is "error", so a second run of a fresh checkout fails immediately with “file already exists” the moment a previous run already wrote that backend.tf.
  • A GCS remote_state block missing project or location — silently unable to auto-create the bucket (proven above from the docs), which surfaces as a generic “bucket not found” error at apply time that looks unrelated to the missing keys.
  • An azurerm remote_state block on Terragrunt < 1.2.0 that assumes the storage account gets created automatically with no extra flag — proven above, bootstrap needs --experiment azure-backend on this course’s installed 1.1.0.
  • terraform { source = "..." } with no ?ref= pin on a Git URL — every unit resolves against whatever the default branch currently contains, so two apply runs an hour apart can pull genuinely different module code with no diff visible in the unit’s own terragrunt.hcl.

🐛 Spot the bug

# root.hcl — shared by every unit
remote_state {
backend = "s3"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
key = "terraform.tfstate"
region = "us-east-1"
encrypt = true
}
}
Show the answer

The bug is key = "terraform.tfstate" — a literal string, not "${path_relative_to_include()}/terraform.tfstate". Proven above, path_relative_to_include() is what makes the vpc unit resolve to "vpc/terraform.tfstate" and an ec2 unit resolve to "ec2/terraform.tfstate"; with a hardcoded literal, both units resolve to the exact same S3 object key. Terragrunt never warns about this — nothing about a bucket+key pair “already being state for another unit” is checked. The first symptom is usually ec2’s apply reporting it wants to destroy resources nobody remembers creating: those are actually vpc’s resources, because both units have been reading and overwriting the same state object the entire time.

root.hcl is not magic inheritance — it is one file that include copies into every unit’s evaluated config, the same way a shared header gets #included into multiple C source files. Nothing is shared at runtime; each unit still runs its own independent terraform init/plan/apply against its own generated backend.tf. The key’s uniqueness (via path_relative_to_include()) is the only thing standing between “independent units with independent state” and “units that silently corrupt each other’s state” — proven directly above. Treat a Terragrunt unit exactly the way you’d treat a Terraform root module, because that is literally what it is: terragrunt.hcl plus include just automates writing the backend and provider blocks that a hand-run root module would otherwise need typed out by hand, every time.

ChoiceBenefitCost
Splitting infrastructure into units (own state per directory)A mistake or lock in one unit cannot touch another; smaller plansCross-unit references need dependency blocks (next lesson) instead of a same-state resource reference
remote_state + generate centralizing the backendOne place to change the bucket/region for every unit; zero copy-pasted backend blocksThe generated file is invisible until you go look for it in .terragrunt-cache — a terraform init run directly inside a unit directory (bypassing Terragrunt) will not see it
Cloud auto-create (S3/GCS immediately, Azure gated behind an experiment pre-1.2.0)First-run convenience — no separate bootstrap step for the state bucket itself, once availableProven above: this convenience is not uniform across clouds, and getting it wrong on Azure pre-1.2.0 looks like a real auth failure, not a missing-flag failure
  • “root.hcl and terragrunt.hcl are different file formats.” They are the same HCL dialect — proven above, this course’s own root.hcl uses remote_state/generate, identical blocks to what a unit’s terragrunt.hcl can also contain. The name is pure convention to avoid ambiguity, nothing enforces it.
  • “generate writes directly into my unit directory, so I’ll see backend.tf right there.” Proven above: the real backend.tf this lesson captured lives inside .terragrunt-cache/HASH/HASH/backend.tf, Terragrunt’s working copy — not the source directory you edit.
  • “Every cloud backend Terragrunt supports auto-creates its storage the same way.” Proven above with the docs quote and a real run: S3 and GCS auto-create with no extra flag (GCS needs project/location to know where); azurerm on this course’s installed Terragrunt 1.1.0 needs --experiment azure-backend for the bootstrap/delete/migrate lifecycle specifically, graduating to on-by-default only in 1.2.0.
  • “use_lockfile is a Terragrunt feature.” It’s a Terraform/OpenTofu S3 backend feature (stable since Terraform 1.11, experimental in 1.10) that Terragrunt’s remote_state.config simply passes through into the generated backend.tf unchanged — proven above, it appears verbatim inside the terraform { backend "s3" {} } block.
What is a Terragrunt unit?
Why is the shared parent config file now conventionally named root.hcl instead of terragrunt.hcl?
What does a remote_state block with a generate sub-block actually produce?
Where does the terraform source attribute pointing at the actual module code belong?
A root.hcl sets key = "terraform.tfstate" (a literal string) instead of "${path_relative_to_include()}/terraform.tfstate". What actually happens when two units, vpc and ec2, both include this root.hcl?
On this course's installed Terragrunt 1.1.0, what happens if you configure an azurerm remote_state block with no storage account created yet, and no --experiment azure-backend flag?