Terraform

Terraform Module Composition Patterns: Safely Designing and Operating Multi-Module Stacks

2026-04-19
NicheeLab Editorial Team

ハブ記事: Terraform Modules: Complete Guide

Hub article covering the full picture of Terraform modules: design, distribution, and operations

Modules are Terraform's unit of reuse, but their real value shows up when you compose them. Safely combining multiple modules — network, security, application platform — hinges on a handful of non-negotiables: expressing dependencies, propagating providers, designing inputs and outputs, and pinning versions.

This article walks through composition patterns that hold up under change, grounded in the behavior described in the official documentation. It also covers the points exams tend to focus on: depends_on between modules, the stability of for_each keys, propagating provider aliases, and how to write version constraints.

The Basic Design: Composing in the Root Module

Composition of multiple modules normally happens in the root module. Each child module has a clear boundary (input variables and output outputs), and the root wires them together. Define input types strictly, and keep outputs to the minimal shape downstream modules need.

Use a registry, VCS, or local path for module source, and pin version explicitly for stable operations. Initialize providers at the root, and map aliases through to children when needed.

  • Spell out input types, defaults, and validation conditions to harden the boundary
  • Limit outputs to the minimum set downstream needs (IDs, ARNs) and keep the structure stable
  • Pin source and version on the module block and bump them release by release
  • Define providers at the root and propagate them explicitly to children via the providers argument
PatternOverviewStrengthsCaveats
Root composition (Flat)Wire multiple child modules directly from the rootEasy to read and debugThe root tends to bloat
Wrapper module (Facade)Bundle a series of constructions into a single reusable moduleHigh reusability and standardizationStrict version management is essential when swapping internals
Per-environment stack splitRepeat the same composition in per-env directoriesClear separation of state and responsibilityAbsorb duplicated definitions with templating or a shared wrapper

A typical module composition (wired at the root)

root modulevariables.tfvarsnetworkoutputssecurityoutputsapp platformoutputs destinationWire variables at the root module → network / security → app platform

Root composition example (pinning source and wiring outputs)

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

provider "aws" {
  region = var.region
}

module "network" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
  name    = var.name
  cidr    = var.vpc_cidr
}

module "security" {
  source  = "git::https://example.com/org/security-group.git?ref=v1.4.2"
  vpc_id  = module.network.vpc_id
}

module "app" {
  source          = "./modules/app"
  subnet_ids      = module.network.private_subnets
  security_groups = [module.security.sg_id]
}

output "app_endpoint" {
  value = module.app.endpoint
}

Stabilizing the Wiring Between Outputs and Inputs

Modules connect to each other by passing outputs into inputs. As long as the references are there, Terraform resolves implicit dependencies and orders the graph for you. Use locals for intermediate shaping so the data matches the downstream module's input spec.

Marking an output sensitive suppresses display but does not change the fact that it is stored in plaintext in state. For secrets, consider provider-side secret management or an external secret store.

  • Prioritize stability when naming outputs; reserve breaking changes for major versions
  • Shape data with locals to keep the child module's input schema simple
  • sensitive controls visibility; state encryption is the backend's job

Using locals to wire outputs through

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
  name   = var.name
  cidr   = var.vpc_cidr
}

locals {
  app_subnet_ids = slice(module.vpc.private_subnets, 0, 2)
}

module "db" {
  source     = "./modules/rds"
  subnet_ids = local.app_subnet_ids
}

output "db_endpoint" {
  value     = module.db.endpoint
  sensitive = true
}

Controlling Dependencies and Evaluation Order

Terraform builds an implicit dependency graph from references (e.g., to resource or module outputs). Only when a procedural ordering is required without any reference should you add an explicit dependency via the depends_on meta-argument on the module block.

Data sources are read-only and evaluated at plan time. Adding unnecessary depends_on to a data source for side effects or ordering just adds complexity. First check whether implicit dependency through a reference is enough.

  • The module block supports the depends_on meta-argument (Terraform 0.13+)
  • If a reference exists, depends_on is unnecessary; duplicated dependencies inflate plan time
  • Consider explicit dependencies only for side-effect-only modules (e.g., audit configuration)

Explicit dependency between modules (when no implicit reference exists)

module "iam" {
  source = "./modules/iam-baseline"
}

module "eks" {
  source     = "terraform-aws-modules/eks/aws"
  version    = "~> 20.0"
  cluster_name = var.name
  # This module does not directly reference iam outputs, but iam must be applied first
  depends_on = [module.iam]
}

Replicating Modules: Choosing Between count and for_each

When instantiating the same module multiple times, you can use either count or for_each. From the perspective of stable-key addressing and diff control, real-world practice usually favors for_each.

Use identifiers that will stay stable into the future (logical names or IDs) as for_each keys to avoid destructive replacements when items are reordered or removed.

  • count is index-dependent and shifts easily on insertion
  • for_each minimizes diffs as long as the keys are stable
  • Aggregating outputs into a map via a comprehension makes them easier for the caller to handle

Replicate a subnet module with for_each and aggregate the outputs

variable "subnets" {
  type = map(object({
    cidr = string
    az   = string
  }))
}

module "subnet" {
  source = "./modules/subnet"
  for_each = var.subnets

  name = each.key
  cidr = each.value.cidr
  az   = each.value.az
}

# Bundle outputs with their keys so the caller can work with them easily
output "subnet_ids" {
  value = { for k, m in module.subnet : k => m.id }
}

Propagating Providers and Pinning Versions

Child modules inherit providers from the caller. When passing aliased providers to children, map them via the providers argument on the module block. Assign the caller's definitions to the provider names (including aliases) the child expects.

Declare provider version ranges with required_providers and pin module versions on the source. Use range operators like ~> according to your release compatibility policy.

  • The providers argument assigns the parent's configuration to the provider names the child expects
  • Always pin module version; constrain provider versions with ranges in required_providers
  • The three-segment registry source (org/name/provider) is friendlier to caching and validation

Propagating provider aliases and constraining versions

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0, < 6.0"
    }
  }
}

provider "aws" {
  alias  = "use1"
  region = "us-east-1"
}

provider "aws" {
  alias  = "usw2"
  region = "us-west-2"
}

# Assume the child module expects the default provider name 'aws'
module "logs" {
  source = "./modules/logs"
  providers = {
    aws = aws.usw2
  }
}

# Pin registry modules with version
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.1"
  name    = var.name
  cidr    = var.vpc_cidr
}

Environment Separation and State Layout

Separating state by directory or backend for production and staging environments is the practical default. Workspaces are useful for lightweight variants, but they are not a substitute for environment boundaries or permission separation.

When applying the same composition across environments, building a shared wrapper module and only varying the input values and backend configuration per env makes management easy. During refactors, use moved blocks to make resource address moves explicit and avoid breaking state.

  • Separate state files per environment and run plan/apply per environment
  • Apply backend authentication and encryption settings per environment
  • At scale, directory separation plus a shared wrapper is clearer

Minimal example of splitting by environment directory

prod/
  main.tf
  variables.tf
  backend.hcl   # remote backend config for prod
  terraform.tfvars
staging/
  main.tf
  variables.tf
  backend.hcl   # for staging
  terraform.tfvars

# Example: supply backend.hcl at init time with -backend-config=backend.hcl
# Example of a moved block (when moving a resource into a wrapper)
moved {
  from = aws_iam_role.app
  to   = module.security.aws_iam_role.app
}

Check Your Understanding

Pro

問題 1

You are composing three modules — network, iam, and eks — in the root module. eks does not reference iam's outputs directly, but iam must be applied first in creation order. Which approach is the most appropriate Terraform best practice?

  1. Add depends_on = [module.iam] to the module block for module.eks
  2. Pass a dummy value via a variable to create an implicit dependency through value reference
  3. Set lifecycle create_before_destroy on every resource inside eks
  4. Run terraform refresh before apply

正解: A

When there is no reference but a procedural order is required, add an explicit dependency with depends_on on the module block. Creating a pseudo-dependency through a dummy reference is an anti-pattern; lifecycle controls destroy ordering, not dependency resolution; and refresh only updates state and is not appropriate for ordering.

Frequently Asked Questions

Should I use count or for_each to replicate a module?

for_each is recommended because stable identifiers minimize diffs. count is fragile against index shifts and can trigger unintended replacements when items are inserted or removed. Exams also frequently test the stability of for_each keys.

How do I make a child module use a provider for a different region?

Define an aliased provider in the parent and map it to the provider name the child expects via the providers argument on the module block. Example: providers = { aws = aws.usw2 }. The child then uses the provider under that name.

How should I manage versions for registry modules versus providers?

Pin modules via the version argument on the module block, and constrain providers with version ranges in required_providers. Declaring both explicitly produces highly reproducible plans.

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.