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.

published: reading time: 10 min read

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

FailureImpactMitigation
Breaking changes in shared moduleConsumer stacks fail after updatePin module versions, test upgrades in staging
Hidden interdependenciesModule A breaks Module B silentlyUse explicit data dependencies, document relationships
Modules too largeHard to understand, slow to planSplit into focused modules by concern
Missing required variablesConsumer gets cryptic errorsAlways validate required inputs with error_message
Circular dependenciesTerraform plan hangsAudit 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

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 #iac #state-management

Terraform: Declarative Infrastructure Provisioning

Learn Terraform from the ground up—state management, providers, modules, and production-ready patterns for managing cloud infrastructure as code.

#terraform #iac #devops

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.

#aws #cdk #iac