Overview
Last year I was setting up an IaC system for an e-commerce client. When I took over, their Terraform codebase looked roughly like this: three environments (dev/staging/prod), each with its own copy of the configuration, totaling over 3,000 lines—90% of which was copy-paste. Changing a VPC CIDR meant editing three files in sync. Miss one, and you get environment drift. The worst part: a security group rule was missing in prod for three months without anyone noticing.
Anyone who has used Terraform has experienced this pain. Modularization is the公认 answer—but many teams find that after modularizing, the code is shorter but maintainability is worse. A module with 40 variables and 3 layers of nesting—changing one parameter requires flipping through three files to understand the blast radius. This is the classic symptom of “turning modules into black boxes.”
This article breaks down a Terraform module layered architecture that I’ve validated across multiple enterprise client projects, plus 6 anti-patterns I’ve genuinely hit in production. Not a generic “best practices” list—every decision point comes with “why I did it this way” and “when not to do it this way.”
Why Modularization Matters
Let’s be precise about what problems modularization solves. Not just “code reuse” in vague terms, but three specific pain points:
Pain point 1: Environment drift. Three environments should have identical configurations, but manual copying inevitably introduces errors. In an出行 project, we counted: copy-pasted configurations averaged 3-5 subtle differences per environment (CIDR shifts, inconsistent availability zone counts, missing security group rules). These differences don’t blow up normally—but when a failure hits, they turn root cause analysis into a nightmare.
Pain point 2: Change synchronization cost. Changing one AMI ID means editing it in 3 environments. If 5 team members are simultaneously modifying different modules, merge conflicts become frequent enough to make you question your career choices.
Pain point 3: Knowledge transfer breakdown. A new team member looks at an 800-line main.tf and has no idea which resources are related, which changes affect what. Without module boundaries, there are no cognitive anchors.
The essence of modularization isn’t “making code shorter”—it’s establishing clear abstraction boundaries. Each module becomes a unit that can be independently understood, tested, and changed.
Module Layered Architecture Design
The Three-Layer Model
My recommended three-layer architecture:
| Layer | Responsibility | Example | Variable Count | Resource Count |
|---|---|---|---|---|
| Base module | Encapsulates a single cloud resource | modules/vpc, modules/rds | 5-15 | 3-8 |
| Composition module | Combines multiple base modules | modules/eks-platform | 15-30 | Calls 3-5 base modules |
| Environment layer | Defines environment-specific config | envs/prod/ | Unlimited | Calls 2-4 composition modules |
These three layers aren’t a new concept, but many people mix them up in practice. The most common mistake: base modules containing composition logic, or environment layers calling base modules directly and skipping the composition layer. Let me break down each layer.
Base Module: Single-Responsibility Building Blocks
The core principle of base modules is single responsibility: one module manages one type of resource. modules/vpc only manages VPC and subnets. modules/rds only manages database instances. Sounds simple, but I’ve seen too many violations:
# Anti-pattern: one module manages VPC + RDS + Security Group + IAM
module "everything" {
source = "./modules/everything"
vpc_cidr = "10.0.0.0/16"
rds_engine = "postgres"
rds_instance_class = "db.r5.large"
# ... plus 38 more variables
}
Changing one RDS parameter in this module might trigger a VPC rebuild plan because Terraform’s dependency graph couples unrelated resources together.
A correct base module looks like this:
# modules/vpc/main.tf
# Only manages VPC, subnets, route tables, NAT gateways—nothing else
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(
local.common_tags,
{ Name = "${var.name_prefix}-vpc" }
)
}
resource "aws_subnet" "private" {
for_each = toset(var.private_subnet_cidrs)
vpc_id = aws_vpc.this.id
cidr_block = each.value
availability_zone = var.availability_zones[tonumber(regex("[0-9]+$", each.value)) % length(var.availability_zones)]
tags = merge(
local.common_tags,
{ Name = "${var.name_prefix}-private-${each.key}", Tier = "private" }
)
}
resource "aws_route_table" "private" {
for_each = aws_subnet.private
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[each.key].id
}
tags = merge(local.common_tags, { Name = "${var.name_prefix}-rt-private-${each.key}" })
}
Keep base module variables to 5-15. Beyond 15, start questioning—chances are the responsibility has expanded.
My rule of thumb: If a module’s variable descriptions contain more than 3 “when X is true, enable Y resource” patterns, the module is doing too much. Split it.
Composition Module: Business Logic Assembly Layer
Composition modules assemble multiple base modules according to business logic. For example, modules/eks-platform combines VPC + EKS cluster + IAM roles + security groups into a complete K8s platform.
# modules/eks-platform/main.tf
# Combines VPC + EKS + node groups, but doesn't redefine these resources
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr
name_prefix = var.name_prefix
availability_zones = var.availability_zones
private_subnet_cidrs = var.private_subnet_cidrs
public_subnet_cidrs = var.public_subnet_cidrs
}
module "eks" {
source = "../eks"
cluster_name = "${var.name_prefix}-cluster"
kubernetes_version = var.kubernetes_version
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
node_groups = var.node_groups
}
# Outputs for the environment layer
output "cluster_endpoint" {
value = module.eks.cluster_endpoint
}
output "vpc_id" {
value = module.vpc.vpc_id
}
Key design principle for composition modules: assemble only, don’t define resources. No resource blocks should appear in composition modules—all resources are defined in base modules. Composition modules only handle module calls and parameter passing.
This principle saved me in a real project. A client wanted to add an extra security group rule to their EKS cluster and directly added a resource "aws_security_group_rule" in the eks-platform composition module. When the EKS module was upgraded, that security group rule became orphaned—Terraform state had the resource, but no module claimed it, resulting in state drift.
Environment Layer: The Differentiation Entry Point
The environment layer is the top level. Each environment gets its own directory and only defines that environment’s specific configuration:
infrastructure/
├── modules/ # Base modules
│ ├── vpc/
│ ├── eks/
│ ├── rds/
│ └── alb/
├── compositions/ # Composition modules
│ ├── eks-platform/
│ └── web-stack/
├── envs/ # Environment layer
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars
└── README.md
The environment layer’s main.tf is extremely concise:
# envs/prod/main.tf
terraform {
required_version = ">= 1.5.0"
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "eks-platform/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks-prod"
encrypt = true
}
}
module "platform" {
source = "../../compositions/eks-platform"
name_prefix = "prod"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
kubernetes_version = "1.30"
node_groups = {
general = {
instance_types = ["m5.large", "m5.xlarge"]
min_size = 3
max_size = 10
desired_size = 5
}
spot = {
instance_types = ["m5.large"]
min_size = 0
max_size = 20
desired_size = 5
capacity_type = "spot"
}
}
}
Notice: no resource blocks in the environment layer—only module calls and parameter assignments. All logic lives in modules; the environment layer only handles “what parameters does this environment use.”
For Terraform State backend configuration, this shows the basic structure only. Production state file management involves a complete disaster recovery approach—see Related: Lost Your State File? Terraform State Disaster Recovery and Production-Grade Backend Architecture for details.
Variable and Output Design
Three Iron Rules for Variables
Rule 1: Every variable must have a type and description.
# Correct
variable "node_groups" {
type = map(object({
instance_types = list(string)
min_size = number
max_size = number
desired_size = number
capacity_type = optional(string, "ON_DEMAND")
}))
description = "Map of node group configurations. Key is the group name."
}
# Wrong: no type, no description
variable "node_groups" {
default = {}
}
This isn’t a style issue—it’s an engineering issue. Variables without type constraints accept any input. Pass the wrong type accidentally, and terraform plan won’t error but apply will explode at 2 AM. You don’t want to deal with that.
Rule 2: Provide safe defaults.
variable "enable_deletion_protection" {
type = bool
default = true # Production-safe default
description = "Whether to enable deletion protection for resources that support it."
}
Design principle for defaults: defaults must be production-safe. enable_deletion_protection defaults to true. Dev environments explicitly set it to false. This way, even if someone forgets to configure it, production won’t lose protection from accidental deletion.
Rule 3: Split when variables exceed 15.
I set a hard metric in real projects: base modules ≤ 15 variables, composition modules ≤ 30. Beyond that, split. This isn’t an arbitrary number—it’s the cognitive load threshold I’ve observed across multiple projects. A module with 20+ variables takes 3x longer for a newcomer to understand compared to a 15-variable module.
Output Value Standards
Output values are the module’s “interface contract.” Design principle: output what consumers need, not what the module internally implements.
# Correct: output identifiers consumers need
output "vpc_id" {
value = aws_vpc.this.id
description = "The ID of the VPC."
}
output "private_subnet_ids" {
value = [for s in aws_subnet.private : s.id]
description = "List of private subnet IDs."
}
# Wrong: output internal implementation details
output "route_table_private_ids" {
value = [for rt in aws_route_table.private : rt.id]
}
Route table IDs are internal implementation of the VPC module—unless there’s a clear consumer needing them, don’t expose them. Every additional output is an additional coupling point: when you refactor the module’s route table implementation, everything depending on that output needs to change too.
Sensitive Data Handling
variable "db_password" {
type = string
sensitive = true
description = "Master password for the RDS instance."
}
output "db_endpoint" {
value = aws_db_instance.this.endpoint
sensitive = true # Mark as sensitive if endpoint contains sensitive info
}
Variables marked sensitive = true are displayed as (sensitive value) in terraform plan and terraform apply output. But note: this only masks at the CLI output level—the state file still stores values in plaintext. True sensitive data protection requires Vault or KMS integration—see Related: Deploying IaC Without Tests? A Four-Layer Validation Pipeline That Cuts 3AM Alerts by 80% for a detailed security validation approach.
Module Version Management
Local Modules vs Registry Modules
This choice directly impacts team iteration speed.
| Aspect | Local Modules | Registry Modules |
|---|---|---|
| Change speed | Fast, change and use immediately | Slow, requires release + update reference |
| Version control | Depends on Git repo | Independent version numbers |
| Reuse scope | Within single repo | Cross-repo, cross-team |
| Best for | Rapid iteration phase | Stable sharing phase |
My recommendation: Use local modules exclusively during the first 3 months (project initial phase) for rapid iteration and experimentation. Once modules stabilize, extract high-frequency reusable modules to independent Git repos for versioned Registry references.
This strategy comes from real lessons. One client started with Registry modules from day one. In the first two months, they modified modules 40 times—each time requiring: modify module repo → tag → update source version in consumers → re-run terraform init. Iteration speed dropped by 3x.
Version Locking Strategy
# Recommended: semantic version constraints
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # Allow 5.x patch updates
}
# More conservative
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.2" # Lock exact version, manual upgrades only
}
# Not recommended: no version lock
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
# No version field, pulls latest
}
~> 5.0 allows 5.x updates but no major version bumps. This strategy balances security and convenience: bugfixes in patch versions come automatically, major version upgrades require human review.
For more Terraform basics, see Related: Getting Started with Terraform Infrastructure as Code.
Multi-Environment Reuse in Practice
The Right Way to Handle Environment Differentiation
Multi-environment reuse is the most direct value of modularization. Core principle: module logic doesn’t change, only parameters change.
# envs/dev/terraform.tfvars
name_prefix = "dev"
vpc_cidr = "10.20.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
node_groups = {
general = {
instance_types = ["t3.medium"]
min_size = 1
max_size = 3
desired_size = 1
}
}
# envs/prod/terraform.tfvars
name_prefix = "prod"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
node_groups = {
general = {
instance_types = ["m5.large", "m5.xlarge"]
min_size = 3
max_size = 10
desired_size = 5
}
spot = {
instance_types = ["m5.large"]
min_size = 0
max_size = 20
desired_size = 5
capacity_type = "spot"
}
}
Dev uses 2 availability zones, t3.medium, minimum 1 node. Prod uses 3 availability zones, m5.large, minimum 3 nodes plus a Spot pool. Logic is identical—only parameters differ.
Terragrunt vs Native Terraform
As environment count grows, native Terraform’s pain points emerge: each environment directory needs its own terraform block (backend config) and provider block, causing code duplication. Terragrunt solves this:
# envs/prod/terragrunt.hcl
include "root" {
path = find_in_parent_folders("root.hcl")
}
terraform {
source = "../../compositions/eks-platform"
}
inputs = {
name_prefix = "prod"
vpc_cidr = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
kubernetes_version = "1.30"
}
# envs/root.hcl - shared configuration
remote_state {
backend = "s3"
config = {
bucket = "mycompany-tfstate"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "tf-locks"
encrypt = true
}
}
Terragrunt’s find_in_parent_folders automatically finds shared config. path_relative_to_include() makes the state key auto-differentiate by environment name. Backend config is written once.
My choice: For 5 or fewer environments, native Terraform is sufficient. Beyond that, use Terragrunt. Terragrunt isn’t a silver bullet—it introduces an additional toolchain and learning curve. In a logistics platform project with only 3 environments, we managed perfectly fine with native Terraform + tfvars files. No need to add Terragrunt complexity.
Performance Comparison Data
This layered architecture produced the following comparison data from a real client deployment:
| Metric | Before Modularization | After Modularization | Change |
|---|---|---|---|
| Total lines of code | 3,200 lines | 850 lines | ↓73% |
| Environment deployment time | 25 min | 8 min | ↓68% |
| Change sync cost | Edit 3 files | Edit 1 tfvars | ↓67% |
| Config-error incidents | 2-3/month | 1/quarter | ↓80% |
| New member onboarding | 2 weeks | 3 days | ↓78% |
The 73% code reduction came from eliminating copy-paste, not removing features. The 68% deployment time improvement is partly because modularization produces a cleaner dependency graph, allowing Terraform to parallelize unrelated resources more effectively.
6 Production Anti-Patterns and Fixes
These anti-patterns aren’t copied from articles—each one comes from real production experience.
Anti-Pattern 1: Over-Abstraction
Symptom: A base module tries to cover every scenario, stuffed with enable_xxx toggles.
# Anti-pattern
module "vpc" {
source = "./modules/vpc"
enable_vpc = true
enable_nat_gateway = true
enable_flow_log = true
enable_vpn_gateway = false
enable_dns = true
enable_ipv6 = false
# ... plus 20 more enable_xxx
}
Why it’s a problem: The module’s terraform plan output becomes a combinatorial explosion of if-else branches. To understand “what resources does this module actually create,” you need to trace a dozen toggle states.
Fix: Split into independent modules. NAT gateway, Flow Log, VPN Gateway each get their own module, referenced as needed.
# Fixed
module "vpc" {
source = "./modules/vpc"
# Only manages VPC and subnets
cidr_block = "10.0.0.0/16"
}
module "nat" {
source = "./modules/nat-gateway"
subnet_ids = module.vpc.public_subnet_ids
# Reference only when needed; omit this block entirely if not needed
}
Anti-Pattern 2: Variable Explosion
Symptom: Composition modules expose all underlying module variables, causing variable count to spiral out of control.
# Anti-pattern: composition module transparently passes through all variables
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr_block
enable_dns_support = var.vpc_enable_dns_support
enable_dns_hostnames = var.vpc_enable_dns_hostnames
instance_tenancy = var.vpc_instance_tenancy
# Passing through all 15 variables of the VPC module
}
Why it’s a problem: The composition module becomes a “pipe”—consumers need to understand all underlying module variables. Variable count goes from 15 + 20 + 10 = 45, all dumped on the composition module’s interface.
Fix: Composition modules should collapse parameters, exposing only business-relevant ones.
# Fixed: composition module exposes only business parameters
module "vpc" {
source = "../vpc"
cidr_block = var.vpc_cidr
# Other parameters use base module defaults, no passthrough
}
Rule of thumb: If a composition module’s variable count exceeds 30, check whether it’s transparently passing through underlying variables. Beyond 50, refactor immediately.
Anti-Pattern 3: Circular Dependencies
Symptom: Module A depends on module B’s output, and module B depends on module A’s output.
This is particularly common between VPC and security groups: the VPC module needs a security group ID for default route rules, and the security group module needs the VPC ID for ownership.
Fix: Extract shared resources to the upper layer.
# Fix: environment layer creates VPC first, then passes to both security group and routing
module "vpc" {
source = "../../modules/vpc"
cidr_block = var.vpc_cidr
}
module "security_group" {
source = "../../modules/security-group"
vpc_id = module.vpc.vpc_id
rules = var.security_group_rules
}
# VPC module doesn't do security group associations internally
Anti-Pattern 4: Ignoring Module Version Compatibility
Symptom: Local modules use source = "../modules/vpc" directly. Changing a module affects everyone—no version isolation.
Why it’s a problem: You add a force_destroy parameter to the VPC module, tested in dev, but prod’s configuration isn’t compatible with this parameter. Without version isolation, prod’s next terraform init pulls the incompatible version.
Fix: Use Git tags for version isolation.
# Fix: lock version with Git ref
module "vpc" {
source = "git::https://git.mycompany.com/infra/terraform-modules.git//vpc?ref=v1.2.0"
cidr_block = var.vpc_cidr
}
Dev uses ref=v1.3.0-rc1, prod locks ref=v1.2.0. Upgrade by first validating in dev, then updating prod’s version number.
Anti-Pattern 5: State File Coupling
Symptom: Multiple modules share the same state file. One module’s terraform destroy affects other modules’ resources.
Why it’s a problem: The state file is Terraform’s “ledger.” All modules in the same state means all modules share one lock—while one module is applying, all others wait. And terraform destroy might accidentally remove too much.
Fix: Use independent state files per environment layer.
# envs/prod/main.tf - independent state key
terraform {
backend "s3" {
bucket = "mycompany-tfstate-prod"
key = "eks-platform/terraform.tfstate" # This key is independent
region = "us-east-1"
}
}
Different modules use different state keys, no interference. The complete state management approach is detailed in Related: Terraform State Disaster Recovery and Production-Grade Backend Architecture.
Anti-Pattern 6: Deploying Modules to Production Without Tests
Symptom: Modules go straight to prod with terraform apply. Problems get fixed manually in state.
Why it’s a problem: Terraform modules are code—deploying without tests is flying blind. A variable type error or missing dependency can cause apply to fail midway, leaving half-created resources.
Fix: Use terraform test (Terraform 1.6+) or terratest for module testing.
# tests/vpc_test.tftest.hcl
run "vpc_creation" {
command = plan
variables {
cidr_block = "10.0.0.0/16"
name_prefix = "test"
availability_zones = ["us-east-1a", "us-east-1b"]
}
assert {
condition = output.vpc_cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR block does not match input"
}
assert {
condition = length(output.private_subnet_ids) == 2
error_message = "Should create 2 private subnets"
}
}
Run terraform test in CI. Module changes that don’t pass tests can’t merge to main. For a complete IaC testing system design, see Related: Deploying IaC Without Tests? A Four-Layer Validation Pipeline That Cuts 3AM Alerts by 80%.
Production Environment Notes
Capacity assessment: Modularization doesn’t change resource count—only management approach. Before implementation, count resources per environment. Beyond 500 resources, a single state file’s terraform plan takes over 5 minutes—consider splitting state.
Rollback strategy: If apply fails after a module change, Terraform doesn’t auto-rollback (that’s not its design goal). Rollback strategy: use version control to revert module code → terraform apply to reconcile actual state to the old configuration. Not terraform state rollback—Terraform doesn’t have that command.
Team collaboration: After modularization, module owners must be explicit. Recommend assigning an owner to each base module—changes require owner review. Modules without owners gradually become “orphan code”—nobody dares to touch them, eventually becoming technical debt.
Toolchain selection: Here’s my toolchain recommendation by project scale:
| Project Scale | Recommended Toolchain | Rationale |
|---|---|---|
| ≤3 environments | Native Terraform + tfvars | Simple toolchain, sufficient |
| 5-10 environments | Terragrunt + local modules | Reduces config duplication, keeps local module iteration speed |
| 10+ environments | Terragrunt + Registry modules + Atlantis/TFC | Version isolation + PR-driven automated plan |
| Cross-team sharing | Terraform Cloud/Enterprise | Unified module registry + RBAC + audit logs |
Summary
The core of modular IaC isn’t “splitting code”—it’s “establishing abstraction boundaries.” The three-layer architecture (base module → composition module → environment layer) provides clear boundary划分: base modules manage single resources, composition modules manage business assembly, environment layers manage differentiated parameters.
The common root cause of all 6 anti-patterns is blurred boundaries: over-abstraction is base module boundary expansion, variable explosion is composition module interface失控, circular dependency is cross-module boundary intersection, ignoring versions is missing evolution isolation, state coupling is missing runtime boundaries, no testing is missing quality boundaries.
From real-world data, this architecture reduced code by 73%, shortened deployment time by 68%, and cut configuration-error incidents by 80%. But numbers are just results—the real value is: each module becomes a unit that can be independently understood, tested, and changed. New members onboard in 3 days. Changing one parameter doesn’t require flipping through three files.
If you only remember one sentence: modules are written for humans to read, not for Terraform to run. A module that Terraform can execute but humans can’t understand is a failed design.
References & Acknowledgments
The following resources were referenced during the writing of this article. Thanks to the original authors for their contributions:
- Terraform Best Practices — terraform-best-practices.com, provided community practice references for code structure, naming conventions, and module design
- Terraform v1.x Compatibility Promises — HashiCorp official documentation, defines Terraform 1.x compatibility commitments, basis for module version management strategy
- Architecture strategies for using infrastructure as code — Microsoft Azure Well-Architected Framework, provided IaC maturity model and design pattern references
- Terraform 模块化设计: 如何构建可复用、可组合的基础设施代码 — Jianshu tech blog, referenced modular design principles and code reuse rate data
- Terragrunt 常见问题解答 — CSDN tech blog, referenced Terragrunt’s DRY configuration reuse mechanism and multi-environment management approach