Terragrunt — DRY Orchestration

Terragrunt is an opinionated wrapper around Terraform/OpenTofu that keeps configurations DRY, enforces remote state conventions, and fans out deployments safely.

Why Terragrunt?

Without Terragrunt, deploying the same solution across 10+ environments means duplicating backend configuration, provider blocks, and shared variables in every folder. Terragrunt eliminates this duplication through hierarchical configuration.

Key Features

Feature Description
Hierarchical config Inherit settings from parent root.hcl files
DRY backends Define remote state once, reuse everywhere
Dependency management Declare dependencies between deployments
Provider generation Auto-generate provider blocks per environment
Built-in state locking Prevent concurrent modifications
Fan-out execution Apply changes across multiple modules in order

How It Fits in IDLC

Terragrunt is the engine behind the Deploy phase. It orchestrates how solutions are instantiated across regions and environments.

deployments/
├── region-1/
│   ├── stage/
│   │   ├── root.hcl          ← Shared config for region-1 stage
│   │   ├── my-app/
│   │   │   └── terragrunt.hcl
│   │   └── my-database/
│   │       └── terragrunt.hcl
│   └── production/
│       ├── root.hcl          ← Shared config for region-1 production
│       └── my-app/
│           └── terragrunt.hcl
└── region-2/
    └── production/
        ├── root.hcl
        └── my-app/
            └── terragrunt.hcl

Configuration Hierarchy

root.hcl — Environment Level

Defines shared settings for all deployments in an environment:

remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    bucket         = "my-org-region-1-production-terraform-remote-state"
    dynamodb_table = "my-org-region-1-production-terraform-remote-state-locks"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "us-west-2"
    encrypt        = true
    role_arn       = local.env_vars.atlantis_role
  }
}

locals {
  env_vars = {
    business_region      = "region-1"
    environment          = "production"
    vpc_id               = "vpc-0example"
    db_subnet_group_name = "region-1-production-db-subnet-group"
    alarm_sns_topic_arn  = "arn:aws:sns:us-west-2:123456789:region-1-production-alerts"
  }
}

terraform_binary = "tofu"

terragrunt.hcl — Deployment Level

References a solution from Terrareg and passes environment-specific inputs:

include "root" {
  path   = find_in_parent_folders("root.hcl")
  expose = true
}

dependency "eks_cluster" {
  config_path = "../eks/base/jazz"
}

terraform {
  source = "tfr://terrareg.example.com/solutions/my-app/aws?version=2.1.0"
}

inputs = {
  business_region = "region-1"
  environment     = "production"

  eks_cluster_names = [
    dependency.eks_cluster.outputs.name
  ]
}

Provider Generation

Terragrunt can auto-generate provider blocks, keeping them consistent across environments:

generate "provider" {
  path      = "providers.tf"
  if_exists = "overwrite_terragrunt"

  contents = <<EOF
provider "aws" {
  region = "us-west-2"
  assume_role {
    role_arn = "${local.env_vars.atlantis_role}"
  }
  default_tags {
    tags = ${jsonencode(local.tags)}
  }
}
EOF
}

Dependency Management

Declare explicit dependencies between deployments:

dependency "eks_cluster" {
  config_path = "../eks/base/jazz"
}

dependency "database" {
  config_path = "../my-database"
}

inputs = {
  cluster_name = dependency.eks_cluster.outputs.name
  db_endpoint  = dependency.database.outputs.main_db_instance_endpoint
}

Always use dependency blocks instead of hardcoding values. This ensures deployments are applied in the correct order and outputs are always fresh.

Common Commands

# Plan a single deployment
terragrunt plan

# Apply a single deployment
terragrunt apply

# Plan all deployments in a directory
terragrunt run-all plan

# Apply all deployments in order
terragrunt run-all apply