working module handles VPCs, subnets, and route tables. A compute module manages instance groups, scaling policies, and health checks. A database module provisions engines, parameter groups, and security associations. Cross-cutting concerns like tagging, logging, or encryption are handled by composition, not inheritance.
Step 2: Design Explicit Contracts
Variables and outputs form the module's API. Every variable requires type constraints, validation rules, and default behavior. Outputs must expose only what downstream modules need, never internal implementation details. Provider blocks belong in the root module or explicitly passed via required_providers, never duplicated across child modules.
# modules/network/main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
}
variable "environment" {
type = string
description = "Deployment environment"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "cidr_block" {
type = string
description = "Primary VPC CIDR"
default = "10.0.0.0/16"
}
variable "enable_dns_support" {
type = bool
description = "Enable DNS resolution"
default = true
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_support = var.enable_dns_support
enable_dns_hostnames = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
ManagedBy = "terraform"
}
}
output "vpc_id" {
value = aws_vpc.main.id
description = "VPC identifier for downstream resources"
}
output "cidr_block" {
value = aws_vpc.main.cidr_block
description = "Resolved CIDR for subnet allocation"
}
Step 3: Implement Composition Over Inheritance
Root modules orchestrate child modules. They handle provider configuration, remote state backends, and cross-module wiring. Composition avoids tight coupling by passing only necessary outputs as inputs.
# root/networking/main.tf
terraform {
backend "s3" {
bucket = "tf-state-prod"
key = "networking/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "tf-locks"
}
}
provider "aws" {
region = "us-east-1"
default_tags {
tags = {
Team = "platform"
Project = "core-infra"
ManagedBy = "terraform"
}
}
}
module "vpc" {
source = "../../modules/network"
environment = "prod"
cidr_block = "10.0.0.0/16"
enable_dns_support = true
}
module "subnets" {
source = "../../modules/subnet"
vpc_id = module.vpc.vpc_id
cidr_block = module.vpc.cidr_block
environment = "prod"
# Subnet allocation logic handled internally
}
Step 4: Version and Distribute
Modules are distributed via Git tags, private registries, or S3/GCS archives. Semantic versioning (v1.2.0) enforces compatibility. Consumers pin versions explicitly. Breaking changes require major version bumps. Internal registries or module mirrors prevent supply chain drift.
Step 5: Validate and Test
Static analysis catches syntax, security, and policy violations. Plan validation verifies dependency graphs. Integration tests confirm resource creation and state consistency.
# Static validation
terraform fmt -recursive
tflint --deep --module
tfsec . --format sarif --out reports/tfsec.sarif
# Plan validation
terraform init -backend=true
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions[] == "delete")'
Architecture Decisions and Rationale
- State isolation per module group: Prevents lock contention and limits blast radius. Networking, compute, and data layers maintain separate state files.
- Explicit variable validation: Fails fast on misconfiguration. Reduces silent drift and downstream failures.
- Provider declaration in root: Avoids duplicate provider initialization, simplifies credential rotation, and enables multi-account routing via aliases.
- Output minimization: Exposing only stable identifiers prevents consumers from depending on implementation details that may change.
- Composition over inheritance: Terraform lacks native inheritance. Composition via root modules enables flexible wiring without fragile base-module coupling.
Pitfall Guide
- Over-abstracting: Creating modules for every resource type leads to parameter explosion and unreadable interfaces. Best practice: group resources by lifecycle and dependency. A module should represent a deployable unit, not a single AWS resource.
- Tight coupling via outputs: Chaining outputs across multiple modules creates fragile dependency graphs. Best practice: limit output exposure to stable identifiers. Use data sources or remote state reads for cross-environment references instead of direct module chaining.
- Ignoring state boundaries: Sharing a single state file across environments or teams causes lock contention and accidental cross-environment modifications. Best practice: isolate state by environment, module group, and team ownership. Use backend configuration per root module.
- Missing variable validation: Unvalidated variables accept incorrect types or values, propagating errors to apply time. Best practice: enforce type constraints, validation blocks, and default behaviors. Document acceptable ranges and examples.
- Hardcoding provider configuration: Embedding region, account IDs, or credentials in modules breaks multi-account and multi-region deployments. Best practice: pass provider configuration from the root. Use
required_providers for version pinning and aliases for cross-account routing.
- Skipping version pinning: Using
latest or untagged references introduces uncontrolled drift and breaking changes. Best practice: pin to semantic versions. Automate version updates via dependency management tools or registry policies.
- Treating modules as copy-paste templates: Duplicating module code across projects loses version control, testing, and centralized updates. Best practice: publish modules to a registry or Git repository. Consume via
source references. Maintain a changelog and deprecation policy.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-env startup | Single root module with lightweight child modules | Reduces overhead while maintaining structure | Low initial cost, minimal state management |
| Multi-account enterprise | Environment-scoped root modules with shared registry | Enforces isolation, enables cross-account routing | Moderate overhead, prevents cross-env drift |
| Platform team vs App teams | Platform publishes modules, apps consume via composition | Separates concerns, standardizes compliance | Higher initial investment, reduces app team cloud waste by 15-20% |
| High-compliance regulated | Strict version pinning, policy-as-code gates, immutable state | Auditability, change control, rollback capability | Increased tooling cost, eliminates compliance penalties |
Configuration Template
terraform-modules/
βββ modules/
β βββ network/
β β βββ main.tf
β β βββ variables.tf
β β βββ outputs.tf
β β βββ versions.tf
β βββ compute/
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
β βββ versions.tf
βββ environments/
β βββ dev/
β β βββ main.tf
β βββ prod/
β βββ main.tf
βββ .tflint.hcl
βββ tfsec.yaml
βββ Makefile
# modules/network/versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.30"
}
}
}
# environments/prod/main.tf
terraform {
backend "s3" {
bucket = "tf-state-prod"
key = "prod/networking/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "tf-locks-prod"
}
}
provider "aws" {
region = "us-east-1"
}
module "network" {
source = "../../modules/network"
environment = "prod"
cidr_block = "10.0.0.0/16"
}
module "compute" {
source = "../../modules/compute"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
environment = "prod"
}
Quick Start Guide
- Scaffold a module directory with
main.tf, variables.tf, outputs.tf, and versions.tf. Define one resource group and explicit variable contracts.
- Initialize with
terraform init and validate with terraform fmt -recursive && tflint --deep --module. Fix structural or policy violations before proceeding.
- Create an environment root module, reference the child module via
source = "../../modules/<name>", and configure a remote backend for state isolation.
- Run
terraform plan -out=tfplan, inspect the dependency graph, and verify no unintended deletions or cross-environment references exist.
- Tag the module repository with a semantic version (
v1.0.0), update the root module to pin the version, and apply. Subsequent changes follow the same validation and versioning workflow.