Most Terraform tutorials show you how to write a module. Almost none explain how to scope one. In enterprise environments — multi-team, multi-account, regulated industries — the way you divide infrastructure into modules is an architectural decision with real consequences for security, velocity, and maintenance burden. Here's the framework I use.
The three scoping dimensions
When deciding what goes into a module and what stays outside it, I evaluate three things:
- Encapsulation — does this represent a cohesive unit that makes sense to manage as a whole?
- Privilege boundary — does this resource require different IAM permissions than the surrounding infrastructure?
- Volatility — how often does this change? Resources that change together should be grouped together.
A module that fails any of these tests is probably scoped wrong. Networking resources (VPC, subnets, route tables) are a classic example of a natural module: they're cohesive, require specific permissions, and change on a completely different cadence from application resources.
The 80% MVP rule
Enterprise modules get over-engineered constantly. Engineers try to handle every possible edge case upfront — optional load balancers, switchable database engines, conditional monitoring — and end up with 40-variable modules that nobody can understand or maintain.
The better approach: build for the 80% use case first. A module that solves the common case cleanly, with a well-documented interface, delivers more value than a hyper-flexible module that requires expert knowledge to invoke correctly.
# Good: opinionated module with sensible defaults
module "rds_postgres" {
source = "./modules/rds-postgres"
identifier = "app-db"
environment = "prod"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.private_subnet_ids
# Sensible defaults baked in:
# - Multi-AZ enabled in prod
# - Encryption at rest
# - Automated backups (7-day retention)
# - Parameter group tuned for OLTP
}
Privilege boundaries as module boundaries
In regulated industries (healthcare, fintech, banking), IAM privilege separation isn't optional. The pattern I use: separate modules for separate blast radii.
- Foundation module — VPC, subnets, route tables, NACLs. Deployed by network team, read-only for everyone else.
- IAM module — roles, policies, permission boundaries. Deployed by security team. Never bundled with application resources.
- Data module — RDS, S3, KMS keys, DynamoDB. Deployed with data-plane permissions.
- Application module — ECS, Lambda, ALB, security groups. Deployed by application teams with constrained permissions.
This separation means a compromised application CI/CD pipeline can't touch networking or IAM resources. Each module is deployable independently with minimal permissions.
Module versioning
In a monorepo with multiple teams, unversioned modules create chaos. A change to a shared module can break every consumer simultaneously. The fix: version your modules and pin consumers to explicit versions.
module "networking" {
source = "git::https://gitlab.internal/infra/modules.git//networking?ref=v2.3.1"
# ...
}
module "rds_postgres" {
source = "git::https://gitlab.internal/infra/modules.git//rds-postgres?ref=v1.8.0"
# ...
}
Teams can upgrade at their own pace. Breaking changes get a major version bump. The module maintainer publishes a changelog. This is boring infrastructure engineering — and that's exactly what you want.
Variable design
Module interfaces deserve as much care as API design. A few rules:
- Required variables should represent the minimal set of inputs that genuinely vary per invocation. If every consumer passes the same value, it should be a default or a local.
- Use
validationblocks for inputs that have constraints — it gives callers an immediate, readable error instead of a cryptic provider error ten steps into apply. - Group related optional inputs into an object variable rather than proliferating top-level variables.
variable "scaling" {
description = "Auto-scaling configuration"
type = object({
min_size = number
max_size = number
target_cpu_pct = optional(number, 70)
})
default = {
min_size = 1
max_size = 4
}
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging, or prod."
}
}
Output discipline
Outputs are how modules communicate with each other. Be deliberate: export everything a consumer might need (IDs, ARNs, DNS names, security group IDs), but don't leak implementation details. A module that outputs its internal resource counts or intermediate computed values is a module with leaky abstraction.
Wrapping up
Enterprise Terraform is mostly about discipline, not cleverness. Scope modules around natural encapsulation and privilege boundaries. Build for the 80% case and document the rest. Version everything. Design interfaces with the caller in mind. These patterns don't make headlines, but they're what makes IaC maintainable across teams and years of infrastructure evolution.