Steven's Knowledge

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 PointPlain TerraformWith Terragrunt
Backend config repeated in every moduleCopy-paste backend "s3" {} everywhereOne remote_state block inherited by all
Provider config repeated per envDuplicate provider "aws" blocksgenerate block creates it from a single source
No cross-module dependency orderingManual terraform apply in correct orderdependency blocks + run-all
Environment driftWorkspace variables or copy-pasted directoriesHierarchical 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.hcl

Root 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

AspectWorkspacesTerragrunt
State isolationSame backend, different keysDifferent backends per environment
Config differencesConditional logic with terraform.workspaceSeparate inputs per environment
Module pinningSame module version across workspacesDifferent versions per environment
Cross-module depsNot supportedFirst-class dependency blocks
Blast radiusAll workspaces share a root moduleEach 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.

On this page