Root vs Child Module #

Every Terraform configuration you run with terraform apply is a root module — the directory you’re in. The root module is the orchestrator: it calls child modules, passes values into them, and takes outputs from them. A child module is a work unit that can be called many times from different places. Understanding this division of responsibility — what belongs in the root and what belongs in a child — is the key to clean Terraform architecture.

flowchart TD
    A["Root Module\n(entry point)"] -->|"module calls"| B["Child Module A"]
    A -->|"module calls"| C["Child Module B"]
    B -->|"outputs"| A
    C -->|"outputs"| A
    B -->|"resource refs"| C

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#f59e0b,stroke:#d97706,color:#fff

The Root Module: Orchestrator #

The root module is the entry point of every terraform apply. It reads variables from tfvars, calls child modules, and glues everything together.

# environments/production/main.tf — this is the root module

# The root module calls child modules and passes configuration
module "vpc" {
  source = "../../modules/vpc"

  cidr_block  = var.vpc_cidr      # Values from the root module's variables
  environment = var.environment
}

module "eks" {
  source = "../../modules/eks"

  cluster_name       = "${var.environment}-eks"
  vpc_id             = module.vpc.vpc_id             # Output from another module
  private_subnet_ids = module.vpc.private_subnet_ids
}

module "rds" {
  source = "../../modules/rds"

  identifier         = "${var.environment}-db"
  vpc_id             = module.vpc.vpc_id
  private_subnet_ids = module.vpc.private_subnet_ids
}

# The root module can also have direct resources (not in a child module)
# for resources too specific to abstract
resource "aws_route53_record" "app" {
  zone_id = var.hosted_zone_id
  name    = "app.${var.domain}"
  type    = "CNAME"
  ttl     = 300
  records = [module.eks.load_balancer_dns]
}

The Child Module: Work Unit #

A child module contains the implementation of one infrastructure “concept”. It doesn’t know where it’s called from, which environment, or how its outputs will be used — it just does its job based on the inputs it receives.

# modules/rds/main.tf — this is a child module

# The child module doesn't know about the environment or project
# It only receives variables and creates resources

resource "aws_db_subnet_group" "this" {
  name       = "${var.identifier}-subnet-group"
  subnet_ids = var.subnet_ids

  tags = merge(var.tags, {
    Name = "${var.identifier}-subnet-group"
  })
}

resource "aws_security_group" "this" {
  name   = "${var.identifier}-sg"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = var.allowed_cidr_blocks
  }
}

resource "aws_db_instance" "this" {
  identifier        = var.identifier
  engine            = var.engine
  instance_class    = var.instance_class
  allocated_storage = var.allocated_storage

  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.this.id]

  tags = var.tags
}
flowchart TD
    subgraph ROOT["Root Module"]
        R_CALL["module "vpc""]
    end
    subgraph CHILD["Child Module: RDS"]
        C_RES["aws_db_instance\naws_security_group\naws_db_subnet_group"]
        C_OUT["outputs: endpoint\naddress, port"]
    end

    R_CALL -->|"source = ./modules/rds"| C_RES
    C_RES --> C_OUT
    C_OUT -->|"module.rds.endpoint"| ROOT

    style ROOT fill:#e8f5e9,stroke:#2e7d32
    style CHILD fill:#e3f2fd,stroke:#1565c0

Child Module Sources: The Different Source Types #

Child modules can come from various sources. The choice of source affects how modules are versioned and updated.

# SOURCE 1: Local path — for modules in the same monorepo
module "vpc" {
  source = "../../modules/vpc"
  # Relative path from the current root module directory
}

# SOURCE 2: Terraform Registry — public modules from registry.terraform.io
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
  # Format: <namespace>/<module>/<provider>
}

# SOURCE 3: Git repository — a module in a separate repo
module "vpc" {
  source = "git::https://github.com/org/terraform-module-vpc.git?ref=v2.1.0"
  # ?ref= can be a tag, branch, or commit hash
}

# SOURCE 4: Git over SSH
module "vpc" {
  source = "git::ssh://[email protected]/org/terraform-module-vpc.git?ref=v2.1.0"
}

# SOURCE 5: A subdirectory within a Git repository
module "vpc" {
  source = "git::https://github.com/org/terraform-modules.git//modules/vpc?ref=v1.0.0"
  # // (double slash) separates the repo URL from the path within the repo
}

How State Manages Resources from Child Modules #

Resources created by a child module go into the root module’s state — not a separate state. Their addresses reflect the module hierarchy.

terraform state list

# Output — resources from child modules are prefixed with module.<name>:
# module.vpc.aws_vpc.this
# module.vpc.aws_subnet.public[0]
# module.vpc.aws_subnet.public[1]
# module.vpc.aws_subnet.private[0]
# module.vpc.aws_internet_gateway.this
# module.eks.aws_eks_cluster.this
# module.eks.aws_eks_node_group.workers
# module.rds.aws_db_instance.this
# module.rds.aws_security_group.this
# aws_route53_record.app    ← A direct root module resource (no module. prefix)
# State operations on resources inside modules
terraform state show module.vpc.aws_vpc.this
terraform state rm module.vpc.aws_subnet.public[0]

# Targeted plan/apply for a specific module
terraform plan -target=module.vpc
terraform apply -target=module.rds

Module Composition: Modules Calling Modules #

A child module can call other child modules — but this needs to be done carefully to avoid creating too deep a hierarchy.

# modules/eks/main.tf — a child module calling another child module

# The EKS module calls the IAM module to create the needed role
module "cluster_iam" {
  source = "../iam-role"

  name               = "${var.cluster_name}-cluster-role"
  assume_role_service = "eks.amazonaws.com"
  policy_arns = [
    "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  ]
}

resource "aws_eks_cluster" "this" {
  name     = var.cluster_name
  role_arn = module.cluster_iam.role_arn  # Output from the sub-module

  vpc_config {
    subnet_ids = var.subnet_ids
  }
}
RECOMMENDED HIERARCHY DEPTH:

Root Module
  └── Child Module (level 1) ← IDEAL, easy to understand
      └── Child Module (level 2) ← STILL OK for clear abstractions
          └── Child Module (level 3) ← GETTING COMPLEX, reconsider
              └── ... ← AVOID — hard to debug, hard to test

Division of Responsibilities #

THE ROOT MODULE IS RESPONSIBLE FOR:
  ✓ Composition — calling the right modules
  ✓ Environment configuration — values that differ per environment
  ✓ "Glue" between modules — passing one module's output to another module's input
  ✓ Resources very specific to a particular deployment
  ✓ Backend configuration and provider configuration
  ✓ Outputs that expose values to external systems

THE CHILD MODULE IS RESPONSIBLE FOR:
  ✓ Implementing one infrastructure "concept"
  ✓ Validating the inputs it receives
  ✓ Exposing useful outputs to callers
  ✓ Sensible defaults for non-required configuration
  ✗ DON'T: hardcode values that should be configurable
  ✗ DON'T: assume deployment context (environment, region)


Root Module Special Behavior #

The root module has special behavior that differs from child modules.

# Root module:
# - Backend configuration (root only)
# - Provider configuration (root only)
# - Variable values provided from the CLI/tfvars
# - Outputs shown in the terminal

# Child module:
# - CANNOT have a backend config
# - Has no provider of its own (inherited from the root)
# - Variable values provided from the root module
# - Outputs only accessible by the root module
# Root module (.)
terraform {
  backend "s3" { ... }  # ✓ Root only
  required_providers { ... }  # ✓ Root only
}

provider "aws" { ... }  # ✓ Root only

module "networking" {
  source = "./modules/networking"
  # Variables sent from the root
  vpc_cidr = var.vpc_cidr
}

# Child module (./modules/networking)
# NO backend, provider, or terraform block
variable "vpc_cidr" { ... }  # Received from the root
resource "aws_vpc" "main" { ... }
output "vpc_id" { ... }  # Returned to the root

Module Composition Patterns #

Good composition patterns make code easier to maintain.

# PATTERN 1: Thin root module
# The root module only calls child modules
module "networking" {
  source = "./modules/networking"
  vpc_cidr = var.vpc_cidr
}

module "compute" {
  source      = "./modules/compute"
  vpc_id      = module.networking.vpc_id
  subnet_ids  = module.networking.private_subnet_ids
}

module "database" {
  source      = "./modules/database"
  subnet_ids  = module.networking.database_subnet_ids
  vpc_id      = module.networking.vpc_id
}

# PATTERN 2: Orchestration module
# One large module that calls smaller modules
module "infrastructure" {
  source = "./modules/infrastructure"
  
  environment    = var.environment
  vpc_cidr       = var.vpc_cidr
  instance_count = var.instance_count
}
# The infrastructure module calls networking, compute, database
flowchart TD
    subgraph THIN["Thin Root Module"]
        R1["Root"] --> M1["networking"]
        R1 --> M2["compute"]
        R1 --> M3["database"]
    end

    subgraph ORCH["Orchestration Module"]
        R2["Root"] --> O1["infrastructure"]
        O1 --> N1["networking"]
        O1 --> N2["compute"]
        O1 --> N3["database"]
    end

    style R1 fill:#e3f2fd,stroke:#1565c0
    style R2 fill:#e3f2fd,stroke:#1565c0
    style O1 fill:#fff3e0,stroke:#e65100

Child Module Best Practices #

# A child module SHOULD:
# 1. Have no backend configuration
# 2. Have no provider configuration (except aliases)
# 3. Receive all values through variables
# 4. Return all needed values through outputs

# modules/networking/main.tf
variable "vpc_cidr" {
  type = string
}

variable "environment" {
  type = string
}

resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr
  tags = { Name = "${var.environment}-vpc" }
}

output "vpc_id" {
  value = aws_vpc.main.id
}

output "vpc_cidr_block" {
  value = aws_vpc.main.cidr_block
}

Summary #

  • The root module is the orchestrator — calls child modules, passes values, and connects one module’s outputs to another module’s inputs.
  • A child module is a work unit that doesn’t know where it’s called — it only receives inputs and produces outputs.
  • Resources from child modules go into the root module’s state with the address module.<name>.<type>.<name> — not separate state.
  • Four source types: local paths (monorepo), the Terraform Registry, Git repositories, and Git subdirectories.
  • Pin external module versions with version or ?ref= — don’t let modules always grab the latest version without control.
  • Limit hierarchy depth — a module calling a module calling a module makes debugging far harder.

← Previous: Structure   Next: Versioning →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact