Terraform

Designing Terraform Root and Child Modules: A Practical Hierarchy Guide

2026-04-19
NicheeLab Editorial Team

Terraform design comes down to one decision: what becomes the root and what gets carved out into child modules. Get this wrong and you will struggle with state management, reuse, and team collaboration.

This article covers the Terraform Associate (entry-level) exam angles while showing the hierarchy design principles that hold up in production, with minimal patterns and code.

Internalize the Definitions and Roles of Root vs Child

The root module is the set of .tf files in the directory where you run terraform init/plan/apply. A child module is one called by a module block from the root, and a child can call further child modules of its own.

The key point for both practice and the exam: concentrate provider configuration, state file, and I/O boundaries at the root, and keep child modules small and loosely coupled for reuse. As a rule, provider configuration lives in the root and is passed to children via the providers argument.

  • Root is the operational unit bound to a single state file (backend)
  • Child is a per-feature reuse unit. Make its I/O (variables/outputs) explicit
  • Centralize provider configuration in the root. Children externalize their provider dependency
  • Make inter-module dependencies explicit via outputs. Avoid implicit dependencies
AspectRoot moduleChild moduleNotes
Execution locationWhere you run terraform init/plan/applyExecuted when calledThe CLI always operates on the root
State managementWhere the backend config lives. Bound to one stateState is consolidated into the rootChildren normally do not have their own state
ProvidersDefines provider configurationReceives them via the providers argumentSupply aliases only when intentionally swapping
I/OAggregation point for variables.tf, tfvars, outputsExpose a minimal variables/outputs surfaceHide internal representation with locals
ReusabilityLow (mostly environment-specific)High (per-feature, general-purpose)Semantic versioning is recommended

Minimal example: a root calling a child

terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

module "network" {
  source    = "./modules/network"
  providers = { aws = aws }
  cidr      = var.vpc_cidr
}

Directory Design Patterns That Resist Breakage

Sharing modules in a single repository and splitting roots by environment is the go-to layout that balances learning cost and isolation. Splitting across multiple repositories is fine to defer until your change flow and release management are settled.

Prefer nouns over verbs in naming. Split modules along resource boundaries like network, compute, security, and let the root apply only the per-environment differences (dev/stg/prod).

  • Keep modules/ small and loosely coupled: one module, one responsibility
  • Under environments/ are the roots: separate tfvars and backend per environment
  • Include the environment name in the state key (prefix) to prevent collisions

Recommended layout (single repository)

repo-root/
  modules/
    network/
      main.tf
      variables.tf
      outputs.tf
    compute/
      main.tf
      variables.tf
      outputs.tf
  environments/
    dev/
      main.tf
      variables.tf
      terraform.tfvars
      backend.hcl
    stg/
      main.tf
      terraform.tfvars
      backend.hcl
    prod/
      main.tf
      terraform.tfvars
      backend.hcl

Split backend configs into files and pass them via the CLI (variables are not supported here)

# environments/dev/backend.hcl
bucket         = "my-tfstate-bucket"
key            = "env/dev/terraform.tfstate"
region         = "ap-northeast-1"
dynamodb_table = "my-tf-lock"

# 初期化
# terraform -chdir=environments/dev init -backend-config=backend.hcl

Lock Down the Flow of variables / outputs / locals

In a child module, the input boundary is variables.tf and the output boundary is outputs.tf. Keep the internal representation inside locals so the root cannot see it.

The root supplies only the per-environment differences via tfvars, and makes dependencies explicit by passing one module's output as the input of another.

  • Strictly manage required vs default for child variables
  • Expose only the outputs that downstream consumers truly need
  • Use a naming convention on locals to signal they are not exposed externally

Minimal I/O example

# modules/network/variables.tf
variable "cidr" {
  type        = string
  description = "VPC CIDR"
}

# modules/network/main.tf
resource "aws_vpc" "this" {
  cidr_block = var.cidr
  tags = { Name = "nlab-vpc" }
}

# modules/network/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

# environments/dev/main.tf
module "network" {
  source = "../../modules/network"
  cidr   = var.vpc_cidr
}

module "compute" {
  source = "../../modules/compute"
  vpc_id = module.network.vpc_id
}

Environment Separation: Directory Layout vs Workspaces

For production where strong isolation is required (separate permissions, backends, and change windows), the baseline is to split the root directory per environment. State and backend config are then physically separated.

Terraform workspaces switch the state file while keeping the same code and backend. They are useful for lightweight sandboxes and short-lived review environments, but when per-environment differences are large, directory separation is safer.

  • For production, make separate root + separate backend + separate IAM the baseline
  • Workspaces are for short-lived, homogeneous environments. Keep config diffs minimal
  • Backends do not accept variables, so split them per environment into files and pass via init

Apply the differences in each environment's root (tfvars example)

# environments/dev/terraform.tfvars
region    = "ap-northeast-1"
vpc_cidr  = "10.10.0.0/16"

# environments/prod/terraform.tfvars
region    = "ap-northeast-1"
vpc_cidr  = "10.20.0.0/16"

# 実行例
# terraform -chdir=environments/dev plan -var-file=terraform.tfvars

Get a Feel for Module Versioning and Reuse

Pin registry-distributed modules semantically with the version argument. For Git-referenced modules, specify a ref tag or commit hash in source. Modules referenced by local relative path cannot use version.

On update, use terraform init -upgrade, surface the changes with plan, and put them through review.

  • Pin public/private registry modules with version
  • For Git sources, pin with ref=tag/commit. Avoid pinning to a branch
  • Test local modules together in CI, and promote them to a registry once they are ready for externalization

Registry and Git source examples

# Registry から取得
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.5"
  name    = "nlab"
  cidr    = "10.30.0.0/16"
}

# Git から取得(タグ固定)
module "network" {
  source = "git::https://github.com/example/network-module.git//modules/vpc?ref=v1.4.2"
  cidr   = "10.40.0.0/16"
}

# 更新時
# terraform init -upgrade

Validation Steps and How to Integrate into CI/CD

Automate lint → validate → plan in that order. Even just posting the plan diff as a PR comment raises quality. For apply, the safe path is to require a manual approval per environment.

For modules in isolation, run terraform validate plus a plan against a minimal mock. For roots, point at the backend and focus on visualizing the diff.

  • Use fmt/validate/tflint as pre-checks
  • Lock the artifact with plan -out and run apply in a separate job
  • Require protected branches and explicit approval for destroy

Minimal CI step example (GitHub Actions)

name: terraform
on: [pull_request]
jobs:
  plan:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: environments/dev
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.5
      - run: terraform fmt -check
      - run: terraform init -backend-config=backend.hcl
      - run: terraform validate
      - run: terraform plan -var-file=terraform.tfvars -out=tfplan

Check Your Understanding

Associate

問題 1

You want to safely apply different settings per environment in Terraform. Which split of responsibilities between root and child is most appropriate?

  1. Place provider configuration in the root and pass it to children via the providers argument. For per-environment differences, split roots (directories) and separate tfvars and backend.
  2. Put provider configuration inside the child module, and pass only variables from the root. Switch environments using workspaces alone.
  3. Write the same provider configuration in both the root and the child. Absorb per-environment differences via variable default values.
  4. Consolidate everything into a single root and do not use children. Switch per-environment differences using count and for_each.

正解: A

The officially recommended pattern is to concentrate provider configuration in the root and pass it to children via providers. For environments that need strong isolation, the safe approach is to split the root and manage tfvars and backend at the per-environment level.

Frequently Asked Questions

Should I always split the root module per environment?

For production, splitting is the default. Homogeneous environments like dev/stg can share a root via workspaces, but if you need to separate permissions and backends, give each environment its own root directory.

Can a child module declare its own provider block?

Technically yes, but it tends to cause unintended conflicts and makes swapping providers difficult. The safe pattern is to define providers in the root and pass them with the providers argument. Even for alias (aliased) providers, define them in the root and pass them as a map.

How should I manage module updates?

For registry modules, pin with the version argument and review changes with terraform init -upgrade then plan on update. For Git sources, pin ref to a tag or commit and avoid branch references.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Terraform

HCL Syntax: Terraform's Configuration Language (2026)

HCL2 fundamentals for Terraform — blocks, attributes, expres...

Terraform

Terraform Authoring & Operations Pro: Complete Guide (2026)

Tactics for the Terraform Pro exam — module authoring, works...

Terraform

Terraform Providers: Plugin Management Fundamentals (2026)

Provider mechanics — required_providers, versions, mirrors, ...

Terraform

Terraform Resource Blocks: Declarative Infra Units (2026)

Resource block fundamentals — addresses, references, common ...

Terraform

Terraform Data Sources: Read-Only External Data (2026)

Data source basics — declaration, refresh behavior, dependen...

Browse all Terraform articles (102)
© 2026 NicheeLab All rights reserved.