Stop Building 'God Modules': A Guide to Lean Terraform Composition
Stop creating 'God Modules' with dozens of variables. Learn how to use lean Terraform composition, stable interfaces, and moved blocks to build maintainable infrastructure.
11 Jan 2026, 05:40 UTC

The Problem: The 50-Variable Module
It usually starts with a simple goal: "Create a reusable module for our application stack." You start with a VPC and a database, but then you add S3 buckets, IAM roles, security group rules, and monitoring alerts. Six months later, you have a "God Module"—a single directory with 50+ input variables where changing one line of code feels like playing Jenga with your production infrastructure.
The takeaway is simple: Modules should encapsulate a single architectural pattern, not an entire environment. When a module tries to do everything, it becomes impossible to version, difficult to test, and creates a rigid dependency graph that slows down every deployment.
Defining a Stable Interface
A module is essentially a function for your infrastructure. To keep it maintainable, you must define a strict contract between the module and the root configuration. This contract consists of variables (inputs) and outputs (return values).
Avoid "leaking" implementation details. For example, if your module creates an AWS S3 bucket, don't just output the entire resource object. Instead, output the specific attributes the next module needs, such as the bucket_arn or bucket_domain_name. This allows you to change the underlying resource (e.g., moving from a standard bucket to an encrypted one) without breaking the configurations that consume the module.
Use variable validation to catch errors during the terraform plan phase rather than waiting for a provider error during apply. Terraform 1.5+ allows for complex object types and validation blocks that ensure inputs meet your organizational standards.
Composition Over Monoliths
Instead of one giant module, use composition. Build small, specialized modules (e.g., network, database, compute) and orchestrate them in a root module. This approach provides three primary benefits:
- Independent Versioning: You can update the
databasemodule to a new version without risking thenetworkconfiguration. - Reduced Blast Radius: Changes are isolated to specific resource groups, making it easier to reason about the
terraform planoutput. - Flexibility: Different environments (Dev vs. Prod) can compose the same building blocks differently—for example, using a single-node database in Dev and a multi-AZ cluster in Prod.
Worked Example: Composing a Secure Storage Pattern
Consider a scenario where you need a private S3 bucket with a specific IAM policy. Rather than hardcoding these in your main config, create a focused module.
Module Structure (./modules/secure-bucket/main.tf)
variable "bucket_name" {
type = string
description = "Name of the S3 bucket"
validation {
condition = length(var.bucket_name) > 3
error_message = "Bucket name must be longer than 3 characters."
}
}
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
output "s3_bucket_arn" {
value = aws_s3_bucket.this.arn
}
Root Configuration (main.tf)
Run these commands from your terminal with appropriate AWS credentials configured. Use terraform init to initialize the local module source.
module "app_storage" {
source = "./modules/secure-bucket"
bucket_name = "my-app-data-prod-001"
}
# Pass the output of the module into another resource
resource "aws_iam_policy" "app_policy" {
name = "app-storage-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = ["s3:GetObject"]
Effect = "Allow"
Resource = "${module.app_storage.s3_bucket_arn}/*"
}]
})
}
The Refactoring Trap: State Management
The biggest risk when moving from a monolithic config to a modular one is that Terraform views this as a deletion and a creation. If you move a resource into a module, Terraform sees the address change from aws_s3_bucket.this to module.app_storage.aws_s3_bucket.this.
To avoid destroying your production data, use moved blocks (introduced in Terraform 1.1). These tell Terraform that a resource has changed its address in the configuration but remains the same physical resource in the cloud.
moved {
from = aws_s3_bucket.this
to = module.app_storage.aws_s3_bucket.this
}
Verification: Run terraform plan. If the moved block is working, the plan should show 0 to add, 0 to change, 0 to destroy, with a note that the resource has been moved.
Limitations and Trade-offs
While composition is powerful, it introduces dependency chaining. If Module A depends on an output from Module B, you cannot apply Module A until Module B is complete. In extremely large architectures, this can lead to long plan times.
Additionally, avoid the temptation to create "wrapper modules" that simply call other modules without adding any logic. This adds layers of indentation and variable passing (boilerplate) without providing actual architectural value.
Actionable Closing
To clean up your current Terraform codebase, start by identifying your largest module. Look for variables that are only used by a small subset of resources within that module. Extract those resources into a new, smaller module, implement a moved block to preserve your state, and pin the version of your new module to ensure stability across your environments.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.