Cloud/ Terraform / Modules & Reusability / Terraform Nested Modules: Build Composable Infrastructure Hierarchies 2025

IaC Terraform Guide 3 of 4 50 guides ยท updated 2026

Infrastructure as code done right โ€” providers, state, reusable modules, and the workflow patterns that keep multi-cloud deployments sane in 2026.

Terraform Nested Modules

A nested module (also called a sub-module or child module) is a module that calls another module. This composition pattern lets you build higher-level abstractions from smaller, focused components โ€” a production-environment module that calls networking, database, and application modules, which each call even more focused modules.


What Nested Modules Enable

Root Module (environments/production/main.tf)
โ””โ”€โ”€ module "production" (modules/environment/main.tf)
โ”œโ”€โ”€ module "networking" (modules/networking/main.tf)
โ”‚ โ”œโ”€โ”€ aws_vpc
โ”‚ โ”œโ”€โ”€ aws_subnet (multiple)
โ”‚ โ””โ”€โ”€ aws_nat_gateway
โ”œโ”€โ”€ module "database" (modules/database/main.tf)
โ”‚ โ”œโ”€โ”€ aws_db_instance
โ”‚ โ””โ”€โ”€ aws_security_group
โ””โ”€โ”€ module "application" (modules/application/main.tf)
โ”œโ”€โ”€ aws_ecs_service
โ””โ”€โ”€ aws_lb

Each layer knows only about the layer directly below it. The root module doesnโ€™t know about VPCs โ€” it just calls the environment module and provides high-level config.


Basic Nested Module Example

# modules/networking/main.tf โ€” low-level networking module
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
tags = { Name = "${var.name}-vpc" }
}
resource "aws_subnet" "private" {
count = length(var.availability_zones)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 4, count.index)
availability_zone = var.availability_zones[count.index]
}
modules/networking/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
# modules/application-stack/main.tf โ€” calls networking as a sub-module
module "networking" {
source = "../networking" # Relative path from this module
name = var.name
cidr_block = var.vpc_cidr
availability_zones = var.availability_zones
}
module "database" {
source = "../database"
vpc_id = module.networking.vpc_id # Pass networking output to database
subnet_ids = module.networking.private_subnet_ids
name = var.name
}
module "application" {
source = "../ecs-service"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
db_host = module.database.endpoint # Pass database output to application
name = var.name
image = var.image
}
# modules/application-stack/outputs.tf โ€” bubble up what callers need
output "vpc_id" { value = module.networking.vpc_id }
output "app_endpoint" { value = module.application.load_balancer_dns }

Root Module Using the Stack

environments/production/main.tf
module "production" {
source = "../../modules/application-stack"
name = "production"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
image = "mycompany/app:v3.2.1"
}
output "production_url" {
value = module.production.app_endpoint
}

This root module is clean โ€” it describes intent, not implementation.


Referencing Nested Module Outputs

When a sub-moduleโ€™s output needs to reach the root, each layer must explicitly pass it up:

modules/networking/outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
# Layer 2 (stack): modules/application-stack/outputs.tf
output "vpc_id" { value = module.networking.vpc_id } # Re-exported
# Layer 1 (root): environments/production/outputs.tf
output "vpc_id" { value = module.production.vpc_id } # Re-exported again

This explicit propagation is verbose but intentional โ€” it makes module interfaces clear and prevents callers from depending on internal implementation details.


Using count and for_each with Nested Modules

# modules/environment/main.tf โ€” create a full environment per region
variable "regions" {
type = list(string)
default = ["us-east-1", "eu-west-1"]
}
module "networking" {
for_each = toset(var.regions)
source = "../networking"
name = "prod-${each.key}"
providers = {
aws = aws.by_region[each.key] # Pass region-specific provider
}
}

Depth Guidelines

Nested modules are powerful but composition depth adds cognitive overhead:

Recommended: 2-3 levels max
Root โ†’ Stack Module โ†’ Component Module โœ…
Getting complex: 4 levels
Root โ†’ Environment โ†’ Stack โ†’ Component โ†’ Sub-component โš ๏ธ
Too deep: 5+ levels
Root โ†’ ... โ†’ ... โ†’ ... โ†’ ... โ†’ Primitive โŒ (hard to follow data flow)

Signs youโ€™re nesting too deep:

Flatten when composition becomes a burden โ€” separate configurations with remote state data sources may be cleaner than deep nesting.


Module Dependency Graph

Terraform tracks dependencies between nested modules automatically:

Terminal window
# Generate visual dependency graph
terraform graph | dot -Tsvg > graph.svg
# Or view in terminal (requires graphviz)
terraform graph | dot -Tpng > graph.png

The graph shows how modules depend on each other and in what order Terraform will apply them.

Was this helpful?

Questions & Comments

Loading commentsโ€ฆ