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: 20 min read author: GeekWorkBench

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

StrategyProsCons
Semantic versioning (Registry)Clear breaking change signals, industry standardRequires discipline to follow semver strictly
Git branch pinningSimple, no registry neededBranches can diverge, harder to track versions
Directory versioning (monorepo)Full history, easy rollbackRepository bloat, consumers must update paths
Git tag semverRegistry-compatible, automated releasesTag 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

SourceBest forLimitations
Terraform RegistryPublic infrastructure, fast iterationNo private code, public visibility
Private Git repoProprietary patterns, internal standardsRequires git access, no semantic versioning UI
Monorepo directoriesTight coupling with application codeVersion coupling, larger repos
S3/GCS remoteAir-gapped environments, complianceNo 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

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"

Interview Questions

1. What are the three core principles of good module design?

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
2. Why should module outputs be lean?

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
3. How does semantic versioning work for Terraform modules?

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
4. What is the difference between composition and inheritance in module design?

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
5. Why are wrapper modules preferred over forks of community modules?

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
6. What is the module composition hierarchy pattern?

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
7. How do you handle breaking changes in shared modules?

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
8. What makes a module interface well-designed?

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
9. How do you avoid circular dependencies between modules?

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
10. What should you monitor for module health in production?

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
11. How do you structure module tests to validate behavior without real infrastructure?

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
12. What is the difference between a root module and a child module in Terraform?

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
13. How do you handle sensitive data in module variables and outputs?

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
14. How do you design modules for cross-cloud compatibility (AWS, Azure, GCP)?

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
15. What is the purpose of the `terraform` block in module versions.tf and why is it important?

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
16. How do you handle module deprecation and migration for consumers?

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
17. What are the benefits and drawbacks of using module registries versus local path modules?

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
18. How do you document module behavior effectively for consumer onboarding?

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
19. What is the difference between for_each and count in module calls, and when would you use each?

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
20. How do you handle breaking changes in shared modules without disrupting consumers immediately?

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

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

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