o provision complex infrastructure via declarative contracts without deep platform expertise.
Core Solution
Effective module design requires a shift from implementation-centric thinking to contract-centric engineering. The solution follows a structured lifecycle: contract definition, composition, validation, and versioning.
Step 1: Define the Module Contract
A module must solve a specific domain problem. The contract is defined by inputs and outputs. Inputs should represent the what, not the how. Avoid exposing internal implementation details in the interface.
Architecture Decision: Use the "Single Responsibility Principle." A module should manage one logical resource or a tightly coupled set of resources that form a single operational unit. For example, a web-service module might compose an ALB, Target Group, and Security Group, but it should not manage the underlying VPC or RDS instance. Those are separate domains.
Terraform's validation blocks allow modules to enforce constraints at plan time. This prevents invalid configurations from reaching the provider API, reducing blast radius and debugging time.
# variables.tf
variable "environment" {
description = "Deployment environment."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be one of: dev, staging, prod."
}
}
variable "enable_encryption" {
description = "Toggle for server-side encryption."
type = bool
default = true
}
variable "allowed_cidrs" {
description = "List of CIDR blocks allowed access."
type = list(string)
default = ["0.0.0.0/0"]
validation {
condition = length(var.allowed_cidrs) > 0
error_message = "At least one CIDR block must be specified."
}
}
Step 3: Composition and Idempotency
Modules should use for_each over count for resource collections to ensure stable resource addresses. This prevents index-shifting errors during updates. Composition allows building higher-level abstractions from lower-level modules.
# main.tf
module "storage" {
source = "../modules/secure-s3-bucket"
bucket_name = "${var.project}-${var.environment}-data"
versioning = var.environment == "prod" ? true : false
encryption = var.enable_encryption
lifecycle_rules = [
{
id = "archive-old-logs"
enabled = true
transition = {
days = 90
storage_class = "GLACIER"
}
}
]
}
module "compute" {
source = "../modules/ec2-cluster"
cluster_name = var.project
subnet_ids = var.subnet_ids
ami_id = data.aws_ami.latest.id
instance_count = var.environment == "prod" ? 3 : 1
# Composition: Passing outputs from one module to another
security_group_ids = [module.storage.security_group_id]
}
Step 4: Output Minimization and Sensitivity
Outputs should expose only the data required by downstream consumers. Avoid outputting entire resource objects. Mark sensitive data explicitly.
# outputs.tf
output "bucket_arn" {
description = "The ARN of the S3 bucket."
value = aws_s3_bucket.data.arn
}
output "db_connection_string" {
description = "Connection string for the database."
value = aws_db_instance.main.connection_string
sensitive = true
}
Step 5: Versioning and Registry Strategy
Modules must be versioned using Semantic Versioning (SemVer). Pin versions in the source attribute to prevent accidental upgrades.
module "network" {
source = "app.terraform.io/organization/network/azurerm"
version = "2.1.0"
# Configuration arguments...
}
Pitfall Guide
1. The "God Module" Anti-Pattern
Mistake: Creating a module that provisions an entire stack (VPC, RDS, ECS, ALB, CloudWatch).
Impact: Updates to the database schema require redeploying the network. Testing becomes impossible. Reusability is zero because the context is too specific.
Best Practice: Decompose by domain. Use a "root module" to orchestrate composition, but keep child modules focused on single responsibilities.
2. Hardcoding Values Inside Modules
Mistake: Embedding region names, account IDs, or CIDR ranges directly in module logic.
Impact: The module becomes non-portable and cannot be used across environments or accounts.
Best Practice: All variable data must be inputs. Use data sources for lookups that depend on the environment context.
3. Ignoring Provider Version Pinning
Mistake: Allowing modules to inherit provider versions from the root configuration without constraints.
Impact: A provider upgrade in the root config can break module behavior silently.
Best Practice: Define required_providers and required_version within every module. This ensures the module is tested against specific provider versions.
4. Over-Engineering Abstractions
Mistake: Building modules for resources that are rarely reused or have low complexity.
Impact: Indirection overhead exceeds the value of reuse. Debugging requires navigating multiple layers of code.
Best Practice: Apply the "Rule of Three." Only abstract when a pattern is repeated three times or when the abstraction provides significant governance value (e.g., security controls).
5. State Fragmentation and Data Sources
Mistake: Using terraform_remote_state data sources to fetch outputs from modules managed by other teams.
Impact: Creates tight coupling between state files. Changes in one team's module output break another team's plan.
Best Practice: Pass data via explicit inputs. If cross-team sharing is required, use a dedicated state export module or a secrets manager, not remote state dependencies.
6. Missing lifecycle Rules
Mistake: Failing to define prevent_destroy or create_before_destroy on critical resources within modules.
Impact: Accidental deletion of production databases or load balancers during refactoring.
Best Practice: Include appropriate lifecycle meta-arguments in modules managing stateful resources. Document these constraints in the module README.
7. Treating Modules as Functions
Mistake: Assuming modules have no side effects or state persistence.
Impact: Engineers misuse modules for data transformation or scripting, leading to unexpected resource creation or state corruption.
Best Practice: Modules manage infrastructure state. Use local values or external scripts for pure computation. Ensure modules are idempotent.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small Team / Rapid Prototyping | Local Modules | Low overhead, fast iteration, no registry setup. | Low |
| Enterprise / Multi-Team | Private Module Registry | Centralized governance, version control, access management. | Medium |
| Cross-Environment Consistency | Shared Core Modules | Ensures identical baseline configuration across dev/staging/prod. | Low (Amortized) |
| High-Security Compliance | Policy-Enforced Modules | Embeds compliance checks in the contract; prevents drift. | Medium |
| Legacy Refactoring | Wrapper Modules | Gradual migration path; encapsulates legacy resources. | High (Initial) |
Configuration Template
Use this structure for production-grade modules:
modules/
βββ secure-s3-bucket/
βββ README.md # Usage, inputs, outputs, examples
βββ main.tf # Resource definitions
βββ variables.tf # Inputs with validation
βββ outputs.tf # Outputs with sensitivity
βββ versions.tf # Provider and TF version constraints
βββ examples/
β βββ complete/
β βββ main.tf # Example usage
β βββ outputs.tf # Example outputs
βββ tests/
βββ integration/
βββ main_test.go # Terratest suite
versions.tf:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
Quick Start Guide
- Scaffold Structure: Create the directory tree shown in the Configuration Template. Initialize
main.tf, variables.tf, outputs.tf, and versions.tf.
- Define Inputs: Add variables for all configuration parameters. Apply
validation blocks to enforce business rules. Set sensible defaults where appropriate.
- Implement Resources: Write the resource blocks in
main.tf. Use for_each for collections. Reference variables for all dynamic values.
- Validate and Format: Run
terraform fmt -recursive to standardize formatting. Run terraform validate to check syntax. Run terraform init in the examples/complete directory to verify the module loads correctly.
- Publish: Commit code, tag with a SemVer version (e.g.,
v1.0.0), and push to your version control or registry. Update consumers to reference the new version.