LearnDevOps
DevOps04. Infrastructure as Code (IaC)·EXPERT·6 min read

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.hcl centralizes backend configuration, provider setup, and common variables once; each environment inherits them, keeping only genuinely environment-specific values duplicated.
  • The dependency block adds explicit apply ordering; terragrunt run-all apply determines order from the dependency graph instead of requiring a hand-maintained script.
  • mock_outputs solves 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 include and generate wasn’t consistently applied. Fix: Audit all terragrunt.hcl files; move all truly common configuration to the root terragrunt.hcl; use include consistently.

  • Pitfall: Fresh environment bootstrap is blocked on a chicken-and-egg problem (dependent module needs an output that doesn’t exist yet). Why: mock_outputs wasn’t used. Fix: Add mock_outputs to 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 apply is run broadly, applying changes to more environments than intended. Why: Dependency graph complexity wasn’t fully understood before executing. Fix: Always run terragrunt run-all plan and review the full output first; understand what will be applied to which environments.

  • Pitfall: dependency block 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 dependency block provide beyond plain Terraform’s terraform_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_outputs is 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, and dependency for managing scale.

  • terragrunt run-all apply is 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.

🔒 Locked
Sign in and unlock the full syllabus to keep reading.
Sign in to continue
Knowledge check
4 scenario questions on this topic
Take the quiz →
Related in 04. Infrastructure as Code (IaC)
10. IaC Fundamentals
EXPERT
11. Terraform
EXPERT
12. OpenTofu
EXPERT
14. Cloud IaC
EXPERT