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.
Introduction
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
Trade-off Analysis
Versioning Strategy Comparison
| Strategy | Pros | Cons |
|---|---|---|
| Semantic versioning (Registry) | Clear breaking change signals, industry standard | Requires discipline to follow semver strictly |
| Git branch pinning | Simple, no registry needed | Branches can diverge, harder to track versions |
| Directory versioning (monorepo) | Full history, easy rollback | Repository bloat, consumers must update paths |
| Git tag semver | Registry-compatible, automated releases | Tag must match release, easy to forget |
Module Granularity Decisions
Coarse-grained modules (big, opinionated) vs Fine-grained modules (small, composable):
Coarse-grained modules like module "aws-ecs" that provisions an entire ECS stack are easy to consume but inflexible. If you need different networking or security configurations, you either fork the module or work around its assumptions.
Fine-grained modules like module "ecs-task-role", module "ecs-security-group" are flexible but require more composition from consumers. You write more Terraform to assemble the infrastructure, but you have full control.
The sweet spot is domain-aligned modules: one module per logical boundary (VPC, Compute, Data). This gives you flexibility within a domain while keeping composition manageable.
Module Source Decisions
| Source | Best for | Limitations |
|---|---|---|
| Terraform Registry | Public infrastructure, fast iteration | No private code, public visibility |
| Private Git repo | Proprietary patterns, internal standards | Requires git access, no semantic versioning UI |
| Monorepo directories | Tight coupling with application code | Version coupling, larger repos |
| S3/GCS remote | Air-gapped environments, compliance | No version history, artifact management overhead |
Module Wrapper Decisions
When you need to customize a community module, the choices are:
Wrapper module - calls the original with your defaults overlaid. Clean interface, tracks upstream separately.
Fork - copy the module and modify directly. Full control but tracking upstream is manual and error-prone.
Dynamic blocks / overrides - some modules support extension points that let you add behavior without forking.
For security-critical customizations, wrappers are almost always better than forks because you can review and update the upstream independently.
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"
Interview Questions
Expected answer points:
- Single responsibility: one module, one logical unit of infrastructure
- Composability: modules work well together, lean outputs only expose what consumers need
- Least surprise: defaults are sensible, required inputs documented, validation fails with clear errors
Expected answer points:
- Every output is a commitment—if you change it, every consumer using that output might break
- Extra outputs create coupling between module and consumers
- Only expose values that consumers genuinely need
- Unused outputs increase cognitive load without benefit
Expected answer points:
- Major version (5.0.0): breaking changes require consumer updates
- Minor version (5.1.0): new features, backward compatible
- Patch version (5.1.1): bug fixes, backward compatible
- Version constraints like ~> 5.0 allow 5.0.x and 5.1.x but not 5.2.0
Expected answer points:
- Inheritance: module A extends module B, tight coupling, changes to B affect A
- Composition: module A uses outputs from module B as inputs, loose coupling, independently replaceable
- Composition preferred for large infrastructure graphs—avoids circular dependencies
- Inheritance works for simple cases but breaks down with deep chains
Expected answer points:
- Wrapper modules call original with defaults overlaid, clean interface, tracks upstream separately
- Forks give full control but tracking upstream is manual and error-prone
- Security-critical customizations need independent review and update capability
- Wrappers enable upstream patches without losing local customizations
Expected answer points:
- Foundation modules create base resources (VPC, security groups) that application modules consume
- Application modules (EKS, databases) depend on foundation modules
- Application modules stay small because networking and auth are abstracted away
- Each module independently testable and replaceable
Expected answer points:
- Pin module versions in consumer configurations—never use latest
- Increment major version for breaking changes
- Test upgrades in staging environment before production rollout
- Update consumers one by one, verify plan shows no unexpected changes
Expected answer points:
- Required variables are obvious (no required variables without descriptions)
- Optional variables have sensible defaults that work for most use cases
- Object types used for related configurations instead of many primitives
- Validation on inputs that are likely to be wrong with clear error messages
Expected answer points:
- Circular dependencies prevent Terraform from resolving the graph—configuration becomes undeployable
- Audit dependency graph before releasing modules
- Use explicit data dependencies instead of implicit ordering
- Dependency flow should always be one direction: foundation → application
Expected answer points:
- Module version adoption rate—are teams on old versions with security gaps?
- Breaking change frequency per module (high frequency = be careful before changing)
- Consumer count per module (many consumers = high risk of breakage)
- Module state file size as proxy for complexity, time since last update
Expected answer points:
- Use Terratest or similar framework to write unit tests that provision real infrastructure in a test account
- Test pattern: apply module, verify resources created correctly, destroy and verify cleanup
- Use `terraform plan` to verify configuration without actual apply
- Test variable combinations to cover default, custom, and edge case inputs
- Keep test state isolated: unique resource names with random suffixes, separate test workspace
Expected answer points:
- Root module: the directory where you run `terraform apply` — the top-level configuration
- Child module: a module called from within the root module using `module` block
- Root module has no source path; child modules reference external directories or registries
- Variables and outputs in the root module are not automatically passed to child modules
- State is always managed at the root module level—child modules do not have their own state
Expected answer points:
- Mark sensitive output values with `sensitive = true` to prevent them from appearing in logs
- Terraform still stores sensitive values in state, so state file access must be restricted
- Use `optional()` for object type variables to allow consumers to pass partial configurations
- Never default sensitive values like passwords—require them as mandatory variables or use secret manager integration
- For database credentials, prefer random_password resource over hardcoding
Expected answer points:
- Use provider-agnostic abstractions: define resources generically, configure provider specifics at composition layer
- Azure uses azurerm provider, AWS uses aws provider, GCP uses google provider — wrap in module
- Create cloud-specific child modules (vpc-aws, vpc-azure) and a common wrapper that routes based on var.provider
- Keep module interface consistent across cloud providers — same variable names, same outputs for same logical resources
- This level of abstraction adds complexity — only pursue when multi-cloud is an actual requirement
Expected answer points:
- The `terraform` block in `versions.tf` declares required provider versions and Terraform version
- Ensures module consumers use compatible versions — prevents breaking changes from newer Terraform
- Provider version constraints prevent unexpected provider behavior changes
- Without versions.tf, module behavior depends on whatever version the consumer happens to use
Expected answer points:
- Mark deprecated modules with a deprecation notice in README and add a warning to module description
- Create a migration guide: document what changed, why, and how to update consumer configurations
- Maintain old module version for a grace period (e.g., 6 months) with security fixes only, no new features
- Use Terraform's version constraints to guide consumers to newer versions: deprecate old module source
- Communicate migration timelines clearly and provide support channels for consumers struggling with migration
Expected answer points:
- Registries (Terraform Registry, private): versioning, discoverability, reuse across organizations
- Registries: consumers pin to specific version, can update to newer versions when ready
- Local path modules: simpler for monorepo workflows, changes to module are immediately visible to consumers
- Local path: no versioning, harder to rollback if a breaking change is introduced
- For large organizations: private registry provides governance and audit trails for module usage
Expected answer points:
- README.md at module root: what the module does, required inputs, outputs, usage examples
- Document default values for optional variables and what happens if you do not override them
- Include a basic example and a full example covering all options
- Document known limitations, what the module does NOT do, and edge cases consumers might encounter
- Auto-generate documentation from module code if possible to keep docs in sync with code
Expected answer points:
- `count`: creates multiple instances of a module based on a number; instances are identified by index (module.aws_vpc[0])
- `for_each`: creates instances based on a map or set; instances identified by string key (module.aws_vpc["prod"])
- Use `count` when the number of instances is based on a numeric value or when instance identity is just a number
- Use `for_each` when instances have meaningful string identifiers (environment names, region names)
- for_each is preferred in most cases because the string keys make resource identity clearer in plans
Expected answer points:
- Increment major version (4.0.0 → 5.0.0) signals breaking change to consumers
- Provide migration path: keep old module version available alongside new version
- Use feature flags or conditional logic within the module to support both old and new patterns temporarily
- Communicate breaking changes clearly with migration documentation and timeline
- Pin module versions in consumer configs to prevent unexpected upgrades during terraform plan
Further Reading
- Terraform Module Registry - Public module registry for discovery and versioning
- Writing Custom Modules - Official module development guide
- Module Structure Best Practices - Testing patterns for modules
- terraform-aws-modules - Well-structured open-source module examples
- Semantic Versioning for Modules - Version constraint patterns
- Module Composition - Composing modules from other modules
Conclusion
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 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.