Trending
ABUAD, ISN partner to boost neuroscience research in Africa is trending now Greece reports 65 West Nile virus cases, 6 deaths is trending now Africa CDC and WHO call for urgent, community-led action to contain Ebola in the DRC is trending now New quantum microscopy trick quadruples microscope resolution is trending now South Korea space agency shares Moon images after SpaceX rocket debris crash is trending now NASA urges US residents to watch August 12’s total solar eclipse is trending now Cocoa Pod Waste Could Provide a Sustainable Concrete Retarder is trending now Major transfer twist: Rodri agrees to join Barcelona, rules out Real Madrid is trending now Rangers, Rivers United face Sobemap, San Pedro in CAF CL qualifiers is trending now WAFCON 2026: 3 quarter-final fixtures confirmed is trending now Vinicius Jr extends Real Madrid deal to 2032, ending transfer speculation is trending now Davido Ignites Controversy After Calling Iyabo Ojo’s Lover Paul O Broke in Public Clash is trending now ABUAD, ISN partner to boost neuroscience research in Africa is trending now Greece reports 65 West Nile virus cases, 6 deaths is trending now Africa CDC and WHO call for urgent, community-led action to contain Ebola in the DRC is trending now New quantum microscopy trick quadruples microscope resolution is trending now South Korea space agency shares Moon images after SpaceX rocket debris crash is trending now NASA urges US residents to watch August 12’s total solar eclipse is trending now Cocoa Pod Waste Could Provide a Sustainable Concrete Retarder is trending now Major transfer twist: Rodri agrees to join Barcelona, rules out Real Madrid is trending now Rangers, Rivers United face Sobemap, San Pedro in CAF CL qualifiers is trending now WAFCON 2026: 3 quarter-final fixtures confirmed is trending now Vinicius Jr extends Real Madrid deal to 2032, ending transfer speculation is trending now Davido Ignites Controversy After Calling Iyabo Ojo’s Lover Paul O Broke in Public Clash is trending now
Middle Grade Books

Terragrunt Tips and Tricks From The Field

Discover three distinct Terragrunt file patterns for managing your Terraform at scale.

Hey folks in this blog post I’ll be describing three distinct Terragrunt file patterns for orchestrating your Terraform codebase at scale. These Terragrunt tips and tricks will help you through tricky situations should your Terraform inputs rise in complexity and scale.

Terragrunt is an orchestrator for Terraform/OpenTofu and is designed for scalable deployments across multiple environments (e.g. DEV, TEST, UAT, PROD)

The three patterns we’ll explore:

Pattern When You Need It
Simple Input Mapping Multi-team configuration with simple data
TFVars Generation for Large Datasets Large datasets hitting CLI argument limits
Advanced Input Aggregation Multi-service architectures with varying schemas

Pattern 1: Simple Input Mapping

Example scenario: You’re managing platform resources across multiple business units, and each team wants to maintain their own configuration without stepping on each other’s toes.

This is the pattern I typically recommend when you’re starting your Terragrunt journey or when you have simple data that needs to be aggregated from multiple sources.

The traditional approach would be one massive configuration file that becomes a bottleneck for changes. Not ideal!

Here’s how the simple input mapping pattern might solve this problem. This approach allows different teams to maintain their own configuration files independently. Each business unit can modify their settings without creating merge conflicts or requiring coordination with other teams.

For context I used this pattern to manage GitHub repositories and teams across 9 business units with approximately 150+ repositories and 50+ teams.

First we define each source environment file separately to pull in all HCL data from them into our Terragrunt deployment.

locals {
  # Read each file separately
  business_unit1_env = read_terragrunt_config("business-unit1-env.hcl")
  business_unit2_env = read_terragrunt_config("business-unit2-env.hcl")
  business_unit3_env = read_terragrunt_config("business-unit3-env.hcl")
  business_unit4_env = read_terragrunt_config("business-unit4-env.hcl")
  business_unit5_env = read_terragrunt_config("business-unit5-env.hcl")
  business_unit6_env = read_terragrunt_config("business-unit6-env.hcl")
  business_unit7_env = read_terragrunt_config("business-unit7-env.hcl")
  business_unit8_env = read_terragrunt_config("business-unit8-env.hcl")
  business_unit9_env = read_terragrunt_config("business-unit9-env.hcl")
}

read_terragrunt_config() as shown above allows each team to manage their own config file. The Business Unit 2 team might manage business-unit2-env.hcl without affecting other team configurations.

Here’s where we combine the data from the multiple sources above while handling missing or malformed files gracefully. The concat() function safely combines lists from multiple sources. The try() function here can be essential for handling missing config files that might cause deployment failures.

# Concatenate the lists from all files into the single variable
repositories = concat( #concat used for combining lists/arrays. Use merge for combining maps/objects.
  try(local.business_unit1_env.locals.repositories, []),
  try(local.business_unit2_env.locals.repositories, []),
  try(local.business_unit3_env.locals.repositories, []),
  try(local.business_unit4_env.locals.repositories, []),
  try(local.business_unit5_env.locals.repositories, []),
  try(local.business_unit6_env.locals.repositories, []),
  try(local.business_unit7_env.locals.repositories, []),
  try(local.business_unit8_env.locals.repositories, []),
  try(local.business_unit9_env.locals.repositories, [])
)

# Concatenate the lists from all files into the single variable
teams = concat( #concat used for combining lists/arrays. Use merge for combining maps/objects.
  try(local.business_unit1_env.locals.teams, []),
  try(local.business_unit2_env.locals.teams, []),
  try(local.business_unit3_env.locals.teams, []),
  try(local.business_unit4_env.locals.teams, []),
  try(local.business_unit5_env.locals.teams, []),
  try(local.business_unit6_env.locals.teams, []),
  try(local.business_unit7_env.locals.teams, []),
  try(local.business_unit8_env.locals.teams, []),
  try(local.business_unit9_env.locals.teams, [])
)

The final step is straightforward - we pass our aggregated and processed data as inputs to the Terraform module. This keeps the interface clean and predictable.

inputs = {
  # Map module variables to local variables
  repositories = local.repositories
  teams        = local.teams
}

Pattern 1: Complete Example

# terragrunt.hcl - GitHub Platform Onboarding
# Pattern 1: Simple Input Mapping

# Include the root configuration
include {
  path = find_in_parent_folders("root-terragrunt.hcl")
}

# Set Terraform stack source location
terraform {
  source = "${get_repo_root()}/stacks/onboarding"
}

locals {
  # Read each file separately
  business_unit1_env = read_terragrunt_config("business-unit1-env.hcl")
  business_unit2_env = read_terragrunt_config("business-unit2-env.hcl")
  business_unit3_env = read_terragrunt_config("business-unit3-env.hcl")
  business_unit4_env = read_terragrunt_config("business-unit4-env.hcl")
  business_unit5_env = read_terragrunt_config("business-unit5-env.hcl")
  business_unit6_env = read_terragrunt_config("business-unit6-env.hcl")
  business_unit7_env = read_terragrunt_config("business-unit7-env.hcl")
  business_unit8_env = read_terragrunt_config("business-unit8-env.hcl")
  business_unit9_env = read_terragrunt_config("business-unit9-env.hcl")

  # Concatenate the lists from all files into the single variable
  repositories = concat( #concat used for combining lists/arrays. Use merge for combining maps/objects.
    try(local.business_unit1_env.locals.repositories, []),
    try(local.business_unit2_env.locals.repositories, []),
    try(local.business_unit3_env.locals.repositories, []),
    try(local.business_unit4_env.locals.repositories, []),
    try(local.business_unit5_env.locals.repositories, []),
    try(local.business_unit6_env.locals.repositories, []),
    try(local.business_unit7_env.locals.repositories, []),
    try(local.business_unit8_env.locals.repositories, []),
    try(local.business_unit9_env.locals.repositories, [])
  )

  # Concatenate the lists from all files into the single variable
  teams = concat( #concat used for combining lists/arrays. Use merge for combining maps/objects.
    try(local.business_unit1_env.locals.teams, []),
    try(local.business_unit2_env.locals.teams, []),
    try(local.business_unit3_env.locals.teams, []),
    try(local.business_unit4_env.locals.teams, []),
    try(local.business_unit5_env.locals.teams, []),
    try(local.business_unit6_env.locals.teams, []),
    try(local.business_unit7_env.locals.teams, []),
    try(local.business_unit8_env.locals.teams, []),
    try(local.business_unit9_env.locals.teams, [])
  )
}

inputs = {
  # Map module variables to local variables
  repositories = local.repositories
  teams        = local.teams
}

remote_state {
  backend = "azurerm"
  generate = {
    path      = "_backend.tf"
    if_exists = "overwrite_terragrunt"
  }
  config = {
    subscription_id      = "__TFSTATE_SUBSCRIPTION_ID__"
    tenant_id            = "__AZURE_TENANT_ID__"
    client_id            = "__AZURE_CLIENT_ID__"
    use_azuread_auth     = true
    use_oidc             = true
    resource_group_name  = "__TFSTATE_RESOURCE_GROUP_NAME__"
    storage_account_name = "__TFSTATE_STORAGE_ACCOUNT_NAME__"
    container_name       = "__TFSTATE_AZURE_PLATFORM_CONTAINER__"
    key                  = "github/terraform.tfstate"
  }
}

Pattern 2: TFVars Generation for Large Datasets

Example scenario: You’re managing enterprise infrastructure with thousands of resources and assignments, and your CI/CD workflows keep failing with “argument list too long” errors.

This pattern addresses enterprise scale implementations where CLI argument length limits become a constraint. It provides a practical approach for managing large datasets effectively. When you pass large datasets through Terragrunt inputs, they get converted to command-line arguments for the underlying Terraform execution. And there are limits!

I used this pattern to manage Azure RBAC across multiple Azure subscriptions with 2000+ role assignments, 500+ service principals, and 200+ Azure AD groups. But only after I started hitting the limits described above, and in hindsight we should have split up and managed the non-dependent resources separately instead of combining them.

First, we load configuration from multiple HCL environment inputs sources in the directory hierarchy. Using find_in_parent_folders() here is effective for certain folder hierarchies as it automatically discovers configuration files up the directory tree.

locals {
  # Automatically load environment-level variables
  rbac_groups_azure_env_vars  = read_terragrunt_config(find_in_parent_folders("rbac-groups-azure-env.hcl"))
  rbac_groups_github_env_vars = read_terragrunt_config(find_in_parent_folders("rbac-groups-github-env.hcl"))
  rbac_spns_env_vars          = read_terragrunt_config(find_in_parent_folders("rbac-spns-env.hcl"))
  platform_kv_appreg_env_vars = read_terragrunt_config(find_in_parent_folders("platform-kv-appreg-env.hcl"))

  # Map local variables to environment variables
  subscription_id                             = local.rbac_groups_azure_env_vars.locals.subscription_id
  groups                                      = local.rbac_groups_azure_env_vars.locals.groups
  groups_github                               = local.rbac_groups_github_env_vars.locals.groups_github
  spn_app_registrations                       = local.rbac_spns_env_vars.locals.spn_app_registrations
  spn_role_assignments                        = local.rbac_spns_env_vars.locals.spn_role_assignments
  managed_identity_directory_role_assignments = local.rbac_spns_env_vars.locals.managed_identity_directory_role_assignments
}

This is the key insight - we handle small and large datasets differently. Small variables use normal Terragrunt inputs, while large datasets get written to auto-loaded files to avoid CLI limits. This demonstrates the core functionality of Terragrunt’s generate blocks. Small, simple variables might use the normal inputs mechanism, while large, complex datasets could be written to .auto.tfvars.json files that Terraform automatically loads.

# Generate a tfvars file with the large variable inputs to avoid command-line length limits 
# e.g. 'argument list too long' errors in GitHub workflow runs
# Terraform will automatically load *.auto.tfvars.json files
generate "large_vars" {
  path              = "large_vars.auto.tfvars.json"
  if_exists         = "overwrite"
  disable_signature = true
  contents  = jsonencode({
    spn_app_registrations                       = local.spn_app_registrations
    spn_role_assignments                        = local.spn_role_assignments
    managed_identity_directory_role_assignments = local.managed_identity_directory_role_assignments
    # add more large variables here as needed if you are seeing 'argument list too long' errors in the GitHub workflow runs.
  })
}

inputs = {
  # Map smaller variables via inputs (these are safe for command-line)
  subscription_id      = local.subscription_id
  groups               = local.groups
  groups_github        = local.groups_github
  app_reg_key_vault_id = local.platform_kv_appreg_env_vars.locals.app_reg_key_vault_id

  # Large variables (spn_app_registrations, spn_role_assignments, managed_identity_directory_role_assignments)
  # are written to .terraform/large_vars.auto.tfvars.json by the generate 'large_vars' block above.
  # Terraform will automatically load them from the file
}

Pattern 2: Complete Example

# terragrunt.hcl - Enterprise RBAC Management
# Pattern 2: TFVars Generation for Large Datasets

# Include the root configuration
include {
  path = find_in_parent_folders("root-terragrunt.hcl")
}

# Set Terraform stack source location
terraform {
  source = "${get_repo_root()}/stacks/rbac"
}

locals {
  # Automatically load environment-level variables
  rbac_groups_azure_env_vars  = read_terragrunt_config(find_in_parent_folders("rbac-groups-azure-env.hcl"))
  rbac_groups_github_env_vars = read_terragrunt_config(find_in_parent_folders("rbac-groups-github-env.hcl"))
  rbac_spns_env_vars          = read_terragrunt_config(find_in_parent_folders("rbac-spns-env.hcl"))
  platform_kv_appreg_env_vars = read_terragrunt_config(find_in_parent_folders("platform-kv-appreg-env.hcl"))

  # Map local variables to environment variables
  subscription_id                             = local.rbac_groups_azure_env_vars.locals.subscription_id
  groups                                      = local.rbac_groups_azure_env_vars.locals.groups
  groups_github                               = local.rbac_groups_github_env_vars.locals.groups_github
  spn_app_registrations                       = local.rbac_spns_env_vars.locals.spn_app_registrations
  spn_role_assignments                        = local.rbac_spns_env_vars.locals.spn_role_assignments
  managed_identity_directory_role_assignments = local.rbac_spns_env_vars.locals.managed_identity_directory_role_assignments
}

# Generate a tfvars file with the large variable inputs to avoid command-line length limits
# e.g. 'argument list too long' errors in GitHub workflow runs
# Terraform will automatically load *.auto.tfvars.json files
generate "large_vars" {
  path              = "large_vars.auto.tfvars.json"
  if_exists         = "overwrite"
  disable_signature = true
  contents  = jsonencode({
    spn_app_registrations                       = local.spn_app_registrations
    spn_role_assignments                        = local.spn_role_assignments
    managed_identity_directory_role_assignments = local.managed_identity_directory_role_assignments
    # add more large variables here as needed if you are seeing 'argument list too long' errors in the GitHub workflow runs.
  })
}

inputs = {
  # Map smaller variables via inputs (these are safe for command-line)
  subscription_id      = local.subscription_id
  groups               = local.groups
  groups_github        = local.groups_github
  app_reg_key_vault_id = local.platform_kv_appreg_env_vars.locals.app_reg_key_vault_id

  # Large variables (spn_app_registrations, spn_role_assignments, managed_identity_directory_role_assignments)
  # are written to .terraform/large_vars.auto.tfvars.json by the generate 'large_vars' block above.
  # Terraform will automatically load them from the file
}

# Generate an Azure provider block
generate "provider" {
  path      = "_provider.tf"
  if_exists = "overwrite" #overwriting root terragrunt file
  contents  = <<EOF
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = ">=4.41.0"
    }
    azuread = {
      source  = "hashicorp/azuread"
      version = "3.5.0"
    }
  }
}

provider "azurerm" {
  use_oidc                        = true
  resource_provider_registrations = "extended"
  storage_use_azuread             = true
  subscription_id                 = var.subscription_id
  features {}
}

provider "azuread" {
  tenant_id = "__AZURE_TENANT_ID__"
}

EOF
}

Pattern 3: Advanced Input Aggregation

Example scenario: You’re managing complex infrastructure configurations across multiple applications and services, each with different requirements and schemas.

This represents the most advanced pattern for managing complex, multi-service architectures. While this pattern requires careful consideration, it becomes essential for maintaining organised configurations at scale.

I used this pattern to manage an Azure Application Gateway with 50+ backend pools, 30+ SSL certificates, 100+ routing rules across multiple applications, plus Azure Front Door with 20+ custom domains and 40+ origins

First we explicitly define which configuration files to process.

locals {
  app_gateway_base = read_terragrunt_config("app-gateway-env.hcl") # Contains all the base level configuration for App Gateway
  frontdoor_base   = read_terragrunt_config("frontdoor-env.hcl")   # Contains all the base level configuration for Front Door

  # List of config files that have various app specific configs, such as Backend pools, HTTP listeners, SSL certs, etc. that need to be merged
  app_gateway_config_files = [
    "app-gateway-env.hcl",           // Base App Gateway Config
    "app-gateway-application1.hcl",  // Application1 specific config
    "app-gateway-application2.hcl",  // Application2 specific config
    "app-gateway-application3.hcl",  // Application3 specific config
    // Add other config files here as they are added
  ]

  # List of config files that have various frontdoor specific configs, such as Origins, Routes, Custom Domains, etc. that need to be merged
  frontdoor_config_files = [
    "frontdoor-env.hcl",           // Base Front Door Config
    "frontdoor-application1.hcl",  // Application1 specific config
    "frontdoor-application3.hcl",  // Application3 specific config
    // Add other config files here as they are added
  ]

  # Read in all the app gateway and frontdoor config files
  app_gateway_configs = [for file in local.app_gateway_config_files : read_terragrunt_config(file)] # Read in all the app gateway config files
  frontdoor_configs   = [for file in local.frontdoor_config_files : read_terragrunt_config(file)]   # Read in all the frontdoor config files
}

Rather than reading each file individually, we use for expressions to process all configuration files in a single operation. This reduces repetitive code and makes the pattern more maintainable. Using for expressions allows you to process all configuration files efficiently rather than manually reading each one individually.

# Read in all the app gateway and frontdoor config files
app_gateway_configs = [for file in local.app_gateway_config_files : read_terragrunt_config(file)]
frontdoor_configs   = [for file in local.frontdoor_config_files : read_terragrunt_config(file)]

This is where the real complexity lies - combining data from multiple sources while respecting data types and handling missing values. Each data type requires a specific approach.

Different data types require specific merging approaches:

  • Lists get flattened with flatten()
  • Maps get merged with merge() and the spread operator
  • Everything is wrapped with try() for safe access
inputs = {
  # Map application gateway module variables to local variables
  subscription_id                       = local.app_gateway_base.locals.subscription_id
  name                                  = local.app_gateway_base.locals.name
  location                              = local.app_gateway_base.locals.location
  resource_group_name                   = local.app_gateway_base.locals.resource_group_name
  tags                                  = local.app_gateway_base.locals.tags
  
  # Complex merging strategies for different data types
  per_app_waf_policies                  = merge([for config in local.app_gateway_configs : try(config.locals.waf_policies, {})]...)
  backend_address_pools                 = flatten([for config in local.app_gateway_configs : try(config.locals.backend_address_pools, [])])
  backend_http_settings                 = flatten([for config in local.app_gateway_configs : try(config.locals.backend_http_settings, [])])
  ssl_certificates                      = flatten([for config in local.app_gateway_configs : try(config.locals.ssl_certificates, [])])
  http_listeners                        = flatten([for config in local.app_gateway_configs : try(config.locals.http_listeners, [])])
  request_routing_rules                 = flatten([for config in local.app_gateway_configs : try(config.locals.request_routing_rules, [])])
  
  # Map frontdoor module variables to local variables  
  frontdoor_custom_domains    = merge([for config in local.frontdoor_configs : try(config.locals.frontdoor_custom_domains, {})]...)
  origins                     = merge([for config in local.frontdoor_configs : try(config.locals.origins, {})]...)
  origin_group_configuration  = merge([for config in local.frontdoor_configs : try(config.locals.origin_group_configuration, {})]...)
  routes                      = merge([for config in local.frontdoor_configs : try(config.locals.routes, {})]...)
}

Some key callouts:

  • Data type compatibility: Using concat() on maps or merge() on lists will cause errors
  • Spread operator usage: merge([map1, map2]...) flattens the list of maps before merging

This pattern is appropriate when:

  • Multiple services have different configuration schemas
  • Adding new services requires touching multiple configuration areas
  • Team members frequently need to locate specific configuration settings
  • Configuration management overhead impacts development productivity

Pattern 3: Complete Example

# terragrunt.hcl - Application Connectivity (App Gateway + Front Door)
# Pattern 3: Advanced Input Aggregation

# Include the root configuration
include {
  path = find_in_parent_folders("root-terragrunt.hcl")
}

# Set Terraform stack source location
terraform {
  source = "${get_repo_root()}/stacks/app-connectivity"
}

locals {
  app_gateway_base = read_terragrunt_config("app-gateway-env.hcl") # Contains all the base level configuration for App Gateway
  frontdoor_base   = read_terragrunt_config("frontdoor-env.hcl")   # Contains all the base level configuration for Front Door

  # List of config files that have various app specific configs, such as Backend pools, HTTP listeners, SSL certs, etc. that need to be merged
  app_gateway_config_files = [
    "app-gateway-env.hcl",           // Base App Gateway Config
    "app-gateway-application1.hcl",  // Application1 specific config
    "app-gateway-application2.hcl",  // Application2 specific config
    "app-gateway-application3.hcl",  // Application3 specific config
    // Add other config files here as they are added
  ]

  # List of config files that have various frontdoor specific configs, such as Origins, Routes, Custom Domains, etc. that need to be merged
  frontdoor_config_files = [
    "frontdoor-env.hcl",           // Base Front Door Config
    "frontdoor-application1.hcl",  // Application1 specific config
    "frontdoor-application3.hcl",  // Application3 specific config
    // Add other config files here as they are added
  ]

  # Read in all the app gateway and frontdoor config files
  app_gateway_configs = [for file in local.app_gateway_config_files : read_terragrunt_config(file)] # Read in all the app gateway config files
  frontdoor_configs   = [for file in local.frontdoor_config_files : read_terragrunt_config(file)]   # Read in all the frontdoor config files
}

inputs = {
  # Map application gateway module variables to local variables
  subscription_id                       = local.app_gateway_base.locals.subscription_id
  name                                  = local.app_gateway_base.locals.name
  location                              = local.app_gateway_base.locals.location
  resource_group_name                   = local.app_gateway_base.locals.resource_group_name
  tags                                  = local.app_gateway_base

                                    
                        View original source →
                    
                            

Related