Why Terragrunt
The idea in one sentence
Section titled “The idea in one sentence”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.
Two halves of one problem, recap
Section titled “Two halves of one problem, recap”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:
# Workspaces: one directory, one backend, one provider, one subscription_id# for every environment, only the active workspace changesterraform workspace select prodterraform applyDirectory-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:
# 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.tfcd environments/prodterraform initterraform applyNeither 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.
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 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 handremote_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.
What Terragrunt is not
Section titled “What Terragrunt is not”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:
# Every terragrunt command wraps a real terraform (or tofu) commandterragrunt 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"]
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:
resource "local_file" "vnet" { filename = "${path.module}/subnet-id.txt" content = "subnet-0123456789abcdef0\n"}output "subnet_id" { value = "subnet-0123456789abcdef0"}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-approveApply complete! Resources: 1 added, 0 changed, 0 destroyed.$ cat vm-subnet-ref.txtlaunched into subnet-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 vm/main.tf, vnet/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, 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 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=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, 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 existsdata "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.