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
dynamodb_table = "acme-terraform-locks"
}
}

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
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?