Terragrunt
DRY Terraform configuration with Terragrunt - directory patterns, dependency orchestration, multi-account setups, and migration from plain Terraform
Terragrunt
Terragrunt is a thin wrapper around Terraform that solves three pain points that grow with scale: duplicated backend configuration, duplicated provider blocks, and the inability to orchestrate dependencies across modules.
What Terragrunt Solves
| Pain Point | Plain Terraform | With Terragrunt |
|---|---|---|
| Backend config repeated in every module | Copy-paste backend "s3" {} everywhere | One remote_state block inherited by all |
| Provider config repeated per env | Duplicate provider "aws" blocks | generate block creates it from a single source |
| No cross-module dependency ordering | Manual terraform apply in correct order | dependency blocks + run-all |
| Environment drift | Workspace variables or copy-pasted directories | Hierarchical inputs with DRY inheritance |
Directory Structure
The most common pattern organizes by account, region, and component:
infrastructure/
├── terragrunt.hcl # root - backend + provider config
├── _envcommon/ # shared module configurations
│ ├── vpc.hcl
│ ├── eks.hcl
│ └── rds.hcl
├── production/
│ ├── account.hcl # account-level variables
│ ├── us-east-1/
│ │ ├── region.hcl # region-level variables
│ │ ├── vpc/
│ │ │ └── terragrunt.hcl
│ │ ├── eks/
│ │ │ └── terragrunt.hcl
│ │ └── rds/
│ │ └── terragrunt.hcl
│ └── eu-west-1/
│ ├── region.hcl
│ └── vpc/
│ └── terragrunt.hcl
└── staging/
├── account.hcl
└── us-east-1/
├── region.hcl
├── vpc/
│ └── terragrunt.hcl
└── eks/
└── terragrunt.hclRoot Configuration
The root terragrunt.hcl defines remote state and provider generation for all children.
# infrastructure/terragrunt.hcl
locals {
account_vars = read_terragrunt_config(find_in_parent_folders("account.hcl"))
region_vars = read_terragrunt_config(find_in_parent_folders("region.hcl"))
account_id = local.account_vars.locals.account_id
account_name = local.account_vars.locals.account_name
aws_region = local.region_vars.locals.aws_region
}
# Remote state - every child inherits this
remote_state {
backend = "s3"
config = {
bucket = "myorg-terraform-state-${local.account_id}"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
generate = {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
}
}
# Generate provider block
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.aws_region}"
default_tags {
tags = {
ManagedBy = "terraform"
Environment = "${local.account_name}"
}
}
}
EOF
}Account and Region Variables
# production/account.hcl
locals {
account_name = "production"
account_id = "111111111111"
}# production/us-east-1/region.hcl
locals {
aws_region = "us-east-1"
}Include and Inputs
Each component's terragrunt.hcl includes the root and passes inputs to the Terraform module:
# production/us-east-1/vpc/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
include "envcommon" {
path = "${dirname(find_in_parent_folders())}/_envcommon/vpc.hcl"
expose = true
}
inputs = {
cidr_block = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}# _envcommon/vpc.hcl
terraform {
source = "git::git@github.com:myorg/terraform-modules.git//vpc?ref=v3.2.0"
}
inputs = {
enable_nat_gateway = true
single_nat_gateway = false
enable_dns_hostnames = true
}Dependencies
Declare cross-module dependencies so run-all applies them in the right order:
# production/us-east-1/eks/terragrunt.hcl
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::git@github.com:myorg/terraform-modules.git//eks?ref=v5.1.0"
}
dependency "vpc" {
config_path = "../vpc"
mock_outputs = {
vpc_id = "vpc-mock"
private_subnet_ids = ["subnet-mock-1", "subnet-mock-2"]
}
mock_outputs_allowed_terraform_commands = ["validate", "plan"]
}
inputs = {
vpc_id = dependency.vpc.outputs.vpc_id
subnet_ids = dependency.vpc.outputs.private_subnet_ids
cluster_version = "1.29"
}Generate Blocks
Generate arbitrary files alongside the Terraform module:
generate "versions" {
path = "versions.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
EOF
}Hooks
Execute scripts before or after Terraform commands:
terraform {
before_hook "validate" {
commands = ["apply", "plan"]
execute = ["tflint", "--init"]
}
after_hook "notify" {
commands = ["apply"]
execute = ["bash", "-c", "curl -s -X POST $SLACK_WEBHOOK -d '{\"text\":\"Terraform applied: ${path_relative_to_include()}\"}'"]
run_on_error = false
}
}Multi-Account AWS Setup
Use assume_role to manage multiple accounts from a single pipeline:
# Root terragrunt.hcl addition for cross-account access
generate "provider" {
path = "provider.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
provider "aws" {
region = "${local.aws_region}"
assume_role {
role_arn = "arn:aws:iam::${local.account_id}:role/TerraformExecutionRole"
}
default_tags {
tags = {
ManagedBy = "terraform"
Environment = "${local.account_name}"
}
}
}
EOF
}run-all: Orchestrated Operations
Apply, plan, or destroy across all modules with dependency awareness:
# Plan everything in a region
cd infrastructure/production/us-east-1
terragrunt run-all plan
# Apply with dependency ordering (VPC first, then EKS, then RDS)
terragrunt run-all apply
# Destroy in reverse dependency order
terragrunt run-all destroy
# Target specific modules
terragrunt run-all apply --terragrunt-include-dir "*/vpc" --terragrunt-include-dir "*/eks"Migrating from Plain Terraform
Create the Terragrunt directory structure, migrate existing state to S3, create terragrunt.hcl files pointing to the same module sources, import existing resources with terragrunt import, and verify with terragrunt plan (should show no changes).
Terragrunt vs Terraform Workspaces
| Aspect | Workspaces | Terragrunt |
|---|---|---|
| State isolation | Same backend, different keys | Different backends per environment |
| Config differences | Conditional logic with terraform.workspace | Separate inputs per environment |
| Module pinning | Same module version across workspaces | Different versions per environment |
| Cross-module deps | Not supported | First-class dependency blocks |
| Blast radius | All workspaces share a root module | Each component is independently deployable |
When NOT to Use Terragrunt
- Small projects with 1-3 modules and one environment -- plain Terraform is simpler
- Teams unfamiliar with Terraform -- adding a wrapper increases the learning curve
- Terraform Cloud / Spacelift -- these platforms provide their own orchestration and state management
- Short-lived infrastructure -- the directory overhead does not pay off for ephemeral stacks
What's Next
Testing covers how to validate your Terraform code with static analysis, unit tests, integration tests, and policy checks.