Skip to content

Why Terragrunt

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 .tf code, 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 source argument 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.

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 module block boilerplate.
  • Understanding dependency ordering when infrastructure is split across multiple independently-applied units — for example, a vpc unit that an ec2 unit 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:

environments/dev/app/terragrunt.hcl
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
}

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:

Terminal window
# Terragrunt determines vpc must run before ec2, then applies both in order
terragrunt run --all apply
flowchart 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"]
Workspaces (DRY, weak isolation) and directory-per-environment (isolated, duplicated) both leave a gap; Terragrunt wraps the real terraform binary to close it

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:

vpc/main.tf
resource "local_file" "vpc" {
filename = "${path.module}/vpc-id.txt"
content = "vpc-0123456789abcdef0\n"
}
output "vpc_id" {
value = "vpc-0123456789abcdef0"
}
ec2/main.tf
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-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ cat ec2-vpc-ref.txt
launched into vpc-0123456789abcdef0

This 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 plan
Error: Unable to find remote state
No 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 environment
terraform {
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"
}
dev.backend.tfvars
path = "dev.tfstate"
dev.tfvars
environment = "dev"
instance_count = 1

Real 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-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ cat app-dev.txt
environment=dev count=1

A 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.

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-file run, this overstates the case and undersells what a well-organized plain-Terraform setup can already do.
  • terraform_remote_state used across units with no handling for the “state doesn’t exist yet” case — proven above, it hard-fails with Unable to find remote state, with no mock_outputs-equivalent; code assuming it degrades gracefully the way dependency does 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’s terraform { source = ... } still points at the exact same modules/app from the previous lesson; nothing about the module itself changes.

🐛 Spot the bug

ec2/main.tf
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.

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.

ChoiceBenefitCost
terraform_remote_state (plain Terraform)Zero extra tooling, works today, proven above with a real cross-state readNo 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 environmentsNo 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 lessonsAn additional tool in the stack, with its own CLI surface and version to track alongside Terraform’s own
  • “Terragrunt is the only way to avoid copy-pasting backend/provider blocks.” Proven above: -backend-config/-var-file genuinely parameterize a single main.tf per 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_state hard-fails with Unable to find remote state; dependency (previous lesson) succeeds under plan via mock_outputs and only refuses at apply.
  • “Terragrunt replaces or forks the Terraform module code.” Proven above: the terraform { source = ... } block still points at the exact same modules/app used throughout this module — Terragrunt generates the wrapper around a module, never the module itself.
What gap remains after using either Terraform workspaces or the directory-per-environment pattern alone
What is Terragrunt, accurately described
What is Terragrunt commonly mistaken for, that it is explicitly not
What kind of problem does Terragrunt solve that a single Terraform configuration internal dependency graph does not
A plain Terraform ec2/main.tf uses data "terraform_remote_state" "vpc" to read vpc outputs, on a brand-new environment where vpc has never been applied. What happens, proven above?
Is it accurate to say plain Terraform "cannot" vary its configuration per environment without Terragrunt?