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
outputto 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.
| Concern | Flat composition | Deep nesting |
|---|---|---|
| Visibility of wiring | All in the root module | Hidden inside wrappers |
| Pass-through variables | None needed | Required at every level |
| Reusing one layer alone | Easy — call it directly | Hard — coupled to parent |
| Blast radius of a change | Localized | Propagates up the tree |
-target a single layer | Straightforward | Awkward |
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 manualdepends_onbetween modules. - Favor flat composition; nest a module only to hide implementation detail, never to express top-level architecture.
- Add an
outputto 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 planafter wiring changes — the same graph applies under OpenTofu.