Terraform Module Composition Explained

Published:
September 3, 2026
Topics:

Terraform configurations can become difficult to manage as infrastructure grows. Terraform modules help address this complexity by dividing resources into smaller, reusable components that can be combined into larger architectures, making infrastructure easier to organize and maintain.

This article explains how Terraform module composition works, the architectural patterns used to organize composed modules, and how to pass data and manage dependencies between them.

Terraform module composition explained.

What Is Terraform Module Composition?

Terraform module composition is the practice of building infrastructure by connecting multiple modules together. Each module manages a specific group of related resources, while a parent or composition layer passes inputs between them and coordinates the resulting architecture.

A Terraform module is a collection of resources managed together. Modules can expose outputs, accept input variables, and be called from other configurations. This creates defined interfaces between infrastructure components rather than requiring every part of the configuration to directly reference every resource.

For example, a multi-tier application could consist of:

  • A networking module that creates private networks and subnets.
  • A database module that deploys database infrastructure.
  • An application module that creates compute resources.
  • A load balancer module that exposes the application.
  • A root module that connects the components.

The root configuration composes these modules into a complete environment.

The following diagram illustrates a multi-tier application:

A diagram illustrating a multi-tier application.

This model aligns closely with the broader principles of Infrastructure as a Code. Infrastructure is defined programmatically, reused across environments, and deployed through automated workflows instead of manual configuration. IaC also helps reduce environment drift by allowing teams to reproduce infrastructure from consistent configurations.

How Do Modules Work in Infrastructure as Code?

In Terraform, modules package related infrastructure resources behind a defined interface. A module can accept configuration values through input variables, use those values to create resources, and return selected information through outputs. Other configurations can then call the module without interacting directly with every resource it contains. This separation allows teams to reuse the same implementation while supplying different values for different applications or environments.

The following diagram shows how data flows into, through, and out of a module:

A diagram illustrating the Terraform data flow.

For example, the following code shows a parent configuration calling a local network module and passing a CIDR range to it as an input:

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

  network_cidr = "10.0.0.0/16"
}

The child module creates its resources internally. It can then expose selected values through output blocks, allowing the parent configuration or another module to consume information produced by the module.

In other words, a module receives the configuration values it needs, manages the underlying infrastructure, and exposes only the resulting values that other parts of the Terraform configuration need to use. This creates a defined contract between modules and prevents consumers from depending unnecessarily on their internal implementation details.

Standalone Resources vs. Reusable Modules

Terraform configurations do not need to place every resource inside a module. Standalone resources are often simpler and more appropriate when infrastructure is small, unique, or unlikely to be reused. Modules become more valuable when the same infrastructure pattern needs to be deployed repeatedly, when teams want to enforce common configuration standards, or when a group of resources represents a distinct capability that can be maintained independently.

For example, a standalone resource might be defined directly in a configuration:

resource "example_server" "web" {
  hostname = "web-01"
}

This approach works well when the server configuration is specific to that deployment. However, repeatedly copying similar resource definitions across multiple projects creates maintenance problems. If ten applications use the same server pattern, changing that pattern requires updating multiple configurations.

A reusable module centralizes the implementation:

module "application_server" {
  source = "./modules/application-server"

  hostname = "web-01"
}

You can then call the same module repeatedly with different input values.

Note: The resource types in the examples above are illustrative placeholders. Replace them with the appropriate resource types from the Terraform provider you use, such as pnap.

Reusable modules are most valuable when teams need to standardize recurring infrastructure patterns. The goal is not to eliminate all standalone resources. Instead, identify patterns with stable behavior that are likely to be reused.

Architectural Design Patterns for Module Composition

The structure of a Terraform module ecosystem should reflect how infrastructure is actually designed, deployed, and maintained. No single hierarchy works for every organization, but effective compositions usually establish clear boundaries between low-level reusable components and higher-level configurations that combine those components into complete workflows.

The following sections provide useful ways to organize those relationships.

Building Blocks (Atomic) Pattern for Base Resources

The atomic pattern organizes infrastructure into relatively small modules, with each module responsible for one focused infrastructure capability. These modules act as reusable building blocks that can be combined differently depending on the requirements of an application or environment. The approach is particularly useful when the same underlying capabilities, such as networking or compute provisioning, are shared across multiple higher-level architectures.

Terraform atomic building blocks.

Each module manages one primary infrastructure concern. A server module might accept a hostname, instance profile, operating system, and network configuration, while a network module creates the network foundation and exposes the identifiers downstream modules need.

The main advantage of this pattern is flexibility. Teams can assemble the same building blocks into different architectures without duplicating their implementation. The tradeoff is that excessively small modules can increase the number of interfaces and dependencies a configuration must manage, so modules should still represent meaningful, independently reusable capabilities.

Composition Layer Pattern for Multi-Component Workflows

Atomic modules provide the individual components, but they do not necessarily describe how those components should work together for a specific workload. The composition layer pattern addresses this by introducing a higher-level configuration or module that connects lower-level components into a complete infrastructure workflow. This keeps architectural wiring separate from the implementation details of the underlying building blocks.

For example, a repository could separate reusable modules from a higher-level workload stack:

Terraform composition layer example.

The web-platform stack can call all four modules and pass the required values between them. In this example, the networking layer provides connectivity information, the database and application layers consume the appropriate network configuration, and the load balancer connects to the application tier.

This pattern allows low-level modules to remain reusable across different architectures while keeping workload-specific decisions in one place. It is especially useful when multiple teams need to deploy standardized infrastructure platforms without manually wiring every network, compute, database, and load-balancing component together.

Facade Pattern: Abstracting Complexity for Internal Consumers

As module ecosystems grow, directly exposing every low-level component to infrastructure consumers can make even common deployments difficult to configure. The facade pattern addresses this problem by providing a higher-level module with a simpler interface that internally composes several lower-level modules. Consumers interact with the facade instead of managing every implementation detail themselves.

For example, an internal platform may need to provision networking, servers, storage, and other supporting resources for an application environment. Instead of requiring every consumer to configure each component separately, a facade module could expose only the values needed for the standard deployment workflow:

module "application_environment" {
  source = "./modules/application-environment"

  environment = "production"
  application = "payments"
  size        = "large"
}

Internally, the facade can compose multiple modules and apply organization-specific defaults.

The benefit is simplicity for consumers. The risk is hiding too much. A facade should simplify common workflows without preventing users from handling legitimate requirements.

Passing Data and Managing State Across Composed Modules

Composed modules need controlled ways to exchange information and establish the order in which infrastructure is created. A networking module, for example, may create values that an application module needs before it can provision servers.

Terraform handles many of these relationships automatically when configurations reference module outputs, while state records the resources Terraform manages and the relationship between the configuration and real infrastructure.

Chaining Module Outputs as Inputs for Dependent Stacks

The most common way for composed modules to share information is to expose a value from one module as an output and pass that value to another module as an input. This creates an explicit interface between the two modules: the producing module decides which information is safe and useful to expose, while the consuming module receives only the values it needs to perform its own work.

For example, the following configuration creates a network module and passes its network_id output to an application module:

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

  cidr = "10.0.0.0/16"
}

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

  network_id = module.network.network_id
}

The networking module creates the network, while the application module only needs to know which network to use. The application module does not need to reference the networking module's internal resources directly.

This pattern creates a clear contract between modules and makes implementations easier to change. Outputs should therefore expose meaningful values, such as IDs, addresses, endpoints, or other information that downstream configurations genuinely need.

Managing Explicit vs. Implicit Dependencies Between Modules

Terraform must understand dependency relationships so it can create, modify, and destroy resources in the right order. In many cases, those dependencies are implicit because Terraform can infer them from expressions that reference another resource or module output. You can also declare explicit dependencies when an operational relationship exists but cannot be represented through a direct data reference.

For example, this input creates an implicit dependency because the application module uses an output from the networking module:

network_id = module.network.network_id

Terraform can determine that the networking module must produce this value before the application configuration can use it.

In some cases, no output needs to be passed between modules, but one module must still wait for another to complete. The following example uses depends_on to declare that relationship explicitly:

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

  depends_on = [module.network]
}

Explicit dependencies should be used carefully. When a real data relationship exists, directly referencing the required value usually provides a more precise representation of the dependency. Broad depends_on relationships can make plans more conservative and obscure the actual reason that one part of the configuration depends on another.

State Isolation Strategies Across Environments (Dev, Staging, Prod)

As infrastructure expands across multiple environments, teams also need to decide which resources should share a Terraform state and which should be managed independently. State boundaries affect how changes are planned and applied, who can access infrastructure metadata, and how failures or operational changes are isolated. Reusing the same modules does not require every environment to share the same state.

One common structure separates environment-specific root configurations while keeping reusable modules in a shared location:

environments/
├── dev/
├── staging/
└── production/

modules/
├── network/
├── database/
└── application/

Each environment can call the same modules with different inputs while maintaining its own state. For example, production can use different network ranges, server profiles, or capacity settings without sharing the same execution boundary as development.

Sharing data across separate states requires an explicit mechanism. Module outputs can be passed directly only when the modules are part of the same Terraform configuration. When infrastructure is split across independent root modules and state files, downstream configurations cannot directly reference another configuration's child module outputs.

Teams must instead expose selected root outputs and share the required information through mechanisms such as provider data sources, remote-state output access, or platform-specific output-sharing features.

Larger platforms may further isolate state by infrastructure domain, such as networking, shared services, and application stacks. The appropriate strategy depends on how independently those components need to be changed, secured, and recovered. State boundaries should ultimately reflect meaningful operational boundaries rather than simply mirroring the module directory structure.

For team-managed infrastructure, each state boundary should also use an appropriate remote backend with secure access controls. Where the backend supports it, state locking helps prevent concurrent operations from modifying the same state.

Real-World Example: Composing a Multi-Tier Architecture

A multi-tier application provides a practical example of how individual Terraform modules can be connected into a larger deployment. Consider an architecture consisting of a networking foundation, a database tier, application servers, and a load balancer. Each layer has its own responsibility, but the complete system depends on values produced by other layers.

Multi-tier architecture example.

The steps below show how a parent configuration can compose these components while keeping their implementations separated.

Step 1: Composing a Networking and Subnet Foundation Module

The first step is to establish the shared network foundation. This module creates the network resources and exposes the subnet information that the database and application tiers will need. By making the networking module responsible for this foundation, downstream modules do not need to create or understand the complete network topology themselves.

For example, the parent configuration can call the networking module with an environment-specific CIDR range:

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

  environment  = var.environment
  network_cidr = "10.20.0.0/16"
}

The networking module can then expose separate subnet identifiers for the application and database tiers:

output "application_subnet_id" {
  value = example_subnet.application.id
}

output "database_subnet_id" {
  value = example_subnet.database.id
}

These outputs become the networking contract for downstream modules. The parent configuration can use them to place different infrastructure tiers in the appropriate subnets without directly accessing the network module's internal resources.

Step 2: Wiring Database Clusters via Dynamic Module Outputs

Once the network foundation is available, the database module can consume the subnet output intended for the database tier. This demonstrates a core principle of composition: a module can use values dynamically produced by another module instead of requiring those values to be hardcoded or managed separately.

The following example passes the database subnet ID from the networking module into the database module:

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

  subnet_id = module.network.database_subnet_id
  name      = "${var.environment}-database"
}

The database module can then expose information that other layers need, such as a connection endpoint:

output "endpoint" {
  value = example_database_cluster.main.endpoint
}

The parent configuration can pass this output to the application layer. As a result, the database module remains responsible for its own resources while exposing a stable interface for the rest of the architecture.

Step 3: Integrating Application Servers and Load Balancers

The final stage connects the application tier to the resources created earlier and places it behind a load balancer. The application module needs networking information to determine where to deploy its servers and database information to connect to the data tier. The load balancer, in turn, needs information about the application servers it should route traffic to.

For example, the application module can consume outputs from both the network and database modules:

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

  subnet_id         = module.network.application_subnet_id
  database_endpoint = module.database.endpoint
}

The load balancer module can then consume the application module's server or target information:

module "load_balancer" {
  source = "./modules/load-balancer"

  targets = module.application.server_targets
}

The resulting composition creates a dependency flow in which the network provides the foundation, the database and application tiers consume the appropriate network information, and the load balancer connects to the resulting application infrastructure. The parent configuration controls these relationships without requiring each module to know how the other modules implement their resources.

Terraform Module Composition with Bare Metal Cloud

Module composition is particularly useful for API-driven infrastructure platforms because teams can define reusable provisioning patterns and deploy them consistently through automation.

phoenixNAP Bare Metal Cloud supports infrastructure automation through Terraform and other popular IaC tools. The platform provides API-driven dedicated infrastructure with pre-configured server instances, programmable networking, storage options, and multiple deployment locations.

It provides cloud-like provisioning and automation for dedicated servers, with Terraform integration that can help teams incorporate bare metal infrastructure into reusable IaC workflows and composed deployment stacks.

Composing Dynamic Server Provisioning with Custom Hardware Profiles

Compute provisioning is a natural use case for module composition because different workloads often require different hardware profiles while following the same general deployment process. A server module can centralize provisioning logic and expose a consistent interface, while a higher-level composition layer determines which hardware configuration to use for a specific environment or application.

For example, a server module could accept values such as:

hostname
location
instance_type
os
network_configuration
tags

The composition layer can then select appropriate values based on workload requirements. For instance, development and staging environments may use one instance profile while production workloads use a profile designed for greater compute capacity.

This separation lets the server module focus on how an instance is provisioned while the composition layer determines the appropriate hardware profile. Bare Metal Cloud's pre-configured instance types and programmable provisioning model make these infrastructure choices suitable candidates for reusable module inputs.

Orchestrating Bare Metal Network Topology and IP Block Modules

Networking can become difficult to manage when every server configuration independently handles IP allocation, network creation, and connectivity settings. Separating these concerns into dedicated modules makes it possible to define a shared topology and reuse it across multiple workloads. A composition layer can then connect network, IP allocation, and server provisioning modules according to the requirements of a particular architecture.

For example, the overall relationship could follow this sequence:

network-foundation
        ↓
    ip-block
        ↓
server-networking
        ↓
application-cluster

The network foundation module establishes the shared connectivity model, while the IP block module manages address allocation and the server networking module attaches the appropriate infrastructure to that topology. Downstream application modules can consume only the identifiers or addresses they require.

This structure provides clearer ownership boundaries and reduces duplication when multiple applications use similar network patterns.

Abstracting OS Images and Regional Availability Across Environments

Operating system and deployment location choices often vary between environments, but embedding those decisions directly in every server definition creates unnecessary duplication. A composition layer can centralize these policies and resolve the appropriate values before passing them to the underlying server modules. This keeps environment-specific decisions separate from resource-provisioning logic.

For example, a configuration could define environment profiles that map each environment to an approved operating system and location:

locals {
  environment_profiles = {
    dev = {
      location = "region-a"
      os       = "ubuntu"
    }

    production = {
      location = "region-b"
      os       = "rhel"
    }
  }
}

The server module receives the resolved location and operating system values without needing to know why they were selected. If an organization changes its regional availability policy or standard OS images, those decisions can be updated centrally rather than across every individual server definition.

Encapsulating Storage Arrays and Billing Profiles into Dedicated Stacks

Storage requirements and infrastructure consumption models can introduce another layer of environment- or workload-specific complexity. Encapsulating those concerns in dedicated modules or higher-level stacks lets teams manage them independently while still composing them with compute and networking resources when needed.

For example, a workload stack could combine several focused modules:

Compute Module
     +
Storage Module
     +
Network Module
     +
Environment Policy Module

The composition layer determines which storage configuration and infrastructure profile to apply to the workload, while the underlying modules remain responsible for provisioning their respective resources.

This approach reduces the platform-specific configuration application teams must manage directly. It also gives infrastructure teams a clear place to maintain reusable provisioning patterns as storage requirements or organizational policies change.

Common Problems in Complex Module Compositions

Module composition can reduce duplication and improve organization, but adding more modules does not automatically make infrastructure easier to manage. Poor boundaries, unstable interfaces, and excessive dependencies can simply move complexity from one large configuration into a deeply interconnected module hierarchy.

The sections below will help you understand the most common problems and identify when a composition needs simplification.

The Over-Abstraction Trap and Rigid Interfaces

Not every resource needs its own module. Abstraction becomes counterproductive when you create modules for infrastructure components that don't have a meaningful independent responsibility or realistic reuse value.

Creating a module for every small infrastructure object can produce deeply nested configurations with little practical reuse. Consumers must then understand numerous variables and outputs simply to deploy a straightforward architecture.

Over-abstraction also creates rigid interfaces. A module may expose only the options its original author anticipated, forcing consumers to modify the module whenever a new requirement appears.

A better approach is to abstract stable patterns rather than individual lines of Terraform code.

Ask yourself the following questions:

  • Is this capability reused?
  • Does it have a meaningful responsibility?
  • Can it expose a reasonably stable interface?
  • Does the module simplify consumption more than it complicates maintenance?

If the answer is consistently no, a standalone resource may be the better choice.

Tight Coupling and Cascade Failures Across Interdependent Stacks

Modules should depend on stable contracts, not internal implementation details.

For example, an application module should ideally depend on a database endpoint output rather than directly referencing resources inside the database module.

Tight coupling makes changes propagate through the hierarchy. Renaming an internal resource or changing a resource implementation can then require changes in unrelated modules.

Excessive explicit dependencies can also unnecessarily widen Terraform's dependency graph. Terraform recommends expression references over broad depends_on relationships when a data reference can accurately express the dependency.

Variable Explosion Syndrome in Deeply Nested Hierarchies

Deep module hierarchies can create a different kind of complexity when large numbers of variables pass through multiple layers. This often happens when intermediate modules act only as conduits, receiving variables they do not use and passing them to lower-level modules. The result is a fragile interface structure in which small changes require updates throughout the hierarchy.

For example:

Root
  ↓ passes 20 variables
Platform
  ↓ passes 18 variables
Application
  ↓ passes 15 variables
Server

This creates a fragile hierarchy where small interface changes affect multiple modules.

To reduce variable explosion, modules should focus on their own responsibilities and expose only the configuration options they need. Related values can sometimes be grouped into structured objects, while higher-level opinionated modules can provide simpler interfaces for common workflows.

The objective is not to minimize the number of variables at all costs, but to ensure that every interface represents a meaningful boundary instead of merely forwarding implementation details through several layers.

Terraform Module Composition Best Practices

A successful module ecosystem requires more than a logical directory structure. As the number of modules and consumers grows, teams need shared conventions for interfaces, releases, testing, and refactoring. Establishing these practices early helps prevent reusable infrastructure from becoming difficult to upgrade or understand.

Enforcing Strict, Unified Naming Conventions Across Teams

Consistent naming makes large module ecosystems easier to navigate because consumers can recognize the purpose of variables, outputs, and resources without learning a different vocabulary for every module. Naming conventions should therefore be agreed upon at the team or organization level and applied consistently across public module interfaces.

For example:

module: application_server
input:  instance_type
output: private_ip

Teams should standardize names for modules, input variables, outputs, resource labels, environment identifiers, and tags or metadata. A consumer should not need to guess whether network_id, network, and network_identifier represent the same concept in different modules.

Clear names also help distinguish public interfaces from implementation details. Outputs should describe the value they provide, allowing internal resources to change without forcing consumers to update their references.

Semantic Versioning and Managing Registry Dependencies

Once modules are reused by multiple configurations, changes to their interfaces need to be managed as carefully as changes to application APIs. Versioning allows consumers to control when they adopt updates and helps module maintainers communicate whether a release contains a breaking change, a backward-compatible feature, or a fix.

A typical Semantic Versioning model is:

MAJOR.MINOR.PATCH

2.0.0 → Breaking interface change
2.1.0 → Backward-compatible feature
2.1.1 → Backward-compatible fix

A production configuration can explicitly constrain the version it accepts:

module "network" {
  source  = "example/network/platform"
  version = "~> 2.1.0"
}

Version constraints help prevent configurations from automatically consuming incompatible releases. Teams should also document supported Terraform and provider versions, required and optional inputs, outputs, breaking changes, and upgrade instructions so consumers can evaluate new releases before adopting them.

Note: Terraform's dependency lock file records provider selections but does not lock remote module versions. When using a version range for a registry module, Terraform can select a newer version that still satisfies the constraint during initialization. Use an exact module version when deployments require a fixed module release.

Implementing Automated Testing for Composed Modules (Terratest)

Testing becomes increasingly important as modules are combined because a module can function correctly in isolation but fail when its outputs and inputs connect to other components. An effective testing strategy should therefore validate both individual modules and the complete workflows created by composition.

Terratest is a Go library for automating tests for infrastructure code, including Terraform configurations. It can provision test infrastructure, retrieve Terraform outputs, validate expected behavior, and clean up resources after testing.

Note: Terraform also provides native testing capabilities through terraform test, which lets module authors run test files and validate Terraform plans, state, and assertions. Native validation features, such as input validation, preconditions, postconditions, and check blocks, can also verify assumptions directly in the configuration. For broader integration and end-to-end testing, external frameworks such as Terratest can provision infrastructure and validate behavior outside Terraform itself.

A typical workflow can include the following stages:

terraform init
       ↓
terraform apply
       ↓
Validate outputs and infrastructure behavior
       ↓
terraform destroy

Testing is particularly important for composed modules because individual modules may work correctly in isolation while their interfaces fail when connected.

Tests should verify both:

  • Technical outputs, such as IDs and addresses.
  • Behavioral outcomes, such as whether an application can reach a required service after the full stack is deployed.

Migration Strategies: Refactoring Monolithic Code into Composed Modules

Refactoring a large Terraform configuration into modules is usually safer when performed incrementally. Moving resources, changing architecture, and modifying live infrastructure at the same time can make plans difficult to review and increase the risk of unintended changes. A staged migration lets teams improve the structure while preserving existing infrastructure behavior wherever possible.

A typical process is to identify logical resource groups, define appropriate module boundaries, move one group into a module, expose the required outputs, and update the calling configuration to consume the new interface. State should be handled carefully so Terraform continues to associate the refactored configuration with the existing infrastructure.

Note: Terraform v1.1 and later can use moved blocks to preserve the relationship between the existing state object and its new configuration address. This allows teams to refactor resource and module structure without treating every address change as a request to destroy and recreate infrastructure.

For example, a monolithic configuration containing networking, database, server, and load balancer resources can gradually be reorganized into separate modules:

modules/
├── network/
├── database/
├── application/
└── load-balancer/

main.tf

The first objective should be structural improvement rather than a simultaneous redesign of the infrastructure itself. Once the module boundaries work correctly and Terraform plans confirm that existing resources are still mapped appropriately, teams can make further architectural changes with a clearer, safer baseline.

Conclusion

This article explained how Terraform module composition works, including architectural patterns, data sharing and dependencies, state isolation, common challenges, and best practices for building reusable infrastructure. A well-designed composition strategy reduces duplication, simplifies maintenance, and deploys consistent infrastructure across environments.

To learn more about applying these principles to infrastructure automation, read Infrastructure as a Code.

Was this article helpful?
YesNo