Why Terragrunt
The idea in one sentence
Section titled “The idea in one sentence”Terraform workspaces solve DRY reuse but leave isolation weak, directory-per-environment solves isolation but reintroduces duplicated backend and provider boilerplate, and Terragrunt exists to close that remaining gap by wrapping the real terraform binary and generating the repeated configuration for you.
The exact tension, restated precisely
Section titled “The exact tension, restated precisely”The last two lessons each solved half of the problem from the very first lesson in this module:
- Workspaces keep one copy of the
.tfcode, one backend, and one provider configuration — genuinely DRY — but every workspace shares that same backend and the same GCPproject, so isolation between environments is weak enough that a wrongterraform workspace selectcan apply prod-sized changes against what someone assumed was a disposable environment. - Directory-per-environment gives every environment its own backend and its own GCP project — genuinely isolated — but the backend block, the provider block, and the module
sourceand version wiring end up copy-pasted nearly verbatim into every environment directory, which is the exact same manual-repetition risk the first lesson in this module opened with.
Plain Terraform, on its own, does not have a single primitive that gives you both strong isolation and zero configuration duplication across environments at the same time. You can pick one of the two tensions above, but not escape both with .tf code alone.
What Terragrunt actually is
Section titled “What Terragrunt actually is”Terragrunt is a thin wrapper and orchestrator that sits on top of the real terraform (or tofu) binary. It does not introduce a new resource syntax, and it does not compile to some different infrastructure-as-code language — the .tf module code you already write, including everything in the modules/app directory from the previous lesson, stays completely unchanged and is still plain Terraform. Every single Terragrunt command ultimately shells out to a real terraform (or tofu) command underneath:
# What you typeterragrunt apply
# What actually eventually runs, per unit, with generated config already in placeterraform applyWhat Terragrunt adds is a layer that manages the repeated boilerplate you just saw pile up around calling Terraform once per environment:
- Generating backend configuration from one central definition instead of copy-pasting the same
backend "gcs" { ... }block into every environment directory. - Generating provider configuration the same way, from one place, instead of a near-identical
provider "google" { ... }block per environment. - Supplying each environment’s input variables to the shared module so
dev,staging, andprodstill call onemodules/appsource with different values, without hand-wiring a separatemoduleblock per environment. - Understanding dependency ordering when infrastructure is split across multiple independently-applied units — for example, a
networkunit that agkeunit depends on — so Terragrunt can figure out thatnetworkneeds to apply beforegkerather than you tracking that ordering by hand across separateterraform applyruns.
That last point matters specifically because directory-per-environment splits infrastructure into genuinely separate state files per environment (and, as the next module covers, often per component too). A single Terraform configuration has its own internal dependency graph between resources in one state file, but it has no built-in concept of ordering across entirely separate configurations and separate terraform apply invocations — that cross-configuration ordering is exactly the kind of problem Terragrunt is built to solve, on top of Terraform’s own graph, not instead of it.
What Terragrunt is not
Section titled “What Terragrunt is not”It is worth being precise about the boundary here, since it is easy to overstate what Terragrunt does:
- Terragrunt is not a fork of Terraform, and it is not a replacement for it — the actual planning and applying of resources is still done entirely by
terraformortofu. - Terragrunt is not a new configuration language for describing GCP resources — a
google_compute_instanceresource is defined exactly the same way whether or not Terragrunt is involved. - Terragrunt cannot make an unsafe Terraform change safe on its own — it removes copy-pasted boilerplate and orchestrates ordering, but the actual infrastructure logic and its correctness are still entirely your
.tfmodule code’s responsibility.
The next module picks up exactly here, with the concrete Terragrunt building blocks — root.hcl, remote_state, generate, and a unit’s own terragrunt.hcl — that turn this positioning into a working setup.
flowchart TD workspaces["Terraform workspaces: DRY, weak isolation"] --> gap["The gap: isolation AND zero duplication together"] directoryPerEnv["Directory-per-environment: isolated, duplicated boilerplate"] --> gap gap --> terragrunt["Terragrunt: generates backend, provider, inputs, manages unit ordering"] terragrunt -->|shells out to| terraformBinary["Real terraform / tofu binary"]
Proof: terraform_remote_state versus dependency, both run for real
Section titled “Proof: terraform_remote_state versus dependency, both run for real”Plain Terraform already has a way to read another state’s outputs, with no Terragrunt at all: terraform_remote_state. Real two-unit proof, network applied first with a local backend:
resource "local_file" "network" { filename = "${path.module}/network-id.txt" content = "network-0123456789abcdef0\n"}output "network_id" { value = "network-0123456789abcdef0"}data "terraform_remote_state" "network" { backend = "local" config = { path = "../network/terraform.tfstate" }}resource "local_file" "gke" { filename = "${path.module}/gke-network-ref.txt" content = "launched into ${data.terraform_remote_state.network.outputs.network_id}\n"}Real apply in gke/, after network/ has already applied:
$ terraform apply -auto-approveApply complete! Resources: 1 added, 0 changed, 0 destroyed.$ cat gke-network-ref.txtlaunched into network-0123456789abcdef0This genuinely works with zero Terragrunt involved. The real difference from Terragrunt’s dependency block (terragrunt-fundamentals module) shows up the moment the upstream state does not exist yet — same gke/main.tf, network/terraform.tfstate deleted first:
$ terraform planError: Unable to find remote stateNo stored state was found for the given workspace in the given backend.Compare that against dependency + mock_outputs: the exact same “state does not exist yet” situation lets plan succeed there via a placeholder, with apply refusing separately and explicitly. terraform_remote_state has no equivalent — it hard-fails immediately, every time.
Keeping this honest: what plain Terraform can already do
Section titled “Keeping this honest: what plain Terraform can already do”-backend-config and -var-file genuinely reduce duplication, real proof, one main.tf reused for two environments:
# env/main.tf — the only .tf file, used for every environmentterraform { backend "local" {}}variable "environment" { type = string }variable "instance_count" { type = number }resource "local_file" "app" { filename = "${path.module}/app-${var.environment}.txt" content = "environment=${var.environment} count=${var.instance_count}\n"}$ terraform init -backend-config=../dev.backend.tfvars$ terraform apply -var-file=../dev.tfvars -auto-approveApply complete! Resources: 1 added, 0 changed, 0 destroyed.$ cat app-dev.txtenvironment=dev count=1What this genuinely does not give you: dependency-style cross-unit output reading with mock_outputs (terraform_remote_state is the closest plain-Terraform equivalent and it has no mock fallback, proven above), automatic multi-unit ordering (run --all), and a single place to change the shape of the backend/provider generation logic itself.
🐛 Spot the bug
# gke/main.tf — applied as part of a brand-new environment's very first apply, before network existsdata "terraform_remote_state" "network" {backend = "local"config = { path = "../network/terraform.tfstate"}}
resource "google_container_cluster" "app" {network = data.terraform_remote_state.network.outputs.network_self_link}Show the answer
Proven above, terraform_remote_state has no mock-output mechanism at all — unlike a Terragrunt dependency block, there is no way to let this plan succeed with a placeholder while signaling apply should still refuse. The real, unavoidable result here is Error: Unable to find remote state, and the only fix within plain Terraform is manually sequencing applies.