Skip to content

Why Terragrunt

Terraform workspaces solve reuse well but leave isolation weak, directory-per-environment solves isolation well but reintroduces duplicated boilerplate, and Terragrunt exists specifically to close the gap between them without asking you to give up either property.

The last two lessons walked through Terraform’s two native answers to the multi-environment problem, and each one traded one goal for the other. A workspace keeps one .tf config and one backend/provider block, and just switches which state file is active:

Terminal window
# Workspaces: one directory, one backend, one provider, one subscription_id
# for every environment, only the active workspace changes
terraform workspace select prod
terraform apply

Directory-per-environment goes the other way: real isolation through a genuinely separate directory, backend, and provider block per environment, at the cost of that backend/provider boilerplate being copy-pasted almost verbatim into every environment:

Terminal window
# Directory-per-environment: real isolation, separate backend and provider
# per environment, but environments/dev, environments/staging, and
# environments/prod each carry their own near-identical backend.tf
cd environments/prod
terraform init
terraform apply

Neither approach is wrong, and neither is a bug in Terraform — they are two different, honest trade-offs between the same two goals. What Terraform does not offer, on its own, is a single primitive that gives you strong isolation and zero configuration duplication across environments at the same time.

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 the module code you write to actually create Azure resources stays exactly what it already is: plain Terraform, in plain .tf files, unchanged.

What Terragrunt adds is a layer above that code, aimed squarely at the duplication that directory-per-environment reintroduces. Instead of writing out a near-identical backend.tf and provider.tf in every environment directory by hand, Terragrunt lets you define the backend and provider configuration once, centrally, and generate the per-environment specifics — the state key, the subscription_id, the resource group — from that one definition:

# terragrunt.hcl (a conceptual preview only; the actual syntax is the
# subject of the next module, Terragrunt Fundamentals)
# One central place that generates the backend and provider blocks for
# every environment, instead of copy-pasting them into environments/dev,
# environments/staging, and environments/prod by hand
remote_state {
backend = "azurerm"
config = {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstateacct001"
container_name = "tfstate"
key = "app.terraform.tfstate"
}
}

The same idea extends to supplying each environment’s input variables and, once infrastructure is split across multiple independently-applied units, to understanding which unit depends on which so that Terragrunt applies them in the right order.

Terragrunt is not a fork of Terraform, it does not compile to a different infrastructure-as-code language, and it is not a replacement for the terraform binary. Every Terragrunt command ultimately shells out to a real terraform or tofu command underneath, running against the exact same providers and the exact same state formats Terraform already understands:

Terminal window
# Every terragrunt command wraps a real terraform (or tofu) command
terragrunt plan # generates config, then runs a real `terraform init` and `terraform plan`
terragrunt apply # generates config, then runs a real `terraform apply`

That distinction matters for one more reason beyond reducing boilerplate. A single Terraform configuration already has its own internal dependency graph between resources, but that graph stops at the edge of one terraform apply. Once an environment’s infrastructure is deliberately split into multiple, independently-applied units — a network unit, a database unit, an application unit, each with its own state — nothing in Terraform itself knows that the application unit needs the database unit’s outputs first. That is the second kind of problem Terragrunt is built to solve: ordering and passing outputs between separately-applied units, which the next module covers in detail.

flowchart LR
  workspaces["Terraform workspaces: DRY, weak isolation"] --> gap["The gap: no native primitive gives both at once"]
  dirPerEnv["Directory-per-environment: isolated, duplicated"] --> gap
  gap --> terragrunt["Terragrunt: generates config, removes duplication"]
  terragrunt --> realTf["Real terraform / tofu binary actually runs"]
Workspaces and directory-per-environment each close half the gap; Terragrunt wraps the real terraform binary to close the rest

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, vnet applied first with a local backend:

vnet/main.tf
resource "local_file" "vnet" {
filename = "${path.module}/subnet-id.txt"
content = "subnet-0123456789abcdef0\n"
}
output "subnet_id" {
value = "subnet-0123456789abcdef0"
}
vm/main.tf
data "terraform_remote_state" "vnet" {
backend = "local"
config = {
path = "../vnet/terraform.tfstate"
}
}
resource "local_file" "vm" {
filename = "${path.module}/vm-subnet-ref.txt"
content = "launched into ${data.terraform_remote_state.vnet.outputs.subnet_id}\n"
}

Real apply in vm/, after vnet/ has already applied:

$ terraform apply -auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ cat vm-subnet-ref.txt
launched into subnet-0123456789abcdef0

This 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 vm/main.tf, vnet/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 dependency + mock_outputs: the exact same “state does not exist yet” situation lets 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. 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”

-backend-config and -var-file genuinely reduce duplication, 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

What 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, as opposed to just the values fed into it. Terragrunt’s actual value-add is specifically the dependency graph, the generation logic, and the orchestration across many units, not parameterization itself.

🐛 Spot the bug

# vm/main.tf — applied as part of a brand-new environment's very first apply, before vnet exists
data "terraform_remote_state" "vnet" {
backend = "local"
config = {
path = "../vnet/terraform.tfstate"
}
}
resource "azurerm_linux_virtual_machine" "app" {
network_interface_ids = [data.terraform_remote_state.vnet.outputs.nic_id]
}
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 — exactly the ordering problem dependency and run --all solve automatically.

What gap does neither Terraform workspaces nor the directory-per-environment pattern fully close on its own
What is Terragrunt actually, versus what it is commonly mistaken for
What kind of problem does Terragrunt additionally solve that the internal dependency graph inside a single Terraform configuration does not
What does every Terragrunt command ultimately do underneath
A plain Terraform vm/main.tf uses data "terraform_remote_state" "vnet" to read vnet outputs, on a brand-new environment where vnet has never been applied. What happens, proven above?
Is it accurate to say plain Terraform "cannot" vary its configuration per environment without Terragrunt?