Skip to content
Back to Blog
Automation

Building Reusable Terraform Modules for Infrastructure

Design and publish reusable Terraform modules with input variables, output values, and version pinning for team-wide infrastructure sharing.

Sep 2025
14 min read

Introduction

Terraform modules are the cornerstone of reusable, maintainable infrastructure-as-code. Without modules, you end up copying and pasting Terraform code across projects, making global changes a nightmare. With well-designed modules, you define infrastructure patterns once and reuse them with different parameters. This guide teaches you to write, publish, and consume Terraform modules professionally.

What Is a Terraform Module?

Any directory containing .tf files is a module. When you write Terraform code without modules, you're using the "root module." When you call a module from your root module using module blocks, you're using a child module.

TEXT
project/
├── main.tf          ← root module
├── variables.tf
├── outputs.tf
└── modules/
    ├── vpc/         ← child module
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── ec2/         ← another child module
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Building Your First Reusable Module

Create a reusable AWS security group module:

HCL
# modules/security-group/variables.tf
variable "name" {
  description = "Name of the security group"
  type        = string
}

variable "vpc_id" {
  description = "VPC ID to create the security group in"
  type        = string
}

variable "ingress_rules" {
  description = "List of ingress rules"
  type = list(object({
    from_port   = number
    to_port     = number
    protocol    = string
    cidr_blocks = list(string)
    description = string
  }))
  default = []
}

variable "egress_allow_all" {
  description = "Whether to allow all outbound traffic"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Tags to apply to the security group"
  type        = map(string)
  default     = {}
}
HCL
# modules/security-group/main.tf
resource "aws_security_group" "this" {
  name        = var.name
  description = "Managed by Terraform module"
  vpc_id      = var.vpc_id

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

resource "aws_security_group_rule" "ingress" {
  for_each = { for i, rule in var.ingress_rules : i => rule }

  type              = "ingress"
  security_group_id = aws_security_group.this.id
  from_port         = each.value.from_port
  to_port           = each.value.to_port
  protocol          = each.value.protocol
  cidr_blocks       = each.value.cidr_blocks
  description       = each.value.description
}

resource "aws_security_group_rule" "egress_all" {
  count = var.egress_allow_all ? 1 : 0

  type              = "egress"
  security_group_id = aws_security_group.this.id
  from_port         = 0
  to_port           = 0
  protocol          = "-1"
  cidr_blocks       = ["0.0.0.0/0"]
}
HCL
# modules/security-group/outputs.tf
output "id" {
  description = "Security group ID"
  value       = aws_security_group.this.id
}

output "arn" {
  description = "Security group ARN"
  value       = aws_security_group.this.arn
}

Using the Module

HCL
# root main.tf
module "web_sg" {
  source = "./modules/security-group"    # Local path

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

  ingress_rules = [
    {
      from_port   = 80
      to_port     = 80
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTP from internet"
    },
    {
      from_port   = 443
      to_port     = 443
      protocol    = "tcp"
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTPS from internet"
    }
  ]

  tags = {
    Environment = "production"
    Tier        = "web"
  }
}

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

  name   = "app-tier-sg"
  vpc_id = module.vpc.vpc_id

  ingress_rules = [
    {
      from_port   = 8080
      to_port     = 8080
      protocol    = "tcp"
      cidr_blocks = [module.vpc.private_subnet_cidrs[0]]
      description = "App traffic from web tier"
    }
  ]
}

# Reference module outputs
resource "aws_lb_target_group" "web" {
  vpc_id = module.vpc.vpc_id
  # ...
}

resource "aws_instance" "web" {
  vpc_security_group_ids = [module.web_sg.id]
  # ...
}

Module Versioning with Git Tags

BASH
# Tag a module version
git tag v1.0.0
git push origin v1.0.0

# Use a specific version in root module
module "security_group" {
  source = "git::https://github.com/company/tf-modules.git//security-group?ref=v1.0.0"
  # ...
}

# Or using SSH
module "security_group" {
  source = "git@github.com:company/tf-modules.git//security-group?ref=v1.2.0"
  # ...
}

Publishing to Terraform Registry

TEXT
# Module naming convention for public registry:
terraform-<PROVIDER>-<NAME>
# Example: terraform-aws-security-group

# Required structure for registry:
terraform-aws-security-group/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md          ← Required, auto-generated docs
├── LICENSE
└── examples/
    └── complete/
        ├── main.tf   ← Working example
        └── README.md

# Publish:
# 1. Push to GitHub as public repo
# 2. Create GitHub release with semver tag (v1.0.0)
# 3. Sign into registry.terraform.io with GitHub
# 4. Click "Publish Module"

Advanced Module Patterns

Conditional Resources:
HCL
# modules/rds/main.tf
variable "create_read_replica" {
  type    = bool
  default = false
}

resource "aws_db_instance" "primary" {
  # main database config...
}

resource "aws_db_instance" "replica" {
  count = var.create_read_replica ? 1 : 0

  replicate_source_db = aws_db_instance.primary.id
  # replica config...
}
Dynamic Blocks:
HCL
variable "subnets" {
  type = list(object({
    name = string
    cidr = string
    az   = string
  }))
}

resource "aws_subnet" "this" {
  for_each = { for s in var.subnets : s.name => s }

  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value.cidr
  availability_zone = each.value.az

  tags = { "Name" = each.value.name }
}
Module Composition:
HCL
# Compose higher-level modules from primitives
module "vpc" {
  source = "./modules/vpc"
  cidr   = "10.0.0.0/16"
}

module "eks_cluster" {
  source     = "./modules/eks"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}

module "rds" {
  source     = "./modules/rds"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.database_subnet_ids

  # Allow EKS to connect to RDS
  allowed_security_group_ids = [module.eks_cluster.node_security_group_id]
}

Testing Modules with Terratest

GO
// test/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()

    opts := &terraform.Options{
        TerraformDir: "../examples/complete",
        Vars: map[string]interface{}{
            "name":   "test-sg",
            "vpc_id": "vpc-12345678",
        },
    }

    // Cleanup after test
    defer terraform.Destroy(t, opts)

    // Apply
    terraform.InitAndApply(t, opts)

    // Validate output
    sgId := terraform.Output(t, opts, "id")
    assert.NotEmpty(t, sgId)
    assert.Regexp(t, "^sg-", sgId)
}

Module Documentation Best Practices

MARKDOWN
# terraform-aws-security-group

Creates AWS Security Groups with configurable ingress/egress rules.

## Usage
hcl

module "security_group" {

source = "company/security-group/aws"

version = "~> 1.0"

name = "web-sg"

vpc_id = "vpc-12345"

ingress_rules = [

{

from_port = 443

to_port = 443

protocol = "tcp"

cidr_blocks = ["0.0.0.0/0"]

description = "HTTPS"

}

]

}

TEXT

## Requirements

| Name | Version |
|------|---------|
| terraform | >= 1.0 |
| aws | ~> 5.0 |

## Inputs

| Name | Description | Type | Required |
|------|------------|------|----------|
| name | Security group name | `string` | yes |
| vpc_id | VPC ID | `string` | yes |
| ingress_rules | Ingress rules | `list(object)` | no |

## Outputs

| Name | Description |
|------|------------|
| id | Security group ID |
| arn | Security group ARN |

Reusable modules are the key to maintaining infrastructure at scale. Start with modules for your most-duplicated patterns, version them strictly, and always write examples that serve as both documentation and integration tests.