Terraform & IaC Automation12 min read

Terraform Modules: Complete Guide with 10 Real-World Examples

Learn how to structure Terraform modules with 10 production-ready examples for VPC, EKS, RDS, IAM, and more. Build scalable reusable IaC faster.

AAbhay Singh· Cloud Architect
#terraform modules#terraform module examples#reusable terraform modules#terraform best practices#infrastructure as code#aws terraform modules#devops automation#cloudops ai#terraform architecture#IaC modules

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.

A

Written by

Abhay Singh

Cloud Architect

Cloud Architect and DevOps specialist with 10+ years of experience in AWS and Azure.

More articles by Abhay Singh