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 network unit, a gke unit, and a cloud-sql 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 gke does not have to evaluate every resource inside network and cloud-sql 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 a project 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 — on GCP, almost always a Google Cloud Storage (GCS) 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 = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
prefix = "${path_relative_to_include()}/terraform.tfstate"
project = "acme-prod-123456"
location = "US"
}
}

path_relative_to_include() gives each unit its own unique prefix inside the shared bucket, based on where that unit sits relative to the file that includes this one, so the network unit and the gke 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. Unlike some other clouds’ backends, GCS needs no separate lock-table argument — GCS handles state locking on its own, so there is no dynamodb_table-style setting to configure here.

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 "google" {
project = "acme-prod-123456"
region = "us-central1"
}
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:

network/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "git::[email protected]:acme/infrastructure-modules.git//network?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 GCS backend and Google provider for free, without repeating a single line of either.

flowchart TD
  root["root.hcl (remote_state + generate)"]
  root --> network["network/terragrunt.hcl"]
  root --> gke["gke/terragrunt.hcl"]
  root --> sql["cloud-sql/terragrunt.hcl"]
  network --> networkmod["terraform.source: modules//network"]
  gke --> gkemod["terraform.source: modules//gke"]
  sql --> sqlmod["terraform.source: modules//cloud-sql"]
  network -.->|inherits generated backend.tf + provider.tf| root
  gke -.->|inherits generated backend.tf + provider.tf| root
  sql -.->|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 = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
prefix = "${path_relative_to_include()}/terraform.tfstate"
project = "acme-prod-123456"
location = "US"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "google" {
project = "acme-prod-123456"
region = "us-central1"
}
EOF
}

And network/terragrunt.hcl, using find_in_parent_folders("root.hcl"):

network/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "."
}
inputs = {
network_name = "app-network"
}

Running terragrunt render --format json from inside network/ (no apply, no network call — render only parses and evaluates HCL) prints the fully resolved config, real output trimmed:

$ terragrunt render --format json
{
"remote_state": {
"backend": "gcs",
"config": {
"bucket": "acme-terraform-state",
"location": "US",
"prefix": "network/terraform.tfstate",
"project": "acme-prod-123456"
},
"generate": { "if_exists": "overwrite_terragrunt", "path": "backend.tf" }
},
"generate": {
"provider": {
"contents": "provider \"google\" {\n project = \"acme-prod-123456\"\n region = \"us-central1\"\n}\n",
"if_exists": "overwrite_terragrunt",
"path": "provider.tf"
}
},
"inputs": { "network_name": "app-network" },
"terraform": { "source": "." }
}

The proof is in "prefix": "network/terraform.tfstate" — path_relative_to_include() evaluated to the literal string "network" and got interpolated into the prefix with zero hardcoding on the unit’s side. A gke/terragrunt.hcl unit with the exact same three-line body would resolve to "prefix": "gke/terraform.tfstate" instead.

The module itself is real too — google_compute_network with auto_create_subnetworks = false and an explicit project, validated with a real terraform init -backend=false && terraform validate against hashicorp/google ~> 8.0:

$ terraform validate
Success! The configuration is valid.

The real backend.tf that generate writes — and what it deliberately leaves out

Section titled “The real backend.tf that generate writes — and what it deliberately leaves out”

generate does not call GCP. It writes an ordinary .tf file before Terraform ever runs. Real content, captured from Terragrunt’s own .terragrunt-cache:

backend.tf
# Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa
terraform {
backend "gcs" {
bucket = "acme-terraform-state"
prefix = "network/terraform.tfstate"
}
}

Look closely at what is missing compared to the remote_state.config above: project and location never appear in the generated backend block at all — proven above, only bucket and prefix make it into the real terraform { backend "gcs" {} } block, because the GCS backend itself has no project/location arguments; those two keys exist purely for Terragrunt’s own auto-create logic (next section), not for Terraform’s backend configuration. Trying to actually run this (terragrunt plan) reaches a real, expected wall: storage.NewClient() failed: dialing: credentials: could not find default credentials — a genuine GCP authentication failure, not a fake error, proving generate handed a correctly-shaped backend off to Terraform before stopping at the one wall this course cannot cross (no GCP credentials).

remote_state auto-create on GCS: the two keys that matter

Section titled “remote_state auto-create on GCS: the two keys that matter”

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. Quoting the Terragrunt docs directly: “If you are using the GCS backend for remote state storage and 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.” Proven above, those two keys are consumed entirely by Terragrunt itself and never reach the generated backend.tf — omit either one and auto-create silently cannot happen, since GCS bucket names sit in one flat global namespace with no notion of “current project” the way an S3 bucket’s owning account is implicit. For comparison: S3 auto-creates with no extra keys at all, and azurerm’s equivalent bootstrap lifecycle needs an explicit --experiment azure-backend flag on this course’s installed Terragrunt 1.1.0 — GCS’s auto-create needs neither an experiment flag nor zero extra config, just these two specific keys.

🐛 Spot the bug

root.hcl
remote_state {
backend = "gcs"
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
config = {
bucket = "acme-terraform-state"
prefix = "terraform.tfstate"
}
}
Show the answer

Two bugs, both proven above. First, prefix = "terraform.tfstate" is a literal string, not "${path_relative_to_include()}/terraform.tfstate" — proven above, without the function call every unit that includes this root resolves to the exact same GCS object prefix, silently sharing one state file. Second, project and location are both missing from config — proven above from the docs quote, auto-creating the bucket needs both; without them, the first run against a bucket that doesn’t exist yet fails instead of bootstrapping it, and the failure mode looks like an unrelated “bucket not found” error rather than pointing at the two missing keys.

root.hcl is not magic inheritance — it is one file that include copies into every unit’s evaluated config. Nothing is shared at runtime; each unit still runs its own independent terraform init/plan/apply against its own generated backend.tf. Proven above, that generated file only ever contains what the GCS backend itself actually understands (bucket, prefix) — project/location are Terragrunt-side bootstrap instructions, evaluated and consumed before Terraform ever sees the file.

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)
remote_state + generate centralizing the backendOne place to change the bucket for every unitProven above: the generated file omits project/location entirely — reading the raw backend.tf alone gives an incomplete picture of what the remote_state block actually configures
GCS auto-create via project/locationFirst-run convenience, no separate bootstrap stepProven above: silently does nothing if either key is missing, with a misleading downstream error
  • “Every key inside remote_state.config ends up in the generated backend.tf.” Proven above: project and location are consumed entirely by Terragrunt’s own auto-create logic and never appear in the real generated terraform { backend "gcs" {} } block — only bucket and prefix do.
  • “GCS needs a separate lock-table setting like some other backends.” It does not — GCS handles state locking on its own, and there is no dynamodb_table-style argument anywhere in the remote_state.config shown above.
  • “generate writes directly into my unit directory.” Proven above: the real backend.tf this lesson captured lives inside .terragrunt-cache/HASH/HASH/backend.tf, not the source directory you edit.
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 declares remote_state.config with bucket, project, and location keys for a GCS backend. What actually ends up in the real generated backend.tf, proven above?
A remote_state block for GCS sets bucket but omits both project and location. What happens if that bucket does not already exist, per the docs quoted above?