IaC Module Design: Reusable and Composable Infrastructure
Design Terraform modules that are reusable, composable, and maintainable—versioning, documentation, and publish patterns for infrastructure building blocks.
IaC Module Design: Reusable, Composable Infrastructure
Terraform modules transform copy-pasted resource definitions into versioned, tested, documented building blocks. A well-designed module hides complexity behind a clean interface, encodes best practices in its implementation, and lets consumers provision infrastructure without knowing the details. Getting module design right is the difference between infrastructure that scales with your organization and configuration spaghetti that nobody wants to touch.
This post covers the principles, patterns, and practices that make modules work in production.
When to Use / When Not to Use
When to write modules
Modules earn their keep when you have infrastructure patterns that repeat across multiple projects or environments. If you find yourself copying and pasting the same VPC configuration, the same EKS cluster setup, or the same database provisioning logic, a module eliminates that duplication.
Use modules to encode organizational standards. A well-designed module enforces best practices by default—encryption enabled, logging configured, tags applied—without requiring every consumer to know the details. Teams consume the module interface without needing to understand the underlying resource configuration.
Modules also provide a unit of versioning. When you fix a security issue in a module, every consumer gets the fix by updating a version number. Without modules, the same security issue might exist in dozens of copy-pasted copies that all need individual fixes.
When to skip modules
If your infrastructure is small and unlikely to grow, modules add indirection without benefit. A single environment with a handful of resources does not need module abstraction.
If you do not have repeating patterns yet, do not build a module library preemptively. Build modules when the repetition actually exists, not when you imagine it might exist someday. Premature abstraction is as harmful as premature optimization.
Module Design Principles
Good modules follow a few core principles. They do one thing well, expose a minimal interface, and fail fast when misconfigured. A module that tries to be everything to everyone ends up being hard to use and harder to maintain.
Single responsibility means a module should manage one logical unit of infrastructure. A VPC module creates networking components. An EKS module creates the Kubernetes control plane. A database module creates the database instance and its dependencies. Resist the temptation to bundle everything together “for convenience.”
Composability means modules should work well with each other. Output values from one module become input values to another. Keep outputs lean—only expose what consumers actually need. Extra outputs create coupling that makes module changes breaking changes.
Least surprise means the module should behave as a consumer expects. Default values should be sensible for most use cases. Required inputs should be documented clearly. If a configuration is likely to be wrong, validate it and fail with a clear error message rather than silently doing the wrong thing.
# Good module interface - minimal, opinionated
variable "name" {
description = "Name prefix for all resources"
type = string
}
variable "environment" {
description = "Environment identifier"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "vpc_id" {
description = "VPC ID for subnet placement"
type = string
}
variable "subnet_ids" {
description = "List of subnet IDs for instance placement"
type = list(string)
}
output "security_group_id" {
description = "ID of the security group created"
value = aws_security_group.this.id
}
Input and Output Variable Design
Variables are the public API of your module. Design them with the same care you would give a library API. Required variables should be obvious. Optional variables should have sensible defaults.
Use object types for related configurations rather than passing many primitive variables.
# Instead of many primitive variables
variable "instance_type" {}
variable "ami_id" {}
variable "root_volume_size" {}
variable "root_volume_type" {}
variable "root_volume_encrypted" {}
# Use an object variable for related config
variable "instance_config" {
description = "Configuration for EC2 instances"
type = object({
instance_type = string
ami_id = string
root_volume_size = optional(number, 20)
root_volume_type = optional(string, "gp3")
root_volume_encrypted = optional(bool, true)
})
default = {}
}
Output design matters equally. Only expose values that consumers genuinely need. Every output is a commitment—if you change what a resource outputs, every consumer that uses that output might break.
# Include meaningful descriptions
output "instance_ids" {
description = "IDs of the created EC2 instances"
value = aws_instance.this[*].id
}
output "instance_arns" {
description = "ARNs of the created EC2 instances"
value = aws_instance.this[*].arn
}
# Use sensitive for values that should not be logged
output "database_password" {
description = "Database password (sensitive)"
value = aws_db_instance.this.password
sensitive = true
}
Module Versioning Strategies
Modules need versioning to evolve without breaking existing consumers. Terraform supports semantic versioning for modules in the Terraform Registry, and you can use git branches or tags for private modules.
The pattern is straightforward: release a version, consumers pin to that version, and breaking changes require a new major version.
# Pin to a specific version
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
# ... configuration
}
# Or use version constraints for flexibility
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
# Allows 5.0.x and 5.1.x but not 5.2.0
}
For private modules in a monorepo, use a directory structure that makes version history clear.
modules/
networking/
vpc/
v1.0.0/
v1.1.0/
v2.0.0/
Consumers reference specific version directories. When you release a new version, you copy the module to a new directory and update consumers gradually. This approach trades storage for clarity and rollback capability.
Git-based referencing works similarly.
module "vpc" {
source = "git::https://github.com/org/terraform-modules.git//vpc?ref=v2.0.0"
}
Publishing to Terraform Registry
The Terraform Registry hosts thousands of public modules. Publishing your module there makes it discoverable and easy to use. The process is mostly automatic: put your module in a public git repository with the right structure, and the Registry indexes it.
Your module needs a few files at the root: README.md with documentation, LICENSE with a standard open-source license, and the module files themselves with main.tf, variables.tf, outputs.tf, and versions.tf.
# versions.tf - declare provider and version requirements
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
The Registry displays documentation extracted from your README, so keep it thorough. Document what the module does, what inputs it accepts, what outputs it produces, and any examples of usage.
Version numbers in the Registry come from git tags on your repository. Tag your releases with semver tags like v1.2.3, and the Registry handles versioning automatically.
Module Composition Patterns
Large infrastructure tends to layer modules. Foundation modules create base resources that application modules consume. This hierarchy reduces duplication while keeping modules focused.
# Root module composition
module "networking" {
source = "./modules/networking/vpc"
environment = var.environment
cidr_block = var.vpc_cidr
}
module "compute" {
source = "./modules/compute/eks"
cluster_name = "${var.prefix}-eks"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
node_group_size = var.node_group_size
}
module "databases" {
source = "./modules/data/postgres"
identifier = "${var.prefix}-postgres"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
instance_class = var.db_instance_class
}
Application modules stay small because infrastructure concerns like networking and authentication are abstracted into foundation modules. Each module is independently testable and replaceable.
Forks of community modules are common when you need customization beyond what the module exposes. Pin the original module source, then apply overrides with a wrapper module or patches. Document why you forked and track upstream changes for integration.
Anti-Patterns to Avoid
Modules that are too generic create confusion. If a module accepts every possible option for a resource, it becomes a thin wrapper with no opinion. You lose the benefits of abstraction because consumers still need to understand the underlying resource to configure it properly.
# Anti-pattern: passing everything through
variable "all_the_things" {
description = "All the resource arguments"
type = any
}
resource "aws_instance" "this" {
instance_type = var.all_the_things.instance_type
ami = var.all_the_things.ami
# ... 100 more arguments
}
# Better: opinionated module with sensible defaults
variable "instance_type" {
default = "t3.micro"
}
Hidden dependencies between modules cause surprises. If module B implicitly relies on resources created by module A without an explicit dependency, consumers who use B without A get confusing failures. Use explicit inputs and outputs to document and enforce dependencies.
Circular dependencies between modules are catastrophic. They prevent Terraform from resolving the graph and make the configuration undeployable. Audit your module dependency graph to ensure flow is one direction.
Finally, avoid embedding secrets in modules. Use dynamic references or secret manager integrations instead of hardcoding credentials in module code.
Module Composition Patterns Flow
flowchart TD
A[Foundation Modules] --> B[Networking VPC Module]
A --> C[Security Group Module]
B --> D[Application Modules]
C --> D
D --> E[EKS Module]
D --> F[Database Module]
E --> G[Production Environment]
F --> G
Production Failure Scenarios
Common Module Failures
| Failure | Impact | Mitigation |
|---|---|---|
| Breaking changes in shared module | Consumer stacks fail after update | Pin module versions, test upgrades in staging |
| Hidden interdependencies | Module A breaks Module B silently | Use explicit data dependencies, document relationships |
| Modules too large | Hard to understand, slow to plan | Split into focused modules by concern |
| Missing required variables | Consumer gets cryptic errors | Always validate required inputs with error_message |
| Circular dependencies | Terraform plan hangs | Audit dependency graph before releasing |
Module Upgrade Flow
flowchart TD
A[Update module in registry] --> B{Changes breaking?}
B -->|Yes| C[Increment major version]
B -->|No| D[Increment minor/patch version]
C --> E[Update consumers one by one]
D --> F[Update consumers in CI]
E --> G[Test each consumer stack]
F --> H[Verify plan shows no unexpected changes]
Observability Hooks
Track module health and usage to maintain quality across your organization.
What to monitor:
- Module version adoption rate (are teams on old versions?)
- Breaking change frequency per module
- Consumer count per module (too many consumers = be careful before changing)
- Module state file size as a proxy for complexity
- Time since last update (stale modules may have security gaps)
# List all module sources used across configurations
grep -r "module \"" . --include="*.tf" | awk '{print $2}' | sort | uniq
# Check module version constraints
grep -r "version" . --include="*.tf" | grep "module"
# Audit module source URLs for consistency
grep "source.*git::" . -r --include="*.tf"
Quick Recap
Key Takeaways
- Modules encode best practices and eliminate copy-paste duplication
- Single responsibility: one module, one logical unit of infrastructure
- Lean outputs—only expose what consumers actually need
- Version everything, document liberally, treat module changes as API changes
- Composition over inheritance for large infrastructure graphs
Module Health Checklist
# Validate module syntax
terraform validate
# Format module files
terraform fmt -recursive
# List all variables and outputs
grep "^variable\|^output" *.tf
# Check for sensitive outputs
grep "sensitive = true" outputs.tf
# Test module with example configuration
cd examples/full-setup && terraform init && terraform plan
# Verify documentation is up to date
grep -c "description" variables.tf outputs.tf
Conclusion
Well-designed modules are the foundation of scalable infrastructure. Keep them focused, version them carefully, and expose only what consumers need. As your infrastructure grows, module composition lets you assemble complex systems from simple building blocks without sacrificing maintainability.
For securing your infrastructure, see Cloud Security for IAM policies, encryption, and access controls. For monitoring module health and drift detection, see Observability Engineering.
Category
Tags
Related Posts
IaC State Management: Remote Backends and Team Collaboration
Manage Terraform/OpenTofu state securely with remote backends, state locking, and strategies for team collaboration without state conflicts.
Terraform: Declarative Infrastructure Provisioning
Learn Terraform from the ground up—state management, providers, modules, and production-ready patterns for managing cloud infrastructure as code.
AWS CDK: Cloud Development Kit for Infrastructure
Define AWS infrastructure using TypeScript, Python, or other programming languages with the AWS Cloud Development Kit, compiling to CloudFormation templates.