Back to articles
blog — iac — zsh$ cat iac.md# Nov 21, 2025Stop duplicating Terraformvalues, use Terragrunt'shierarchy
5 min read

Root configs, account configs, environment configs. One value, defined once, available everywhere. No copy-paste.

TerraformTerragruntDevOpsIaC

I had 12 environment directories. Each one had the same AWS region defined. Each one had the same tags. Each one had the same KMS key configuration

Change the region? Update 12 files. Add a new tag? Update 12 files. Miss one? That environment breaks differently

Terragrunt has a hierarchy system that fixes this. Define values once at the right level, they cascade down to everything below

The hierarchy that makes sense

Think about your infrastructure values. Some apply everywhere. Some apply to an AWS account. Some apply to a specific environment. Some apply to a region

root.hcl              → Everything: backend config, provider setup, project-wide tags
account.hcl           → AWS account-specific: account ID, IAM roles, cost center
environment.hcl       → Environment-specific: prod vs staging configs, domain names
region.hcl            → Region-specific: availability zones, regional endpoints

Each level inherits from the one above it. The database module in prod/us-east-1/database/ automatically gets values from all four levels

No duplication. No copy-paste. One source of truth per value

Root config: the foundation

root.hcl sits at the top of your infrastructure directory. It defines what every single resource shares:

# infrastructure/root.hcl
locals {
  project_name = "myapp"
  project_id   = "app123"

  # These tags go on every resource
  root_tags = {
    "project"     = "myapp"
    "managed-by"  = "terraform"
    "cost-center" = "engineering"
  }
}

# Read child configs
locals {
  account_vars     = read_terragrunt_config(find_in_parent_folders("account.hcl"))
  environment_vars = read_terragrunt_config(find_in_parent_folders("environment.hcl"))
  region_vars      = read_terragrunt_config(find_in_parent_folders("region.hcl"))
}

# Backend for all modules
remote_state {
  backend = "s3"
  config = {
    bucket         = "myapp-terraform-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

# Provider config generated for every module
generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    provider "aws" {
      region = "${local.region_vars.locals.region}"

      assume_role {
        role_arn = "${local.account_vars.locals.iam_role}"
      }

      default_tags {
        tags = var.default_tags
      }
    }
  EOF
}

# Make everything available as inputs
inputs = merge(
  local.account_vars.locals,
  local.environment_vars.locals,
  local.region_vars.locals,
  {
    project_name = local.project_name
    default_tags = merge(
      local.root_tags,
      local.account_vars.locals.account_tags,
      local.environment_vars.locals.environment_tags
    )
  }
)

Write your backend config once. Your provider config once. Your root tags once

Every module gets them automatically. Change the S3 bucket? One line. Change a tag? One line

Account config: AWS-specific stuff

account.hcl defines what’s specific to an AWS account. You have different accounts for prod and non-prod? Each account has its own file:

# infrastructure/prod-account/account.hcl
locals {
  account_id   = "123456789012"
  account_name = "production"

  # IAM role to assume for deployments
  iam_role = "arn:aws:iam::123456789012:role/terraform-deploy"

  # Cost tracking tags specific to this account
  account_tags = {
    "environment" = "production"
    "account-id"  = "123456789012"
    "criticality" = "high"
  }

  # Prod-specific config
  enable_deletion_protection = true
  backup_retention_days      = 90
}
# infrastructure/staging-account/account.hcl
locals {
  account_id   = "987654321098"
  account_name = "staging"

  iam_role = "arn:aws:iam::987654321098:role/terraform-deploy"

  account_tags = {
    "environment" = "staging"
    "account-id"  = "987654321098"
    "criticality" = "medium"
  }

  enable_deletion_protection = false
  backup_retention_days      = 30
}

The account ID, IAM role, and account-level policies are defined once. Every resource in that account gets them

Change the IAM role for prod? One file. Add account-level tags? One file

Environment config: prod vs staging differences

environment.hcl defines what’s different between prod and staging within the same account:

# infrastructure/prod-account/prod/environment.hcl
locals {
  environment = "prod"

  environment_tags = {
    "environment" = "prod"
  }

  # Prod domain
  domain_name = "myapp.com"

  # Prod sizing
  database_instance_class = "db.r6g.xlarge"
  cache_node_type         = "cache.r6g.large"

  # Prod scaling
  min_instances = 3
  max_instances = 10

  # Prod monitoring
  enable_detailed_monitoring = true
  alarm_email               = "oncall@company.com"
}
# infrastructure/prod-account/staging/environment.hcl
locals {
  environment = "staging"

  environment_tags = {
    "environment" = "staging"
  }

  domain_name = "staging.myapp.com"

  # Smaller for staging
  database_instance_class = "db.t4g.medium"
  cache_node_type         = "cache.t4g.small"

  min_instances = 1
  max_instances = 3

  enable_detailed_monitoring = false
  alarm_email               = "dev-team@company.com"
}

The differences between prod and staging are explicit. Instance sizes, domain names, scaling parameters

All your prod resources get prod values. All your staging resources get staging values. No mixing them up

Region config: geographic stuff

region.hcl defines what’s specific to a region:

# infrastructure/prod-account/prod/us-east-1/region.hcl
locals {
  region = "us-east-1"

  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

  # Regional endpoints
  regional_domain = "use1.myapp.com"
}
# infrastructure/prod-account/prod/eu-west-1/region.hcl
locals {
  region = "eu-west-1"

  availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]

  regional_domain = "euw1.myapp.com"
}

Each region has its own config. Deploy to a new region? Copy the region directory, update region.hcl

Everything else cascades from above. The account config, environment config, root config all apply automatically

Module config: the specific stuff

At the bottom of the hierarchy, each module has a terragrunt.hcl:

# infrastructure/prod-account/prod/us-east-1/database/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}

terraform {
  source = "${get_repo_root()}/modules/database"
}

dependency "network" {
  config_path = "../network"
}

inputs = {
  # Module-specific values
  db_name          = "myapp_prod"
  multi_az         = true

  # Everything else comes from the hierarchy:
  # - region from region.hcl
  # - database_instance_class from environment.hcl
  # - backup_retention_days from account.hcl
  # - default_tags from root.hcl

  vpc_id     = dependency.network.outputs.vpc_id
  subnet_ids = dependency.network.outputs.database_subnet_ids
}

The module config is tiny. It only defines what’s unique to this specific database

Everything else? Already defined higher up. The hierarchy provides it automatically

How values cascade

When you run terragrunt apply in prod-account/prod/us-east-1/database/:

  1. Terragrunt finds root.hcl and loads project-wide config
  2. Loads account.hcl for account-specific values
  3. Loads environment.hcl for prod vs staging differences
  4. Loads region.hcl for regional settings
  5. Merges everything in inputs

The database module receives:

  • project_name from root
  • account_id, iam_role, backup_retention_days from account
  • environment, database_instance_class from environment
  • region, availability_zones from region
  • db_name, multi_az from the module itself

One variable, defined once, at the right level. No duplication

The merge strategy

The inputs block in root.hcl merges everything:

inputs = merge(
  local.account_vars.locals,
  local.environment_vars.locals,
  local.region_vars.locals,
  {
    project_name = local.project_name
    default_tags = merge(
      local.root_tags,
      local.account_vars.locals.account_tags,
      local.environment_vars.locals.environment_tags
    )
  }
)

Tags get merged. Account tags override root tags. Environment tags override account tags

You want global tags? Put them in root. Account-specific? Put them in account. Environment-specific? Put them in environment

Directory structure

infrastructure/
├── root.hcl
├── modules/
│   ├── database/
│   ├── cache/
│   └── app/
├── prod-account/
│   ├── account.hcl
│   ├── prod/
│   │   ├── environment.hcl
│   │   ├── us-east-1/
│   │   │   ├── region.hcl
│   │   │   ├── database/
│   │   │   │   └── terragrunt.hcl
│   │   │   ├── cache/
│   │   │   │   └── terragrunt.hcl
│   │   │   └── app/
│   │   │       └── terragrunt.hcl
│   │   └── eu-west-1/
│   │       ├── region.hcl
│   │       ├── database/
│   │       └── ...
│   └── staging/
│       ├── environment.hcl
│       └── us-east-1/
│           └── ...
└── staging-account/
    ├── account.hcl
    └── ...

Clear hierarchy. You know where to find things. Change account-level config? account.hcl. Change prod config? environment.hcl

No searching through 47 files to find where that value is defined

Shared module configs

Sometimes multiple modules need the same config. Create _envcommon/:

# infrastructure/_envcommon/database.hcl
terraform {
  source = "${get_repo_root()}/modules/database"
}

locals {
  # Read environment config to find network dependencies
  environment_vars = read_terragrunt_config(
    find_in_parent_folders("environment.hcl")
  )
}

dependency "network" {
  config_path = "../network"
}

inputs = {
  vpc_id     = dependency.network.outputs.vpc_id
  subnet_ids = dependency.network.outputs.database_subnet_ids
}

Then each database instance includes it:

# prod-account/prod/us-east-1/database/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}

include "envcommon" {
  path = "${dirname(find_in_parent_folders("root.hcl"))}/_envcommon/database.hcl"
}

inputs = {
  db_name  = "myapp_prod"
  multi_az = true
}

The common config is shared. The specific config is local. No duplication

Real example: changing regions

You’re deploying to eu-west-1. Copy the region directory:

cp -r prod-account/prod/us-east-1 prod-account/prod/eu-west-1

Edit one file:

# prod-account/prod/eu-west-1/region.hcl
locals {
  region = "eu-west-1"
  availability_zones = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
}

Run it:

cd prod-account/prod/eu-west-1
terragrunt run-all apply

Every module in that region gets:

  • The new region automatically
  • The same account config
  • The same environment config
  • The same root config

You changed one value. Everything else cascaded

What this fixes

Before Terragrunt hierarchy, I had:

  • AWS region defined in 12 places
  • Backend config copy-pasted everywhere
  • Tags duplicated across all environments
  • IAM roles scattered through configs
  • No idea which value should go where

After:

  • Region in region.hcl, once
  • Backend in root.hcl, once
  • Tags merged from root, account, environment
  • IAM role in account.hcl, once
  • Clear hierarchy, obvious where values belong

I changed our prod AWS account ID. One file. Took 10 seconds

Before this? I would have grepped through 47 files, updated each one, missed two, broken staging, spent an hour debugging

Common patterns

Cross-environment dependencies: Prod uses staging’s VPC for VPN access:

# prod-account/prod/environment.hcl
locals {
  network_dependency_path = "${get_repo_root()}/infrastructure/staging-account/staging/us-east-1/network"
}

Dynamic prefixes: Generate resource names from hierarchy values:

# root.hcl
locals {
  prefix = "${local.project_id}-${local.environment_vars.locals.environment}-${local.region_vars.locals.region}"
}

inputs = {
  resource_prefix = local.prefix
}

Every resource in prod/us-east-1 gets prefix app123-prod-us-east-1. Every resource in staging/eu-west-1 gets app123-staging-eu-west-1. No conflicts

Conditional config: Enable features only in certain environments:

# prod-account/prod/environment.hcl
locals {
  enable_waf            = true
  enable_enhanced_monitoring = true
}

# staging-account/staging/environment.hcl
locals {
  enable_waf            = false
  enable_enhanced_monitoring = false
}

When this doesn’t help

Small projects with one environment. The hierarchy is overkill. Just use a single terragrunt.hcl

Wildly different infrastructure per environment. If prod and staging have nothing in common, the hierarchy doesn’t buy you much

Projects where every module is a special snowflake. If nothing is reusable, there’s nothing to cascade

Getting started

Add hierarchy to existing Terragrunt setup:

  1. Move backend config to root.hcl
  2. Create account.hcl with account-specific values
  3. Create environment.hcl with environment-specific values
  4. Create region.hcl with regional values
  5. Update root.hcl to read and merge them

Start with one value. Move AWS region from every module to region.hcl. See it work

Then move tags. Then IAM roles. One value at a time

After a week you’ll have config that makes sense. Values defined once. Clear hierarchy. No duplication

I spent 6 months copy-pasting Terraform config. I spent 2 days setting up this hierarchy. I’m never going back

Enjoyed this article?

Let me know! A share is always appreciated.

About the author

Sofiane Djerbi

Sofiane Djerbi

Cloud & Kubernetes Architect, FinOps Expert. I help companies build scalable, secure, and cost-effective infrastructures.

Comments