41 pages ยท 8 sections
Ctrl K
GitHub Portfolio

Terraform Modules

Well-designed Terraform modules enable code reuse, enforce standards, and accelerate infrastructure delivery. This guide covers module design patterns and a complete module example based on modules authored for Samsung's infrastructure platform.

Module Structure and Best Practices

A well-structured module follows a clear file organization pattern and adheres to design principles that ensure maintainability and composability:

Standard Module Layout

terraform-aws-security-group/
โ”œโ”€โ”€ main.tf              # Core resource definitions
โ”œโ”€โ”€ variables.tf         # All input variables
โ”œโ”€โ”€ outputs.tf           # All outputs
โ”œโ”€โ”€ versions.tf          # Terraform and provider version constraints
โ”œโ”€โ”€ README.md            # Usage documentation (required)
โ”œโ”€โ”€ CHANGELOG.md         # Version history
โ”œโ”€โ”€ LICENSE
โ”œโ”€โ”€ .terraform-docs.yml  # terraform-docs configuration
โ”œโ”€โ”€ examples/
โ”‚   โ”œโ”€โ”€ complete/        # Comprehensive example
โ”‚   โ”‚   โ”œโ”€โ”€ main.tf
โ”‚   โ”‚   โ”œโ”€โ”€ variables.tf
โ”‚   โ”‚   โ”œโ”€โ”€ outputs.tf
โ”‚   โ”‚   โ””โ”€โ”€ README.md
โ”‚   โ””โ”€โ”€ minimal/         # Minimal viable example
โ”‚       โ”œโ”€โ”€ main.tf
โ”‚       โ””โ”€โ”€ README.md
โ””โ”€โ”€ tests/               # Terratest or kitchen-terraform
    โ””โ”€โ”€ security_group_test.go

Design Principles

PrincipleDescription
Single ResponsibilityEach module manages one logical component (VPC, SG, database)
Sensible DefaultsProvide reasonable defaults so the module works with minimal inputs
ComposabilityModules should work together; avoid hidden dependencies
Input ValidationValidate all inputs with validation blocks
Complete OutputsExpose all resource IDs and attributes consumers might need
DocumentationREADME with usage examples, input/output tables, architecture diagram
Semantic VersioningUse SemVer for releases; document breaking changes

Root Module vs. Child Modules

# Root module (your configuration)
# main.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# Child modules (reusable components)
module "vpc" {
  source  = "app.terraform.io/my-org/vpc/aws"
  version = "~> 2.0"

  name = "production"
  cidr = "10.0.0.0/16"
}

module "security_group" {
  source  = "./modules/security-group"  # Local module
  # Or: source = "github.com/my-org/terraform-aws-sg?ref=v1.2.0"
  # Or: source = "app.terraform.io/my-org/security-group/aws"

  name   = "web-tier"
  vpc_id = module.vpc.vpc_id

  ingress_rules = [
    { protocol = "tcp", from_port = 80,  to_port = 80,  cidr_blocks = ["0.0.0.0/0"] },
    { protocol = "tcp", from_port = 443, to_port = 443, cidr_blocks = ["0.0.0.0/0"] },
  ]
}

module "alb" {
  source  = "terraform-aws-modules/alb/aws"
  version = "~> 9.0"

  name = "web-alb"
  vpc_id = module.vpc.vpc_id
  subnets = module.vpc.public_subnets
  security_groups = [module.security_group.security_group_id]
}

Complete Module Example: AWS Security Group

# modules/security-group/versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

# modules/security-group/variables.tf
variable "name" {
  description = "Name prefix for the security group"
  type        = string

  validation {
    condition     = length(var.name) <= 64
    error_message = "Security group name must be 64 characters or fewer."
  }
}

variable "description" {
  description = "Security group description"
  type        = string
  default     = "Managed by Terraform"
}

variable "vpc_id" {
  description = "VPC ID where the security group will be created"
  type        = string
}

variable "ingress_rules" {
  description = "List of ingress rules"
  type = list(object({
    description      = optional(string, "")
    from_port        = number
    to_port          = number
    protocol         = string
    cidr_blocks      = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups  = optional(list(string), [])
    self             = optional(bool, false)
  }))
  default = []

  validation {
    condition = alltrue([
      for rule in var.ingress_rules :
      rule.from_port >= 0 && rule.from_port <= 65535
    ])
    error_message = "Ingress from_port must be between 0 and 65535."
  }
}

variable "egress_rules" {
  description = "List of egress rules"
  type = list(object({
    description      = optional(string, "")
    from_port        = number
    to_port          = number
    protocol         = string
    cidr_blocks      = optional(list(string), [])
    ipv6_cidr_blocks = optional(list(string), [])
    security_groups  = optional(list(string), [])
    prefix_list_ids  = optional(list(string), [])
    self             = optional(bool, false)
  }))
  default = [
    {
      description = "Allow all outbound"
      from_port   = 0
      to_port     = 0
      protocol    = "-1"
      cidr_blocks = ["0.0.0.0/0"]
    }
  ]
}

variable "tags" {
  description = "Tags to apply to the security group"
  type        = map(string)
  default     = {}
}

variable "revoke_rules_on_delete" {
  description = "Revoke all rules when security group is deleted"
  type        = bool
  default     = false
}

variable "create_timeout" {
  description = "Timeout for creating the security group"
  type        = string
  default     = "10m"
}

# modules/security-group/main.tf
locals {
  # Normalize ingress rules for for_each
  ingress_rules = {
    for idx, rule in var.ingress_rules : "rule-${idx}" => rule
  }

  egress_rules = {
    for idx, rule in var.egress_rules : "rule-${idx}" => rule
  }
}

resource "aws_security_group" "this" {
  name_prefix            = "${var.name}-"
  description            = var.description
  vpc_id                 = var.vpc_id
  revoke_rules_on_delete = var.revoke_rules_on_delete

  tags = merge(var.tags, {
    Name = var.name
  })

  lifecycle {
    create_before_destroy = true
  }

  timeouts {
    create = var.create_timeout
    delete = var.create_timeout
  }
}

resource "aws_vpc_security_group_ingress_rule" "this" {
  for_each = local.ingress_rules

  security_group_id = aws_security_group.this.id
  description       = each.value.description

  from_port   = each.value.from_port
  to_port     = each.value.to_port
  ip_protocol = each.value.protocol

  cidr_ipv4                    = length(each.value.cidr_blocks) > 0 ? each.value.cidr_blocks[0] : null
  cidr_ipv6                    = length(each.value.ipv6_cidr_blocks) > 0 ? each.value.ipv6_cidr_blocks[0] : null
  referenced_security_group_id = length(each.value.security_groups) > 0 ? each.value.security_groups[0] : null

  tags = merge(var.tags, {
    Name = "${var.name}-ingress-${each.key}"
  })
}

resource "aws_vpc_security_group_egress_rule" "this" {
  for_each = local.egress_rules

  security_group_id = aws_security_group.this.id
  description       = each.value.description

  from_port   = each.value.from_port
  to_port     = each.value.to_port
  ip_protocol = each.value.protocol

  cidr_ipv4                    = length(each.value.cidr_blocks) > 0 ? each.value.cidr_blocks[0] : null
  cidr_ipv6                    = length(each.value.ipv6_cidr_blocks) > 0 ? each.value.ipv6_cidr_blocks[0] : null
  referenced_security_group_id = length(each.value.security_groups) > 0 ? each.value.security_groups[0] : null
  prefix_list_id               = length(each.value.prefix_list_ids) > 0 ? each.value.prefix_list_ids[0] : null

  tags = merge(var.tags, {
    Name = "${var.name}-egress-${each.key}"
  })
}

# modules/security-group/outputs.tf
output "security_group_id" {
  description = "ID of the security group"
  value       = aws_security_group.this.id
}

output "security_group_arn" {
  description = "ARN of the security group"
  value       = aws_security_group.this.arn
}

output "security_group_name" {
  description = "Name of the security group"
  value       = aws_security_group.this.name
}

output "security_group_vpc_id" {
  description = "VPC ID of the security group"
  value       = aws_security_group.this.vpc_id
}

output "ingress_rule_ids" {
  description = "IDs of the ingress rules"
  value       = [for rule in aws_vpc_security_group_ingress_rule.this : rule.id]
}

output "egress_rule_ids" {
  description = "IDs of the egress rules"
  value       = [for rule in aws_vpc_security_group_egress_rule.this : rule.id]
}

Using the Module

module "web_security_group" {
  source = "./modules/security-group"

  name        = "web-tier"
  description = "Security group for web tier"
  vpc_id      = module.vpc.vpc_id

  ingress_rules = [
    {
      description = "HTTP from anywhere"
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    },
    {
      description = "HTTPS from anywhere"
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
    },
    {
      description     = "App port from ALB"
      from_port       = 8080
      to_port         = 8080
      protocol        = "tcp"
      security_groups = [module.alb.security_group_id]
    },
  ]

  tags = {
    Environment = "production"
    Team        = "platform"
  }
}

Module Composition Patterns

PatternDescriptionUse Case
Wrapper ModuleWraps a public module with organization-specific defaultsApply corporate tagging, security baselines
Service ModuleCombines multiple modules for a complete service"Web service" = VPC + ALB + ECS + RDS
Environment ModuleInstantiates service modules for a specific environmentProduction environment configuration
Singleton ModuleOne instance per account/regionIAM roles, DNS zones, VPC peering
# Wrapper module example: corporate-vpc
# Enforces tagging, enables flow logs, sets DNS options
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = var.name
  cidr = var.cidr

  azs             = var.azs
  private_subnets = var.private_subnets
  public_subnets  = var.public_subnets

  # Enforce corporate settings
  enable_dns_hostnames = true
  enable_dns_support   = true
  enable_nat_gateway   = true
  single_nat_gateway   = var.environment != "prod"

  # VPC Flow Logs (enforced)
  enable_flow_log                      = true
  create_flow_log_cloudwatch_iam_role  = true
  create_flow_log_cloudwatch_log_group = true
  flow_log_max_aggregation_interval    = 60

  tags = merge(var.required_tags, {
    Environment = var.environment
  })
}

Module Versioning Strategies

Source TypeVersion PinningBest For
Private Registry (TFE)version = "~> 1.2.0"Production use, team collaboration
GitHubref = "v1.2.0" or ref = "main"Development, rapid iteration
S3/HTTPPath versioning /modules/v1.2.0/Air-gapped environments
Local pathGit submodules or monorepoModule development, CI testing
Version Pinning Rule: Never use floating references (e.g., ref = "main" or no version) in production. A module update should be a deliberate, reviewed change. Use exact versions or pessimistic version constraint operators (~> for minor, = for exact).

Module Testing with Terratest

Terratest is a Go library for testing Terraform modules with real infrastructure:

// tests/security_group_test.go
package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestSecurityGroupModule(t *testing.T) {
    t.Parallel()

    terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
        TerraformDir: "../examples/complete",
        Vars: map[string]interface{}{
            "name": "test-sg",
            "ingress_rules": []map[string]interface{}{
                {
                    "description": "HTTP",
                    "from_port":   80,
                    "to_port":     80,
                    "protocol":    "tcp",
                    "cidr_blocks": []string{"0.0.0.0/0"},
                },
            },
        },
    })

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    // Validate outputs
    sgID := terraform.Output(t, terraformOptions, "security_group_id")
    assert.NotEmpty(t, sgID)
    assert.Contains(t, sgID, "sg-")

    sgName := terraform.Output(t, terraformOptions, "security_group_name")
    assert.Equal(t, "test-sg", sgName)
}

// Run: go test -v -timeout 30m ./tests/

Catalog Methodology for Module Discovery

For organizations with 50+ modules, a discovery system is essential. Options include:

ApproachToolingEffort
TFE Module RegistryNative TFE/CloudLow; built-in search and versioning
Backstage.ioSpotify BackstageMedium; requires setup but powerful
GitHub TopicsGitHub search with topic tagsLow; topic:terraform-module org:my-org
Internal WikiConfluence, Notion, or this wikiMedium; manual curation required