What Makes a Terraform Codebase Scale — Or Break
Two teams. Same cloud provider. Same infrastructure requirements. Six months later, one team ships new environments in 20 minutes; the other spends two days copying and editing YAML.
The difference is almost always modules.
Terraform modules are the mechanism that separates infrastructure you wrote once and maintain everywhere from infrastructure you copied everywhere and maintain nowhere. They're the building block of every mature IaC practice — and the thing most teams implement either too late or too naively.
This guide covers everything: what modules actually are, how to structure them properly, and ten production-grade examples your team can use immediately — from VPCs to EKS clusters to cross-account IAM.
What Is a Terraform Module?
A Terraform module is any directory containing .tf files. Every Terraform configuration is technically a module — when you run terraform apply in a directory, you're running the root module.
The power comes from calling modules from other modules:
# Root module: environments/prod/main.tf
module "vpc" {
source = "../../modules/vpc" # local module
cidr_block = "10.0.0.0/16"
environment = "production"
project_name = "payments-platform"
}
module "eks" {
source = "terraform-aws-modules/eks/aws" # Registry module
version = "20.8.4"
cluster_name = "payments-eks"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
The module encapsulates complexity. The caller only sees inputs and outputs. The 300 lines of VPC configuration, subnet math, and route table logic become three readable lines.
Module Anatomy: The Files That Matter
A well-structured module contains exactly these files:
modules/vpc/
├── main.tf # Resource definitions
├── variables.tf # Input declarations
├── outputs.tf # Values exposed to callers
├── versions.tf # Provider and Terraform version constraints
└── README.md # Auto-generated by terraform-docs
Nothing more is required. Keep modules focused — a module that creates a VPC should not also create EC2 instances. Separation of concern is what makes modules composable.
variables.tf — Define your interface
variable "cidr_block" {
description = "CIDR block for the VPC. Use /16 for most production environments."
type = string
validation {
condition = can(cidrnetmask(var.cidr_block))
error_message = "Must be a valid CIDR block (e.g. 10.0.0.0/16)."
}
}
variable "environment" {
description = "Deployment environment: dev, staging, or production."
type = string
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "environment must be dev, staging, or production."
}
}
variable "common_tags" {
description = "Tags applied to all resources in this module."
type = map(string)
default = {}
}
Every variable needs a description. Without it, callers are guessing. Validations catch misconfiguration before resources are created, not after.
outputs.tf — Define what you expose
output "vpc_id" {
description = "ID of the created VPC."
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs, one per availability zone."
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
description = "List of public subnet IDs, one per availability zone."
value = aws_subnet.public[*].id
}
Only expose what callers need. Internal resource IDs that no other module references should stay internal.
The Three Module Sources
# 1. Local path — your own modules
module "vpc" {
source = "../../modules/vpc"
}
# 2. Terraform Registry — community modules
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "20.8.4"
}
# 3. Git — private modules in your org
module "internal_vpc" {
source = "git::https://github.com/myorg/tf-modules.git//vpc?ref=v1.4.0"
}
Always pin Registry and Git modules to a specific version or tag. Floating references (main, latest) break your infrastructure when upstream changes. This isn't optional hygiene — it's the difference between reproducible and fragile.
Example 1: VPC Module with Multi-AZ Subnets
The foundational module every AWS stack needs. This creates a VPC with public and private subnets across multiple availability zones, Internet Gateway, NAT Gateway, and route tables.
# modules/vpc/main.tf
locals {
azs = slice(data.aws_availability_zones.available.names, 0, var.az_count)
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-vpc"
})
}
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-public-${local.azs[count.index]}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index + var.az_count)
availability_zone = local.azs[count.index]
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-private-${local.azs[count.index]}"
Tier = "private"
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(var.common_tags, { Name = "${var.project_name}-${var.environment}-igw" })
}
resource "aws_eip" "nat" {
count = var.az_count
domain = "vpc"
tags = merge(var.common_tags, { Name = "${var.project_name}-${var.environment}-nat-eip-${count.index}" })
}
resource "aws_nat_gateway" "main" {
count = var.az_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
depends_on = [aws_internet_gateway.main]
tags = merge(var.common_tags, { Name = "${var.project_name}-${var.environment}-nat-${count.index}" })
}
Usage:
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
az_count = 3
environment = var.environment
project_name = var.project_name
common_tags = local.common_tags
}
Example 2: EKS Cluster Module
A production-grade EKS cluster with managed node groups, IRSA support, and cluster addons — using the official AWS EKS module as a dependency.
# modules/eks/main.tf
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "20.8.4"
cluster_name = "${var.project_name}-${var.environment}"
cluster_version = var.kubernetes_version
vpc_id = var.vpc_id
subnet_ids = var.private_subnet_ids
cluster_endpoint_public_access = var.environment == "production" ? false : true
cluster_endpoint_private_access = true
enable_cluster_creator_admin_permissions = true
cluster_addons = {
coredns = { most_recent = true }
kube-proxy = { most_recent = true }
vpc-cni = { most_recent = true }
aws-ebs-csi-driver = {
most_recent = true
service_account_role_arn = module.ebs_csi_irsa.iam_role_arn
}
}
eks_managed_node_groups = {
general = {
min_size = var.node_min_size
max_size = var.node_max_size
desired_size = var.node_desired_size
instance_types = var.node_instance_types
labels = {
Environment = var.environment
NodeGroup = "general"
}
tags = var.common_tags
}
}
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-eks"
})
}
module "ebs_csi_irsa" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.39.0"
role_name = "${var.project_name}-${var.environment}-ebs-csi"
attach_ebs_csi_policy = true
oidc_providers = {
ex = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["kube-system:ebs-csi-controller-sa"]
}
}
}
Usage:
module "eks" {
source = "../../modules/eks"
project_name = var.project_name
environment = var.environment
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
kubernetes_version = "1.29"
node_instance_types = ["m5.xlarge"]
node_min_size = 2
node_max_size = 10
node_desired_size = 3
common_tags = local.common_tags
}
Example 3: RDS PostgreSQL Module
An RDS PostgreSQL instance with parameter group, subnet group, automated backups, and encryption — with Multi-AZ controlled by environment.
# modules/rds-postgres/main.tf
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-${var.environment}-db-subnet-group"
subnet_ids = var.private_subnet_ids
tags = var.common_tags
}
resource "aws_db_parameter_group" "main" {
name = "${var.project_name}-${var.environment}-pg${var.postgres_major_version}"
family = "postgres${var.postgres_major_version}"
parameter {
name = "log_connections"
value = "1"
}
parameter {
name = "log_disconnections"
value = "1"
}
parameter {
name = "log_min_duration_statement"
value = var.environment == "production" ? "1000" : "100"
}
tags = var.common_tags
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-${var.environment}-postgres"
engine = "postgres"
engine_version = var.postgres_engine_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
max_allocated_storage = var.max_allocated_storage
storage_encrypted = true
storage_type = "gp3"
db_name = var.database_name
username = var.master_username
password = var.master_password
db_subnet_group_name = aws_db_subnet_group.main.name
parameter_group_name = aws_db_parameter_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
multi_az = var.environment == "production"
publicly_accessible = false
deletion_protection = var.environment == "production"
backup_retention_period = var.environment == "production" ? 30 : 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
performance_insights_enabled = true
monitoring_interval = 60
monitoring_role_arn = aws_iam_role.rds_monitoring.arn
lifecycle {
prevent_destroy = true
ignore_changes = [password]
}
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-postgres"
})
}
Example 4: S3 Static Website + CloudFront Module
A complete static website infrastructure: S3 bucket with OAC (Origin Access Control), CloudFront distribution, custom domain, and ACM certificate.
# modules/static-website/main.tf
resource "aws_s3_bucket" "website" {
bucket = "${var.project_name}-${var.environment}-website"
tags = var.common_tags
}
resource "aws_s3_bucket_public_access_block" "website" {
bucket = aws_s3_bucket.website.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_cloudfront_origin_access_control" "main" {
name = "${var.project_name}-${var.environment}-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "main" {
origin {
domain_name = aws_s3_bucket.website.bucket_regional_domain_name
origin_id = "S3-${aws_s3_bucket.website.id}"
origin_access_control_id = aws_cloudfront_origin_access_control.main.id
}
enabled = true
default_root_object = "index.html"
aliases = var.domain_names
price_class = "PriceClass_100"
default_cache_behavior {
target_origin_id = "S3-${aws_s3_bucket.website.id}"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
forwarded_values {
query_string = false
cookies { forward = "none" }
}
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html" # SPA fallback
}
viewer_certificate {
acm_certificate_arn = var.acm_certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
restrictions {
geo_restriction { restriction_type = "none" }
}
tags = var.common_tags
}
Example 5: IAM Role with IRSA (for EKS Workloads)
A reusable module for creating IAM roles with Kubernetes Service Account annotation support — the correct way to grant AWS permissions to Kubernetes workloads.
# modules/irsa-role/main.tf
data "aws_iam_policy_document" "assume_role" {
statement {
effect = "Allow"
principals {
type = "Federated"
identifiers = [var.oidc_provider_arn]
}
actions = ["sts:AssumeRoleWithWebIdentity"]
condition {
test = "StringEquals"
variable = "${replace(var.oidc_provider_arn, "/^(.*provider/)/", "")}:sub"
values = ["system:serviceaccount:${var.namespace}:${var.service_account_name}"]
}
condition {
test = "StringEquals"
variable = "${replace(var.oidc_provider_arn, "/^(.*provider/)/", "")}:aud"
values = ["sts.amazonaws.com"]
}
}
}
resource "aws_iam_role" "main" {
name = "${var.project_name}-${var.environment}-${var.role_name}"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
tags = var.common_tags
}
resource "aws_iam_role_policy_attachment" "main" {
for_each = toset(var.policy_arns)
role = aws_iam_role.main.name
policy_arn = each.value
}
output "role_arn" {
description = "ARN of the created IAM role. Use as eks.amazonaws.com/role-arn annotation."
value = aws_iam_role.main.arn
}
Example 6: Application Load Balancer Module
An ALB with HTTPS listener, HTTP-to-HTTPS redirect, target group, and health check configuration.
# modules/alb/main.tf
resource "aws_lb" "main" {
name = "${var.project_name}-${var.environment}-alb"
internal = var.internal
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.internal ? var.private_subnet_ids : var.public_subnet_ids
enable_deletion_protection = var.environment == "production"
enable_http2 = true
access_logs {
bucket = var.access_logs_bucket
prefix = "${var.project_name}/${var.environment}"
enabled = true
}
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-alb"
})
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.acm_certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
}
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_lb_target_group" "main" {
name = "${var.project_name}-${var.environment}-tg"
port = var.target_port
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = var.target_type # "ip" for ECS/EKS, "instance" for EC2
health_check {
enabled = true
healthy_threshold = 2
unhealthy_threshold = 3
interval = 30
path = var.health_check_path
matcher = "200-299"
}
tags = var.common_tags
}
Example 7: Lambda Function Module
A Lambda function with IAM execution role, CloudWatch log group with retention, and optional VPC attachment.
# modules/lambda/main.tf
resource "aws_iam_role" "lambda" {
name = "${var.project_name}-${var.environment}-${var.function_name}-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
tags = var.common_tags
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = var.vpc_config != null \
? "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" \
: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${var.project_name}-${var.environment}-${var.function_name}"
retention_in_days = var.environment == "production" ? 90 : 14
tags = var.common_tags
}
resource "aws_lambda_function" "main" {
function_name = "${var.project_name}-${var.environment}-${var.function_name}"
description = var.description
filename = var.filename
source_code_hash = filebase64sha256(var.filename)
handler = var.handler
runtime = var.runtime
timeout = var.timeout
memory_size = var.memory_size
role = aws_iam_role.lambda.arn
environment {
variables = merge(var.environment_variables, {
ENVIRONMENT = var.environment
})
}
dynamic "vpc_config" {
for_each = var.vpc_config != null ? [var.vpc_config] : []
content {
subnet_ids = vpc_config.value.subnet_ids
security_group_ids = vpc_config.value.security_group_ids
}
}
depends_on = [
aws_cloudwatch_log_group.lambda,
aws_iam_role_policy_attachment.lambda_basic
]
tags = var.common_tags
}
Example 8: ElastiCache Redis Module
A Redis replication group with automatic failover, encryption at rest and in transit, and subnet/security group management.
# modules/elasticache-redis/main.tf
resource "aws_elasticache_subnet_group" "main" {
name = "${var.project_name}-${var.environment}-redis-subnet-group"
subnet_ids = var.private_subnet_ids
tags = var.common_tags
}
resource "aws_elasticache_parameter_group" "main" {
name = "${var.project_name}-${var.environment}-redis${var.redis_major_version}"
family = "redis${var.redis_major_version}"
parameter {
name = "maxmemory-policy"
value = var.maxmemory_policy
}
}
resource "aws_elasticache_replication_group" "main" {
replication_group_id = "${var.project_name}-${var.environment}-redis"
description = "Redis cluster for ${var.project_name} ${var.environment}"
engine = "redis"
engine_version = var.redis_engine_version
node_type = var.node_type
num_cache_clusters = var.environment == "production" ? 2 : 1
parameter_group_name = aws_elasticache_parameter_group.main.name
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [aws_security_group.redis.id]
port = 6379
at_rest_encryption_enabled = true
transit_encryption_enabled = true
automatic_failover_enabled = var.environment == "production"
multi_az_enabled = var.environment == "production"
snapshot_retention_limit = var.environment == "production" ? 7 : 1
snapshot_window = "05:00-06:00"
maintenance_window = "Mon:06:00-Mon:07:00"
lifecycle {
prevent_destroy = true
}
tags = merge(var.common_tags, {
Name = "${var.project_name}-${var.environment}-redis"
})
}
Example 9: Cross-Account IAM Role Module
A module for assuming roles across AWS accounts — essential for multi-account architectures where a central tooling account deploys into workload accounts.
# modules/cross-account-role/main.tf
data "aws_iam_policy_document" "assume_role" {
statement {
effect = "Allow"
principals {
type = "AWS"
identifiers = var.trusted_account_arns
}
actions = ["sts:AssumeRole"]
dynamic "condition" {
for_each = var.require_mfa ? [1] : []
content {
test = "Bool"
variable = "aws:MultiFactorAuthPresent"
values = ["true"]
}
}
dynamic "condition" {
for_each = var.external_id != null ? [1] : []
content {
test = "StringEquals"
variable = "sts:ExternalId"
values = [var.external_id]
}
}
}
}
resource "aws_iam_role" "main" {
name = var.role_name
description = var.description
assume_role_policy = data.aws_iam_policy_document.assume_role.json
max_session_duration = var.max_session_duration
tags = var.common_tags
}
resource "aws_iam_role_policy_attachment" "main" {
for_each = toset(var.managed_policy_arns)
role = aws_iam_role.main.name
policy_arn = each.value
}
resource "aws_iam_role_policy" "inline" {
for_each = var.inline_policies
name = each.key
role = aws_iam_role.main.id
policy = each.value
}
output "role_arn" {
description = "ARN of the cross-account IAM role."
value = aws_iam_role.main.arn
}
Example 10: Complete Application Stack (Composing Modules)
The real power of modules is composition. Here's a root module that wires together multiple child modules to create a complete application environment in under 60 lines:
# environments/production/main.tf
locals {
common_tags = {
Project = var.project_name
Environment = "production"
ManagedBy = "terraform"
Team = var.team_name
}
}
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
az_count = 3
environment = "production"
project_name = var.project_name
common_tags = local.common_tags
}
module "eks" {
source = "../../modules/eks"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
environment = "production"
project_name = var.project_name
kubernetes_version = "1.29"
node_instance_types = ["m5.xlarge"]
node_min_size = 3
node_max_size = 20
node_desired_size = 5
common_tags = local.common_tags
}
module "rds" {
source = "../../modules/rds-postgres"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
environment = "production"
project_name = var.project_name
instance_class = "db.r6g.xlarge"
master_username = "appuser"
master_password = var.db_password
common_tags = local.common_tags
}
module "redis" {
source = "../../modules/elasticache-redis"
vpc_id = module.vpc.vpc_id
private_subnet_ids = module.vpc.private_subnet_ids
environment = "production"
project_name = var.project_name
node_type = "cache.r6g.large"
common_tags = local.common_tags
}
module "alb" {
source = "../../modules/alb"
vpc_id = module.vpc.vpc_id
public_subnet_ids = module.vpc.public_subnet_ids
private_subnet_ids = module.vpc.private_subnet_ids
environment = "production"
project_name = var.project_name
acm_certificate_arn = var.acm_certificate_arn
common_tags = local.common_tags
}
This is 55 lines that provisions a full production environment: multi-AZ VPC, EKS cluster with managed nodes, Multi-AZ RDS PostgreSQL, Redis replication group, and an ALB with HTTPS. Each module is independently versioned, testable, and reusable across environments.
Module Best Practices: What Separates Good from Great
Keep modules focused. A module should do one thing well. modules/vpc creates a VPC. modules/eks creates a cluster. modules/app-stack composes them. Don't build monolith modules — they become impossible to reuse.
Version your modules. Tag releases in Git. Reference specific versions in callers. This makes upgrades deliberate, not accidental.
Use for_each over count for named resources. count produces index-based state keys (aws_subnet.public[0]); for_each produces name-based keys (aws_subnet.public["us-east-1a"]). Removing a middle item with count shifts all subsequent indexes and triggers unexpected recreations.
# Prefer this
resource "aws_subnet" "private" {
for_each = toset(local.azs)
availability_zone = each.key
# ...
}
# Over this
resource "aws_subnet" "private" {
count = length(local.azs)
availability_zone = local.azs[count.index]
# ...
}
Pass common_tags as a variable, not inline. Every module should accept a map(string) variable for tags. This ensures consistent tagging without duplicating logic in every resource block.
Generate documentation automatically. Use terraform-docs to keep README files current:
terraform-docs markdown . > README.md
Wire this into pre-commit hooks so documentation never drifts from the code.
Test modules with Terratest. Module tests catch breaking changes before they reach environments:
func TestVPCModule(t *testing.T) {
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"cidr_block": "10.99.0.0/16",
"environment": "test",
"project_name": "test-project",
"az_count": 2,
},
}
defer terraform.Destroy(t, terraformOptions)
terraform.InitAndApply(t, terraformOptions)
vpcID := terraform.Output(t, terraformOptions, "vpc_id")
assert.NotEmpty(t, vpcID)
}
CloudOps AI: Pre-Built Module Library
Writing all ten of these modules from scratch takes an experienced engineer several days — and that's before testing, documentation, and environment-specific tuning.
CloudOps AI's module library ships production-ready versions of all of them, pre-tested, pre-documented, and configurable through a visual interface. Select your infrastructure pattern, configure your variables, and export clean Terraform organized into the exact module structure described above.
The library includes:
All networking patterns (VPC, Transit Gateway, VPN, Direct Connect)
Compute (EC2 ASG, EKS, ECS Fargate, Lambda)
Data stores (RDS, Aurora, ElastiCache, DynamoDB, OpenSearch)
Security (IAM roles, KMS keys, Security Hub, GuardDuty baselines)
Observability (CloudWatch dashboards, alarms, log aggregation)
Every module is updated when AWS releases new resource types or the Terraform AWS provider ships breaking changes — so you're never debugging deprecated arguments at 2am.
Ready to optimise your cloud operations?
CloudOps AI gives your team AI-powered architecture, FinOps, and DevSecOps in one platform.
Start for free →Frequently Asked Questions
What is the difference between a Terraform module and a root module?
Every Terraform configuration is technically a module. The "root module" is the directory where you run terraform apply. A child module is any module called from another using a module block. The root module orchestrates; child modules encapsulate.
Should I use community modules from the Terraform Registry or write my own?
Both, strategically. The official AWS modules (terraform-aws-modules) are well-maintained and production-tested — use them for common patterns like EKS, VPC, and RDS rather than reinventing them. Write your own modules for organization-specific patterns, internal standards, and cross-cutting concerns like tagging and naming conventions.
How do I share modules across multiple teams in my organization?
Use a private Terraform Registry (available in Terraform Cloud/Enterprise) or a dedicated Git repository with tagged releases. Define a versioning contract (semver), document breaking changes in a CHANGELOG, and use Renovate or Dependabot to alert teams when new module versions are available.
What is the recommended directory structure for Terraform modules?
modules/ at the repository root for shared modules, with each module in its own subdirectory. Environments in environments/ or per-environment directories, each calling modules rather than containing resource blocks directly. Keep root modules thin — they should compose modules, not define resources.
Can Terraform modules have optional variables?
Yes. Set a default value to make a variable optional. For optional complex types (objects, lists), use default = null and conditionally use the variable with var.x != null checks in resource arguments or dynamic blocks.
How do I upgrade a Terraform module version without breaking infrastructure?
Always read the module's CHANGELOG before upgrading. Test version bumps in a non-production environment first using terraform plan to identify changes. For modules using for_each, pay attention to key changes that could trigger resource recreation. Pin to a new version in a separate PR and review the plan output before merging.
Written by
Abhay SinghCloud Architect
Cloud Architect and DevOps specialist with 10+ years of experience in AWS and Azure.
More articles by Abhay Singh →