13. Terragrunt
Terragrunt
TL;DR
- Terragrunt is an orchestration and DRY layer on top of Terraform/OpenTofu, not a replacement; it solves the “copy-pasted config across many environments” problem.
terragrunt.hclcentralizes backend configuration, provider setup, and common variables once; each environment inherits them, keeping only genuinely environment-specific values duplicated.- The
dependencyblock adds explicit apply ordering;terragrunt run-all applydetermines order from the dependency graph instead of requiring a hand-maintained script. mock_outputssolves bootstrap chicken-and-egg problems (a dependent module needs an output from a component that hasn’t been applied yet).- Teams must understand underlying Terraform/OpenTofu concepts independently; Terragrunt is an orchestration layer that wraps but doesn’t replace those fundamentals.
Core Concepts
The DRY problem Terragrunt solves
With 15 environments (dev/staging/prod × 5 regions), backend configuration, provider blocks, and common variables get copy-pasted across 15 directories. Every copy is a place drift creeps in:
- Update backend config in prod but forget staging
- Fix a variable in 3 of 5 regions
- Change a common value in 10 of 15 environments
Terragrunt’s terragrunt.hcl centralizes shared configuration (via include and generate) and centralizes environment-specific values in a single declaration per environment.
Configuration centralization
# Root terragrunt.hcl — centralized, inherited by all environments
remote_state {
backend = "s3"
generate {
path = "backend.tf"
if_exists = "overwrite"
}
config = {
bucket = "mycompany-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
# environments/prod/networking/terragrunt.hcl — only environment-specific values
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../../modules//vpc"
}
inputs = {
environment = "prod"
cidr_block = "10.0.0.0/16"
region = "us-east-1"
}
Every environment inherits the centralized backend config; only inputs differ per environment. Updates to the centralized config apply everywhere automatically.
Dependency ordering with explicit apply sequencing
Plain Terraform’s terraform_remote_state allows reading outputs from other state files, but doesn’t control apply order. terragrunt run-all apply uses the dependency block to derive apply order from the dependency graph:
# environments/prod/database/terragrunt.hcl
dependency "networking" {
config_path = "../networking"
}
inputs = {
vpc_id = dependency.networking.outputs.vpc_id
}
# environments/prod/compute/terragrunt.hcl
dependency "database" {
config_path = "../database"
}
dependency "networking" {
config_path = "../networking"
}
inputs = {
db_endpoint = dependency.database.outputs.endpoint
vpc_id = dependency.networking.outputs.vpc_id
}
terragrunt run-all apply automatically applies in order: networking → database → compute. Without Terragrunt, you’d maintain a CI pipeline stage sequence or a manual apply script.
Bootstrap chicken-and-egg problem and mock_outputs
Fresh environment bootstrap can fail when a dependent module tries to read an output that doesn’t exist yet:
dependency "database" {
config_path = "../database"
mock_outputs = {
endpoint = "mock.example.com:5432"
}
}
mock_outputs allows terragrunt plan to proceed with placeholder values, validating the structure without a real value. On the actual apply, real values replace mocks.
Understanding the underlying IaC tool
Terragrunt wraps and orchestrates Terraform/OpenTofu. It generates Terraform configuration and executes Terraform commands, but the actual infrastructure provisioning happens in Terraform. A team that treats Terragrunt as a black box, without understanding Terraform state, modules, and providers, struggles to debug failures — the actual error is usually one layer down, in the Terraform execution.
Example: Terragrunt apply fails with “state lock timeout.” Without understanding Terraform state locking, the fix (wait for the lock to release, manually unlock if necessary) isn’t obvious.
Terragrunt vs Plain Terraform vs Environment-Specific Branching
| Approach | Terragrunt | Plain Terraform | Environment branches |
|---|---|---|---|
| Code duplication | Minimal (centralized) | High (per-environment copy) | Moderate (per-branch) |
| Apply ordering | Automatic (dependency graph) | Manual (hand-maintained) | Manual (pipeline stages) |
| Config sharing | include + generate |
Copy-paste or workarounds | Git branches (hard to sync) |
| Bootstrap complexity | mock_outputs mechanism |
Chicken-and-egg blocks apply | Manual per-environment workarounds |
| Debugging | Requires Terraform knowledge | Requires Terraform knowledge | Requires Terraform knowledge |
Configuration Reference
# Directory structure
infrastructure/
├── terragrunt.hcl # Root config (inherited)
├── environments/
│ ├── dev/
│ │ ├── terragrunt.hcl # Dev-specific overrides
│ │ ├── networking/
│ │ │ └── terragrunt.hcl # Networking for dev
│ │ └── compute/
│ │ └── terragrunt.hcl # Compute for dev (depends on networking)
│ └── prod/
│ ├── terragrunt.hcl # Prod-specific overrides
│ ├── networking/...
│ └── compute/...
# Root terragrunt.hcl
remote_state {
backend = "s3"
generate {
path = "backend.tf"
if_exists = "overwrite"
}
config = {
bucket = "company-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
generate "provider" {
path = "provider.tf"
if_exists = "overwrite"
contents = <<-EOF
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = "${get_env("AWS_REGION", "us-east-1")}"
}
EOF
}
# environments/prod/networking/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../../modules//vpc"
}
inputs = {
environment = "prod"
cidr_block = "10.0.0.0/16"
}
# environments/prod/compute/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "../../../modules//eks"
}
dependency "networking" {
config_path = "../networking"
mock_outputs = {
vpc_id = "vpc-mock12345"
private_subnet_id = "subnet-mock12345"
}
}
inputs = {
environment = "prod"
vpc_id = dependency.networking.outputs.vpc_id
private_subnet_id = dependency.networking.outputs.private_subnet_id
}
# CLI commands
terragrunt run-all plan # Plan all environments/components
terragrunt run-all apply # Apply all, respecting dependency order
terragrunt run-all destroy # Destroy all, respecting dependency order
cd environments/prod/compute
terragrunt apply # Apply just this component
terragrunt graph-dependencies # Show dependency DAG
Common Pitfalls
-
Pitfall: Backend configuration is still partially copy-pasted across environments despite Terragrunt adoption. Why: Centralization via
includeandgeneratewasn’t consistently applied. Fix: Audit allterragrunt.hclfiles; move all truly common configuration to the rootterragrunt.hcl; useincludeconsistently. -
Pitfall: Fresh environment bootstrap is blocked on a chicken-and-egg problem (dependent module needs an output that doesn’t exist yet). Why:
mock_outputswasn’t used. Fix: Addmock_outputsto any dependency that may not have been applied yet; allows plan and apply to proceed during bootstrap. -
Pitfall: Terragrunt failure takes hours to debug because the team doesn’t understand Terraform state/modules underneath. Why: Terragrunt was treated as a black box. Fix: Require team to understand Terraform concepts independently; Terragrunt is just orchestration on top.
-
Pitfall:
terragrunt run-all applyis run broadly, applying changes to more environments than intended. Why: Dependency graph complexity wasn’t fully understood before executing. Fix: Always runterragrunt run-all planand review the full output first; understand what will be applied to which environments. -
Pitfall:
dependencyblock reads an output that’s stale or missing; apply fails silently or produces wrong results. Why: Dependency assumptions weren’t validated. Fix: Add health checks or validation to confirm dependencies are actually applied and outputs are valid before consuming them.
Interview Questions
-
What does Terragrunt’s
dependencyblock provide beyond plain Terraform’sterraform_remote_state? — Tests whether automatic ordering is understood as the key value-add. -
A fresh environment bootstrap is blocked because a dependent module’s output doesn’t exist yet. How do you solve this? — Tests whether
mock_outputsis the immediate, correct answer. -
Your team adopted Terragrunt but still struggles to debug failures. What’s missing? — Tests whether underlying Terraform knowledge is recognized as a prerequisite.
-
Design a Terragrunt configuration that manages 12 environments (3 per region, 4 regions) with shared networking and environment-specific compute. — Tests understanding of
include,inputs, anddependencyfor managing scale. -
terragrunt run-all applyis about to be run for the first time in a production context. What’s your safety check? — Tests whether understanding the dependency graph and blast radius is a real, practiced habit.