Skip to content
Infrastructure as Code iac modules 5 min read

Composing Modules

Real infrastructure is rarely one module — it’s a network, some compute, a database, and an application layer that all depend on each other. Composition is how you wire those pieces together: a root configuration calls several focused modules and threads each module’s outputs into the next module’s inputs. Done well, composition gives you small, testable building blocks and a single place that describes how they fit. This page covers passing outputs as inputs, why flat composition beats deep nesting, how dependency direction works, and a full worked example.

The root module is the wiring layer

Every Terraform configuration has a root module — the directory you run terraform apply in. In a composed system the root module owns almost no resources directly. Instead it instantiates child modules and connects them. Think of it as a wiring diagram, not a workshop: its job is to declare which components exist and how data flows between them.

# main.tf — the root module composes three child modules

module "network" {
  source   = "./modules/network"
  vpc_cidr = "10.0.0.0/16"
  azs      = ["us-east-1a", "us-east-1b"]
}

module "compute" {
  source     = "./modules/compute"
  subnet_ids = module.network.private_subnet_ids
  vpc_id     = module.network.vpc_id
  instance_count = 2
}

module "app" {
  source            = "./modules/app"
  target_group_arn  = module.compute.target_group_arn
  database_endpoint  = module.database.endpoint
}

Passing outputs as inputs

The mechanism that ties modules together is the module.<name>.<output> reference. When module.compute reads module.network.vpc_id, two things happen: the value flows from one module to the other, and Terraform records an implicit dependency. You almost never need depends_on between modules — referencing an output is enough to make Terraform build the producer before the consumer.

For this to work, the producing module must expose the value as an output, and the consuming module must accept it as a variable.

# modules/network/outputs.tf
output "vpc_id" {
  description = "ID of the created VPC."
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "IDs of the private subnets, one per AZ."
  value       = aws_subnet.private[*].id
}
# modules/compute/variables.tf
variable "vpc_id" {
  description = "VPC the instances launch into."
  type        = string
}

variable "subnet_ids" {
  description = "Subnets to spread instances across."
  type        = list(string)
}

Tip: A module can only output what it declares. If you find yourself wanting to reach into a child module to grab an attribute it doesn’t expose, add an output to that module rather than restructuring the parent. The output set is the contract between layers.

Prefer flat composition over deep nesting

It’s tempting to make the network module call the compute module, which calls the app module — a deep tree. Resist it. Deeply nested modules are hard to reason about: data has to be passed down through every intermediate layer as pass-through variables, and a change three levels deep ripples up through every wrapper. You also lose the ability to apply or target a single layer.

A flat composition keeps all child modules as direct children of the root. The root sees every module and every connection in one file, dependencies are explicit, and each module stays single-purpose.

ConcernFlat compositionDeep nesting
Visibility of wiringAll in the root moduleHidden inside wrappers
Pass-through variablesNone neededRequired at every level
Reusing one layer aloneEasy — call it directlyHard — coupled to parent
Blast radius of a changeLocalizedPropagates up the tree
-target a single layerStraightforwardAwkward

Reserve nesting for genuine encapsulation — for example, an ecs-service module that internally uses a small log-group module the caller should never see. Use nesting to hide detail, not to express top-level architecture.

Dependency direction

Composition imposes a direction on your dependency graph, and that direction should be stable and acyclic. Foundational resources sit upstream; application resources sit downstream. Data flows one way — outputs go from lower layers to higher ones.

network  ->  compute  ->  app
   |                        ^
   +----------> database ---+

Terraform builds this graph automatically from your output references and refuses to run if it contains a cycle. If module A consumes an output of B and B consumes an output of A, you’ll get an explicit error:

Output:


│ Error: Cycle: module.compute.aws_lb_target_group.this,
│ module.network.aws_security_group_rule.from_app

Break cycles by moving the shared resource into a third module both can depend on, or by passing a plain value down instead of reading back up. Keep the arrows pointing one way.

Worked example: network → compute → app

The root module below provisions a VPC, an autoscaling compute tier inside it, and an application that registers behind the compute layer’s load balancer. Notice that no child module references another child module directly — all cross-module wiring lives in the root.

# main.tf
module "network" {
  source   = "./modules/network"
  vpc_cidr = "10.0.0.0/16"
  azs      = ["us-east-1a", "us-east-1b"]
  tags     = local.tags
}

module "compute" {
  source         = "./modules/compute"
  vpc_id         = module.network.vpc_id
  subnet_ids     = module.network.private_subnet_ids
  instance_type  = "t3.medium"
  desired_count  = 3
  tags           = local.tags
}

module "app" {
  source           = "./modules/app"
  target_group_arn = module.compute.target_group_arn
  alb_dns_name     = module.compute.alb_dns_name
  tags             = local.tags
}

output "app_url" {
  description = "Public URL of the deployed application."
  value       = "https://${module.app.fqdn}"
}

A terraform plan shows the three modules building in dependency order, with the network resources scheduled first because both other modules read its outputs:

Output:

Terraform will perform the following actions:

  # module.network.aws_vpc.this will be created
  # module.network.aws_subnet.private[0] will be created
  # module.network.aws_subnet.private[1] will be created
  # module.compute.aws_lb.this will be created
  # module.compute.aws_autoscaling_group.this will be created
  # module.app.aws_route53_record.this will be created

Plan: 9 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + app_url = (known after apply)

This composition works identically under OpenTofu — tofu plan produces the same graph, since module references and output wiring are part of the core HCL2 language, not provider behavior.

Best Practices

  • Keep the root module thin: let it wire child modules together and own as few resources as possible.
  • Connect modules by referencing outputs (module.x.y) so Terraform infers dependencies automatically — avoid manual depends_on between modules.
  • Favor flat composition; nest a module only to hide implementation detail, never to express top-level architecture.
  • Add an output to a child module when a sibling needs its value, rather than reaching across modules or restructuring the tree.
  • Keep dependency direction one-way (foundation → application) so the graph stays acyclic; break cycles with a shared lower-level module.
  • Pass primitive references (IDs, ARNs, endpoints) between modules instead of whole objects to keep interfaces stable.
  • Verify the build order with terraform plan after wiring changes — the same graph applies under OpenTofu.
Last updated June 14, 2026
Was this helpful?