Skip to content

Why Terragrunt

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 last two lessons each solved half of the problem from the very first lesson in this module:

  • Workspaces keep one copy of the .tf code, one backend, and one provider configuration — genuinely DRY — but every workspace shares that same backend and the same GCP project, so isolation between environments is weak enough that a wrong terraform workspace select can 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 source and 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.

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:

Terminal window
# What you type
terragrunt apply
# What actually eventually runs, per unit, with generated config already in place
terraform apply

What 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, and prod still call one modules/app source with different values, without hand-wiring a separate module block per environment.
  • Understanding dependency ordering when infrastructure is split across multiple independently-applied units — for example, a network unit that a gke unit depends on — so Terragrunt can figure out that network needs to apply before gke rather than you tracking that ordering by hand across separate terraform apply runs.

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.

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 terraform or tofu.
  • Terragrunt is not a new configuration language for describing GCP resources — a google_compute_instance resource 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 .tf module 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"]
Workspaces (DRY, weak isolation) and directory-per-environment (isolated, duplicated) both leave a gap that Terragrunt closes by wrapping real Terraform

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:

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

🐛 Spot the bug

# gke/main.tf — applied as part of a brand-new environment's very first apply, before network exists
data "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.

What gap does neither Terraform workspaces nor directory-per-environment fully close on their own
What is Terragrunt most accurately described as
What kind of problem does Terragrunt additionally solve that a single Terraform configuration own internal dependency graph does not cover
Which of these is something Terragrunt explicitly does NOT do
A plain Terraform gke/main.tf uses data "terraform_remote_state" "network" to read network outputs, on a brand-new environment where network has never been applied. What happens, proven above?
Is it accurate to say plain Terraform "cannot" vary its configuration per environment without Terragrunt?