Why Terragrunt
The idea in one sentence
Section titled “The idea in one sentence”Terragrunt is a thin wrapper around the real terraform (or tofu) binary that generates and manages the repeated backend, provider, and input boilerplate across environments, so you can have both DRY configuration and strong isolation instead of trading one for the other.
The gap neither Terraform-native approach closes
Section titled “The gap neither Terraform-native approach closes”The last two lessons covered the two answers Terraform itself offers to the multi-environment problem, and each one closes exactly half the gap:
- Workspaces are excellent at DRY reuse — one configuration, one backend, many state files — but offer weak isolation: every workspace shares the same backend, the same provider configuration, and the same
.tfcode, with nothing but a CLI-selected string separating them. - Directory-per-environment is excellent at isolation — a real directory, a real backend, potentially a real separate AWS account per environment — but reintroduces duplication: the backend block, the provider block, and the module
sourceargument end up copy-pasted nearly verbatim across every environment directory.
Plain Terraform has no first-class primitive that gives you both strong isolation and zero configuration duplication at the same time. Picking one Terraform-native pattern always means accepting the other pattern’s weakness.
What Terragrunt actually is
Section titled “What Terragrunt actually is”Terragrunt is a thin wrapper and orchestrator that sits on top of your existing Terraform (or OpenTofu) setup. It does not introduce a new resource syntax, and it does not replace anything inside modules/app — your actual module code is completely unchanged, plain Terraform, exactly as covered in every earlier lesson. What Terragrunt adds is a layer above that, responsible for the boilerplate you just watched pile up in the directory-per-environment pattern:
- Generating each environment’s backend configuration from one central definition, instead of hand-copying the
backend "s3"block into every environment directory. - Generating each environment’s provider configuration the same way.
- Supplying each environment’s input variables in one place per environment, without repeating the surrounding
moduleblock boilerplate. - Understanding dependency ordering when infrastructure is split across multiple independently-applied units — for example, a
vpcunit that anec2unit needs outputs from.
A minimal Terragrunt unit for the dev environment from the previous lesson looks like this — notice the backend fields are the same ones you wrote by hand before, just generated from configuration instead of copy-pasted:
remote_state { backend = "s3" generate = { path = "backend.tf" if_exists = "overwrite" } config = { bucket = "acme-terraform-state-dev" key = "app/terraform.tfstate" region = "us-east-1" use_lockfile = true }}
terraform { source = "../../../modules/app"}
inputs = { environment = "dev" instance_type = "t3.micro" instance_count = 1}What Terragrunt is not
Section titled “What Terragrunt is not”This is worth being precise about, because it is the most common misconception: Terragrunt is not a fork of Terraform, and it does not compile your configuration into some different infrastructure-as-code language. Every single Terragrunt command ultimately shells out to a real terraform or tofu command underneath, running against the exact backend and provider configuration Terragrunt just assembled for it. Run terragrunt plan and, underneath, an ordinary terraform plan executes — Terragrunt’s job ends at generating the inputs and orchestrating when that real command runs.
The dependency-ordering problem this also solves
Section titled “The dependency-ordering problem this also solves”A single Terraform configuration already understands dependency order between resources inside one state file — if a security group references a VPC’s ID, Terraform figures out the VPC must be created first, automatically. That graph stops at the edge of one state file. The moment infrastructure is split into multiple independently-applied units — a vpc unit with its own state, and a separate ec2 unit with its own state that needs the VPC’s subnet IDs — nothing in either individual terraform apply knows the other unit needs to run first, because each is a completely separate process operating on a completely separate state file.
Terragrunt closes that gap with a dependency block that reads another unit’s outputs, and a run queue that applies units in the correct order automatically:
# Terragrunt determines vpc must run before ec2, then applies both in orderterragrunt run --all applyflowchart LR ws["Workspaces: DRY, weak isolation"] --> gap["The gap: no primitive gives both at once"] dirEnv["Directory-per-environment: isolated, duplicated boilerplate"] --> gap gap --> tg["Terragrunt: generates config, orders units"] tg --> tf["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, vpc applied first with a local backend:
resource "local_file" "vpc" { filename = "${path.module}/vpc-id.txt" content = "vpc-0123456789abcdef0\n"}output "vpc_id" { value = "vpc-0123456789abcdef0"}data "terraform_remote_state" "vpc" { backend = "local"
config = { path = "../vpc/terraform.tfstate" }}
resource "local_file" "ec2" { filename = "${path.module}/ec2-vpc-ref.txt" content = "launched into ${data.terraform_remote_state.vpc.outputs.vpc_id}\n"}Real apply in ec2/, after vpc/ has already applied:
$ terraform apply -auto-approveApply complete! Resources: 1 added, 0 changed, 0 destroyed.$ cat ec2-vpc-ref.txtlaunched into vpc-0123456789abcdef0This genuinely works with zero Terragrunt involved — plain Terraform, a data block, real cross-state reading. The real difference from Terragrunt’s dependency block (previous lesson) shows up the moment the upstream state does not exist yet. Same ec2/main.tf, vpc/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 the previous lesson’s real proof of dependency + mock_outputs: the exact same “state does not exist yet” situation let plan succeed there, using a placeholder value, with apply refusing separately and explicitly. terraform_remote_state has no equivalent — it hard-fails immediately, every time, for both plan and apply, with no mock fallback and no per-command allow-list. That gap — not “reading another unit’s state,” which plain Terraform can already do — is what dependency genuinely adds.
Keeping this honest: what plain Terraform can already do
Section titled “Keeping this honest: what plain Terraform can already do”It would be dishonest to leave the impression that plain Terraform has no answer to configuration duplication at all — -backend-config and -var-file genuinely reduce it, real proof, one main.tf reused for two environments with no directory duplication whatsoever:
# 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"}path = "dev.tfstate"environment = "dev"instance_count = 1Real init and apply, parameterizing both the backend and the inputs entirely from the command line, zero environments/dev/ directory needed:
$ 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=1A staging.backend.tfvars/staging.tfvars pair, or a small wrapper script looping over environment names, gets most of the way to what directory-per-environment provides — one .tf file, per-environment backend and inputs supplied externally. What this genuinely does not give you: dependency-style cross-unit output reading with mock_outputs (proven above, terraform_remote_state is the closest plain-Terraform equivalent and it has no mock fallback), automatic multi-unit ordering (run --all — a hand-rolled script has to encode that order itself, or apply units one at a time in a fixed sequence with no dependency-graph awareness at all), and a single place to change the shape of the backend/provider generation logic itself, as opposed to just the values fed into it. Terragrunt’s actual value-add is narrower and more honest than “plain Terraform can’t do any of this” — it is specifically the dependency graph, the generation logic, and the orchestration across many units, not parameterization itself.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing a “why we need Terragrunt” justification (from an AI or a teammate), check for:
- A claim that plain Terraform “can’t” vary configuration per environment at all — proven above with a real
-backend-config/-var-filerun, this overstates the case and undersells what a well-organized plain-Terraform setup can already do. terraform_remote_stateused across units with no handling for the “state doesn’t exist yet” case — proven above, it hard-fails withUnable to find remote state, with nomock_outputs-equivalent; code assuming it degrades gracefully the waydependencydoes will break the first time a fresh environment is stood up.- A migration plan that treats Terragrunt as replacing Terraform module code — proven above,
environments/dev/app/terragrunt.hcl’sterraform { source = ... }still points at the exact samemodules/appfrom the previous lesson; nothing about the module itself changes.
🐛 Spot the bug
data "terraform_remote_state" "vpc" {backend = "local"config = { path = "../vpc/terraform.tfstate"}}
resource "aws_instance" "app" {subnet_id = data.terraform_remote_state.vpc.outputs.subnet_id}Show the answer
This is being applied as part of a brand-new environment’s very first terraform apply, before vpc has ever been applied. 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 (apply vpc first, always, by hand or by a script that encodes that order) — exactly the ordering problem dependency and run --all solve automatically, proven in the terragrunt-fundamentals module.
Mental Model
Section titled “Mental Model”Terragrunt’s value is best understood as narrow and specific, not “does everything plain Terraform can’t.” Proven above: plain Terraform’s terraform_remote_state reads cross-unit state just fine, and -backend-config/-var-file parameterize a single config per environment just fine. What plain Terraform has no primitive for is graceful degradation when a dependency isn’t ready yet (mock_outputs, previous lesson), and automatic ordering across many independently-applied units (run --all). Terragrunt is the wrapper that adds exactly those two things, plus the generation layer that keeps the backend/provider logic itself (not just its values) in one place.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
terraform_remote_state (plain Terraform) | Zero extra tooling, works today, proven above with a real cross-state read | No mock fallback at all, proven above — a fresh environment’s first plan hard-fails until dependencies are applied in the exact right order by hand |
-backend-config/-var-file (plain Terraform) | Real reduction in duplication, proven above with one main.tf serving two environments | No dependency graph, no automatic ordering — a script wrapping this still has to encode unit-apply order itself |
Terragrunt (dependency + run --all + generate) | Mock-output graceful degradation and automatic ordering, both proven in earlier lessons | An additional tool in the stack, with its own CLI surface and version to track alongside Terraform’s own |
Common Misconceptions
Section titled “Common Misconceptions”- “Terragrunt is the only way to avoid copy-pasting backend/provider blocks.” Proven above:
-backend-config/-var-filegenuinely parameterize a singlemain.tfper environment with plain Terraform, no Terragrunt required. - “terraform_remote_state and dependency behave the same way when the target hasn’t been applied yet.” Proven above with two real, opposite outcomes:
terraform_remote_statehard-fails withUnable to find remote state;dependency(previous lesson) succeeds underplanviamock_outputsand only refuses atapply. - “Terragrunt replaces or forks the Terraform module code.” Proven above: the
terraform { source = ... }block still points at the exact samemodules/appused throughout this module — Terragrunt generates the wrapper around a module, never the module itself.