Skip to main content

02 - Designing Reusable Terraform Modules: Structure, I/O and Registry

Designing Reusable Terraform Modules: Structure, Inputs/Outputs and Registry. When working with Terraform on real projects, the critical moment arrives when code duplication emerges. Learn module design patterns, variable scoping, outputs, and publishing to Terraform Registry.

What do you think about this article?

At a Glance

Designing Reusable Terraform Modules: Structure, Inputs/Outputs and Registry. When working with Terraform on real projects, the critical moment arrives when code duplication emerges. Learn module design patterns, variable scoping, outputs, and publishing to Terraform Registry.

Designing Reusable Terraform Modules: Structure, I/O and Registry

When you start working with Terraform on real projects, crunch time comes around at 500-600 lines of HCL: the configuration starts to become difficult to read, the same patterns are repeated for different environments, and refactoring becomes an operation risky. The solution is i Terraform modules: reusable abstraction units that encapsulate complexity and expose a clean interface.

This guide covers designing professional forms: not just how to create them, but how think to the public interface, manage backwards compatibility, test and publish to the Terraform Registry. A poorly designed module and worse than no module: it creates rigid dependencies, makes upgrades difficult, and hides complexity instead of managing it.

What You Will Learn

  • Canonical structure of a professional Terraform module
  • Variable design: types, validations, default values ​​and complex objects
  • Standardized outputs: what to display and with which naming convention
  • Child modules and compound modules (composition vs inheritance)
  • Semantic versioning and backward compatibility management
  • Publication on the Terraform Public Registry and on a private registry
  • Advanced patterns: generic forms, dynamic for_each, conditional forms

Canonical Structure of a Module

The structure of a Terraform module follows well-defined conventions that the Registry and the community recognize. Deviating from these conventions creates confusion for consumers of the form.

terraform-aws-networking/          # Naming: terraform-{provider}-{name}
├── main.tf                        # Logica principale del modulo
├── variables.tf                   # Input variables (interfaccia pubblica)
├── outputs.tf                     # Output values (interfaccia pubblica)
├── versions.tf                    # required_providers e terraform version
├── locals.tf                      # Valori computati interni
├── README.md                      # Documentazione (auto-generabile con terraform-docs)
├── CHANGELOG.md                   # Versioni e breaking changes
├── LICENSE                        # Apache 2.0 per moduli pubblici
├── examples/                      # Esempi di utilizzo del modulo
│   ├── simple/
│   │   ├── main.tf                # Uso minimo del modulo
│   │   └── outputs.tf
│   └── complete/
│       ├── main.tf                # Uso completo con tutti i parametri
│       ├── variables.tf
│       └── outputs.tf
├── modules/                       # Submoduli interni (opzionale)
│   └── subnet/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── test/                          # Test del modulo
    └── networking_test.go         # Terratest

The naming convention terraform-{provider}-{name} and mandatory for publication on the public Terraform Registry and recommended for private registries. Real examples: terraform-aws-eks, terraform-google-kubernetes-engine, terraform-azurerm-network.

Variable Design: the Public Interface

A module's variables are its public API. Good variable design follows the principle of minimum astonishment: a consumer of the module should be able to understand what to do by just reading the names and descriptions of the variables, without reading the internal code.

# variables.tf - design professionale delle variabili

# Variabile richiesta (no default): rappresenta un input fondamentale
variable "vpc_cidr" {
  description = "CIDR block per la VPC. Deve essere un CIDR /16 privato."
  type        = string

  validation {
    condition = can(regex(
      "^(10\\.|172\\.(1[6-9]|2[0-9]|3[0-1])\\.|192\\.168\\.)\\d+\\.\\d+/16$",
      var.vpc_cidr
    ))
    error_message = "Il CIDR deve essere un blocco privato /16 (RFC 1918)."
  }
}

# Variabile con default ragionevole
variable "name" {
  description = "Nome base usato per il prefisso di tutte le risorse."
  type        = string
  default     = "main"

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,28}[a-z0-9]$", var.name))
    error_message = "Il nome deve essere lowercase, alfanumerico con trattini, 3-30 caratteri."
  }
}

# Oggetto complesso: meglio di N variabili separate per configurazioni correlate
variable "nat_gateway_config" {
  description = <<-EOT
    Configurazione del NAT Gateway.
    - enabled: crea il NAT Gateway (aggiunge costo ~$32/mese per AZ)
    - single_az: usa un solo NAT Gateway (risparmio costi per non-prod)
  EOT

  type = object({
    enabled   = bool
    single_az = bool
  })

  default = {
    enabled   = false
    single_az = false
  }
}

# Lista di oggetti: per configurazioni ripetute
variable "private_subnets" {
  description = "Lista di subnet private da creare."
  type = list(object({
    cidr              = string
    availability_zone = string
    tags              = optional(map(string), {})
  }))
  default = []

  validation {
    condition = alltrue([
      for s in var.private_subnets :
      can(cidrhost(s.cidr, 0))
    ])
    error_message = "Ogni subnet deve avere un CIDR valido."
  }
}

# Variabile sensibile: non viene loggata nell'output di plan/apply
variable "database_password" {
  description = "Password per il database. Usa Secrets Manager in produzione."
  type        = string
  sensitive   = true

  validation {
    condition     = length(var.database_password) >= 16
    error_message = "La password deve avere almeno 16 caratteri."
  }
}

# Map per tag: pattern universale in Terraform
variable "tags" {
  description = "Map di tag aggiuntivi da applicare a tutte le risorse."
  type        = map(string)
  default     = {}
}

# Feature flags: booleani che attivano/disattivano funzionalita
variable "enable_flow_logs" {
  description = "Abilita VPC Flow Logs per il network monitoring."
  type        = bool
  default     = false
}

variable "enable_vpc_endpoints" {
  description = "Crea VPC Endpoints per S3 e DynamoDB (riduce costi NAT)."
  type        = bool
  default     = true
}

Internal Logic: locals and Resource Design

Inside the module, i locals they are the main tool for holding the DRY code. All computational logic must be in locals, not dispersed in resource blocks.

# locals.tf - logica interna del modulo

locals {
  # Naming convention centralizzata
  name_prefix = var.name

  # Merging tag: tag del modulo + tag dell'utente
  # I tag dell'utente sovrascrivono i default del modulo
  default_tags = {
    ManagedBy = "Terraform"
    Module    = "terraform-aws-networking"
  }
  merged_tags = merge(local.default_tags, var.tags)

  # Calcola automaticamente le AZ disponibili se non specificate
  azs = length(var.private_subnets) > 0 ? [
    for s in var.private_subnets : s.availability_zone
  ] : []

  # Decisione sul NAT Gateway: uno per AZ o uno singolo
  nat_count = var.nat_gateway_config.enabled ? (
    var.nat_gateway_config.single_az ? 1 : length(local.azs)
  ) : 0

  # Mappa per for_each: key univoca -> oggetto configurazione
  private_subnets_map = {
    for idx, subnet in var.private_subnets :
    "${local.name_prefix}-private-${idx + 1}" => subnet
  }
}

# main.tf - risorse del modulo
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(local.merged_tags, {
    Name = "${local.name_prefix}-vpc"
  })
}

# for_each su mappa: crea una risorsa per ogni elemento
resource "aws_subnet" "private" {
  for_each = local.private_subnets_map

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

  tags = merge(
    local.merged_tags,
    each.value.tags,
    {
      Name = each.key
      Tier = "Private"
    }
  )
}

# Risorsa condizionale: created solo se nat_gateway_config.enabled = true
resource "aws_eip" "nat" {
  count  = local.nat_count
  domain = "vpc"

  tags = merge(local.merged_tags, {
    Name = "${local.name_prefix}-nat-eip-${count.index + 1}"
  })
}

resource "aws_nat_gateway" "this" {
  count = local.nat_count

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  depends_on = [aws_internet_gateway.this]

  tags = merge(local.merged_tags, {
    Name = "${local.name_prefix}-nat-${count.index + 1}"
  })
}

# Flow Logs condizionali
resource "aws_cloudwatch_log_group" "vpc_flow_logs" {
  count = var.enable_flow_logs ? 1 : 0

  name              = "/aws/vpc-flow-logs/${aws_vpc.this.id}"
  retention_in_days = 30

  tags = local.merged_tags
}

resource "aws_flow_log" "this" {
  count = var.enable_flow_logs ? 1 : 0

  vpc_id          = aws_vpc.this.id
  traffic_type    = "ALL"
  iam_role_arn    = aws_iam_role.flow_logs[0].arn
  log_destination = aws_cloudwatch_log_group.vpc_flow_logs[0].arn
}

Output: What to Exhibit and How

The outputs of a module are just as important as the variables: they are the interface that parent modules and other modules use to get information about created resources. The golden rule is: expose everything a consumer might need, but no more.

# outputs.tf - output standardizzati del modulo

# ID della VPC: sempre necessario per altri moduli
output "vpc_id" {
  description = "ID della VPC creata."
  value       = aws_vpc.this.id
}

output "vpc_cidr" {
  description = "CIDR block della VPC."
  value       = aws_vpc.this.cidr_block
}

# Liste di IDs: convenzione piu comune per subnet
output "private_subnet_ids" {
  description = "Lista degli IDs delle subnet private."
  value       = [for s in aws_subnet.private : s.id]
}

# Map key->id: utile quando il consumatore deve referenziare subnet per nome
output "private_subnet_ids_by_name" {
  description = "Map: nome subnet -> ID. Utile per for_each in moduli parent."
  value       = { for k, v in aws_subnet.private : k => v.id }
}

# ARN per policy IAM
output "vpc_arn" {
  description = "ARN della VPC."
  value       = aws_vpc.this.arn
}

# Output condizionale: null se la risorsa non e stata creata
output "nat_gateway_ids" {
  description = "IDs dei NAT Gateway. Lista vuota se nat_gateway_config.enabled = false."
  value       = aws_nat_gateway.this[*].id
}

# Output di un oggetto completo: utile per passare configurazione a moduli figli
output "vpc_config" {
  description = "Configurazione completa della VPC per uso in moduli downstream."
  value = {
    id                  = aws_vpc.this.id
    arn                 = aws_vpc.this.arn
    cidr_block          = aws_vpc.this.cidr_block
    private_subnet_ids  = [for s in aws_subnet.private : s.id]
    nat_gateway_enabled = var.nat_gateway_config.enabled
  }
}

Using a Module: Syntax and Versioning

When you use a module, Terraform supports different sources: local, Git, Terraform Registry. Version management is essential to avoid unexpected regressions.

# Uso del modulo da sorgenti diverse

# 1. Modulo locale (sviluppo e test)
module "networking" {
  source = "./modules/networking"

  vpc_cidr = "10.0.0.0/16"
  name     = "dev"
}

# 2. Dal Terraform Registry pubblico con versione pinned
module "networking" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.1"  # Permette 5.1.x ma non 5.2.0

  name = "dev-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true  # Per dev/staging: risparmio costi

  tags = {
    Environment = "dev"
    ManagedBy   = "Terraform"
  }
}

# 3. Da repository Git (registry privato aziendale)
module "networking" {
  source = "git::https://github.com/myorg/terraform-modules.git//networking?ref=v2.3.1"

  vpc_cidr = "10.0.0.0/16"
  name     = "prod"
  nat_gateway_config = {
    enabled   = true
    single_az = false  # Multi-AZ in produzione
  }
}

# 4. Da Terraform Enterprise o HCP Terraform Registry privato
module "networking" {
  source  = "app.terraform.io/myorg/networking/aws"
  version = "~> 2.0"

  vpc_cidr = "10.0.0.0/16"
}

# Accesso agli output del modulo
resource "aws_eks_cluster" "main" {
  name     = "prod-cluster"
  role_arn = aws_iam_role.eks.arn

  vpc_config {
    # Referenzia l'output del modulo networking
    subnet_ids = module.networking.private_subnet_ids
  }
}

output "vpc_id" {
  value = module.networking.vpc_id
}

Semantic Versioning for Modules

Terraform modules follow the semantic versioning (several): MAJOR.MINOR.PATCH. The distinction between breaking change and non-breaking change and criticism for those who consume the module.

# Cosa costituisce un breaking change (bump MAJOR):
# - Rimozione di una variabile obbligatoria
# - Cambiamento del tipo di una variabile esistente
# - Rimozione di un output
# - Rinomina di un output
# - Cambiamento del nome di una risorsa (causa destroy + recreate)
# - Aggiunta di una variabile obbligatoria senza default

# Non-breaking change (bump MINOR):
# - Aggiunta di una variabile opzionale (con default)
# - Aggiunta di un nuovo output
# - Aggiunta di una nuova funzionalita opzionale (feature flag)

# Patch:
# - Bugfix che non cambia l'interfaccia
# - Aggiornamento di versione di un sub-modulo
# - Miglioramenti alla documentazione

# Convenzioni nei version constraints:
# ~> 2.0      = >= 2.0, < 3.0  (piu usato: permette minor e patch)
# ~> 2.3      = >= 2.3, < 3.0
# ~> 2.3.0    = >= 2.3.0, < 2.4.0 (solo patch)
# >= 2.0, < 3.0  (equivalente a ~> 2.0 ma piu esplicito)

# CHANGELOG.md esempio:
# ## [3.0.0] - 2026-08-01
# ### Breaking Changes
# - Rimossa variabile `legacy_dns_mode` (deprecata dalla v2.5)
# - Output `subnet_id` rinominato in `private_subnet_ids` (lista)
#
# ## [2.5.0] - 2026-07-15
# ### Added
# - Aggiunto supporto VPC Endpoints per S3 e DynamoDB
# - Nuova variabile opzionale `enable_vpc_endpoints` (default: false)
# ### Deprecated
# - Variabile `legacy_dns_mode` sara rimossa nella v3.0

Advanced Patterns: Generic Modules

The most powerful modules are the ones they use for_each dynamic to create fully parametric configurations. This pattern is common in modules for manage similar resource sets (e.g. multiple subnets, multiple security groups rules).

# Pattern: modulo per Security Groups completamente configurabile

# variables.tf del modulo sg
variable "security_groups" {
  description = "Map di security groups da creare."
  type = map(object({
    description = string
    ingress_rules = optional(list(object({
      description      = string
      from_port        = number
      to_port          = number
      protocol         = string
      cidr_blocks      = optional(list(string), [])
      security_group_ids = optional(list(string), [])
    })), [])
    egress_rules = optional(list(object({
      description = string
      from_port   = number
      to_port     = number
      protocol    = string
      cidr_blocks = optional(list(string), ["0.0.0.0/0"])
    })), [])
    tags = optional(map(string), {})
  }))
  default = {}
}

# main.tf del modulo sg
resource "aws_security_group" "this" {
  for_each = var.security_groups

  name        = "${var.name_prefix}-${each.key}"
  description = each.value.description
  vpc_id      = var.vpc_id

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

  lifecycle {
    create_before_destroy = true
  }
}

# Flatten per le ingress rules: ogni SG puo avere N regole
locals {
  ingress_rules = flatten([
    for sg_name, sg_config in var.security_groups : [
      for idx, rule in sg_config.ingress_rules : {
        sg_name  = sg_name
        rule_idx = idx
        rule     = rule
      }
    ]
  ])
}

resource "aws_security_group_rule" "ingress" {
  for_each = {
    for item in local.ingress_rules :
    "${item.sg_name}-ingress-${item.rule_idx}" => item
  }

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

# Uso del modulo sg:
module "security_groups" {
  source = "./modules/security-groups"

  name_prefix = local.name_prefix
  vpc_id      = module.networking.vpc_id

  security_groups = {
    "web" = {
      description = "Security group per istanze web"
      ingress_rules = [
        {
          description = "HTTPS dal pubblico"
          from_port   = 443
          to_port     = 443
          protocol    = "tcp"
          cidr_blocks = ["0.0.0.0/0"]
        },
        {
          description = "HTTP redirect dal pubblico"
          from_port   = 80
          to_port     = 80
          protocol    = "tcp"
          cidr_blocks = ["0.0.0.0/0"]
        }
      ]
    }
    "database" = {
      description = "Security group per RDS"
      ingress_rules = [
        {
          description        = "PostgreSQL solo dal tier web"
          from_port          = 5432
          to_port            = 5432
          protocol           = "tcp"
          security_group_ids = [module.security_groups.ids["web"]]
          cidr_blocks        = []
        }
      ]
    }
  }
}

Automatic documentation with terraform-docs

Manually documenting a module's variables and outputs is tedious and often obsolete. terraform-docs automatically generates documentation from HCL code.

# Installa terraform-docs
brew install terraform-docs   # macOS
# oppure
go install github.com/terraform-docs/terraform-docs@latest

# Genera README.md dal modulo
terraform-docs markdown table . > README.md

# Configurazione in .terraform-docs.yml
# formatter: "markdown table"
# output:
#   file: README.md
#   mode: inject  # Inietta tra i marker nel README esistente
# settings:
#   show-all: true
#   indent: 2

# Con inject mode nel README.md:
# 
# (documentazione auto-generata)
# 

# Aggiorna il README automaticamente con pre-commit
# .pre-commit-config.yaml:
# - repo: https://github.com/terraform-docs/terraform-docs
#   rev: "v0.19.0"
#   hooks:
#     - id: terraform-docs-go
#       args: ["--output-file", "README.md", "--output-mode", "inject", "."]

Publish to the Terraform Registry

To publish a module to the public Terraform Registry, the GitHub repository must follow specific conventions. The process is almost completely automated via GitHub Tags.

# Pre-requisiti per la pubblicazione:
# 1. Repository pubblico su GitHub
# 2. Nome repository: terraform-{provider}-{name}
# 3. Tag semver nel formato: v{MAJOR}.{MINOR}.{PATCH}

# Struttura obbligatoria per il Registry:
# main.tf, variables.tf, outputs.tf nella root
# README.md con documentazione
# Almeno un esempio in examples/

# Processo di release:
git tag -a v1.0.0 -m "Release v1.0.0: versione iniziale"
git push origin v1.0.0
# Il Registry riceve una webhook e indicizza automaticamente il modulo

# Per registry privato con HCP Terraform:
# 1. Vai su app.terraform.io -> Registry -> Publish Module
# 2. Connetti il repository GitHub
# 3. I tag vengono sincronizzati automaticamente

# terraform.tfvars per testing locale del modulo
vpc_cidr = "10.0.0.0/16"
name     = "test"
nat_gateway_config = {
  enabled   = false
  single_az = false
}
tags = {
  Environment = "test"
  Owner       = "platform-team"
}

Open Source Modules Recommended for AWS

Before writing a module from scratch, check whether a mature version already exists in the Registry. The modules of terraform-aws-modules (by Anton Babenko) are the de facto standard:

  • terraform-aws-modules/vpc/aws - complete networking
  • terraform-aws-modules/eks/aws - EKS cluster
  • terraform-aws-modules/rds/aws - RDS with all parameters
  • terraform-aws-modules/s3-bucket/aws - S3 with encryption and policies
  • terraform-aws-modules/security-group/aws - 100+ pre-configured rules

Conclusions and Next Steps

Designing good Terraform modules requires the same care as designing a good API: think about your consumers, keep the interface stable, document every parameter. A well-designed module is used by the entire team for years without changes.

The next step in your Terraform maturity is to address state management in teams: the most frequent and risky problem in scaling organizations adopting Terraform.

The Complete Series: Terraform and IaC

  • Article 01 - Terraform from Scratch: HCL, Provider and Plan-Apply-Destroy
  • Article 02 (this) - Designing Reusable Terraform Modules: Structure, I/O and Registry
  • Article 03 - Terraform State: Remote Backend with S3/GCS, Locking and Import
  • Article 04 - Terraform in CI/CD: GitHub Actions, Atlantis and Pull Request Workflow
  • Article 05 - IaC Testing: Terratest, Terraform Native Test and Contract Testing
  • Article 06 - IaC Security: Checkov, Trivy and OPA Policy-as-Code
  • Article 07 - Terraform Multi-Cloud: AWS + Azure + GCP with Shared Modules
  • Article 08 - GitOps for Terraform: Flux TF Controller, Spacelift and Drift Detection
  • Article 09 - Terraform vs Pulumi vs OpenTofu: Final Comparison 2026
  • Article 10 - Terraform Enterprise Patterns: Workspace, Sentinel, and Team Scaling

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects