Salta al contenuto principale

02 - Progettare Moduli Terraform Riusabili: Struttura, I/O e Registry

Progettare Moduli Terraform Riusabili: Struttura, I/O e Registry Quando inizi a lavorare con Terraform su progetti reali, il momento critico arriva...

Cosa ne pensi di questo articolo?

02 - Progettare Moduli Terraform Riusabili: Struttura, I/O e Registry
Terraform IaCFoto:Brett SaylesPexels

In Breve

Progettare Moduli Terraform Riusabili: Struttura, I/O e Registry Quando inizi a lavorare con Terraform su progetti reali, il momento critico arriva...

Progettare Moduli Terraform Riusabili: Struttura, I/O e Registry

Quando inizi a lavorare con Terraform su progetti reali, il momento critico arriva intorno alle 500-600 righe di HCL: la configurazione comincia a diventare difficile da leggere, gli stessi pattern si ripetono per ambienti diversi, e fare un refactoring diventa un'operazione rischiosa. La soluzione e i moduli Terraform: unita di astrazione riusabili che incapsulano complessita e espongono un'interfaccia pulita.

Questa guida copre la progettazione di moduli professionali: non solo come crearli, ma come pensare all'interfaccia pubblica, gestire la retrocompatibilita, testare e pubblicare sul Terraform Registry. Un modulo mal progettato e peggio di nessun modulo: crea dipendenze rigide, rende difficili gli upgrade e nasconde complessita invece di gestirla.

Cosa Imparerai

  • Struttura canonica di un modulo Terraform professionale
  • Design delle variabili: tipi, validazioni, valori di default e oggetti complessi
  • Output standardizzati: cosa esporre e con quale naming convention
  • Moduli child e moduli composti (composizione vs ereditarieta)
  • Versioning semantico e gestione della retrocompatibilita
  • Pubblicazione sul Terraform Public Registry e su registry privato
  • Pattern avanzati: moduli generici, for_each dinamico, moduli condizionali

Struttura Canonica di un Modulo

La struttura di un modulo Terraform segue convenzioni ben definite che il Registry e la community riconoscono. Deviare da queste convenzioni crea confusione nei consumatori del modulo.

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

Il naming convention terraform-{provider}-{name} e obbligatorio per la pubblicazione sul Terraform Registry pubblico e raccomandata per i registry privati. Esempi reali: terraform-aws-eks, terraform-google-kubernetes-engine, terraform-azurerm-network.

Design delle Variabili: l'Interfaccia Pubblica

Le variabili di un modulo sono la sua API pubblica. Un buon design delle variabili segue il principio del minimo astonishment: un consumatore del modulo dovrebbe poter capire cosa fare leggendo solo i nomi e le descrizioni delle variabili, senza leggere il codice interno.

# 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
}

Logica Interna: locals e Resource Design

Dentro il modulo, i locals sono lo strumento principale per tenere il codice DRY. Tutta la logica computazionale deve stare nei locals, non dispersa nei 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: Cosa Esporre e Come

Gli output di un modulo sono altrettanto importanti delle variabili: sono l'interfaccia che i moduli parent e gli altri moduli usano per ottenere le informazioni sulle risorse create. La regola d'oro e: esponi tutto quello che un consumatore potrebbe aver bisogno, ma non di piu.

# 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
  }
}

Usare un Modulo: Sintassi e Versioning

Quando usi un modulo, Terraform supporta diverse sorgenti: locale, Git, Terraform Registry. La gestione delle versioni e fondamentale per evitare regressions impreviste.

# 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
}

Versioning Semantico per i Moduli

I moduli Terraform seguono il semantic versioning (semver): MAJOR.MINOR.PATCH. La distinzione tra breaking change e non-breaking change e critica per chi consuma il modulo.

# 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

Pattern Avanzati: Moduli Generici

I moduli piu potenti sono quelli che usano for_each dinamico per creare configurazioni completamente parametriche. Questo pattern e comune nei moduli per gestire set di risorse simili (es. 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        = []
        }
      ]
    }
  }
}

Documentazione Automatica con terraform-docs

Documentare manualmente le variabili e gli output di un modulo e tedioso e spesso obsoleto. terraform-docs genera automaticamente la documentazione dal codice HCL.

# 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", "."]

Pubblicare sul Terraform Registry

Per pubblicare un modulo sul Terraform Registry pubblico, il repository GitHub deve seguire convenzioni specifiche. Il processo e quasi completamente automatizzato 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"
}

Moduli Open Source Raccomandate per AWS

Prima di scrivere un modulo da zero, verifica se esiste gia una versione matura nel Registry. I moduli di terraform-aws-modules (di Anton Babenko) sono lo standard de facto:

  • terraform-aws-modules/vpc/aws - networking completo
  • terraform-aws-modules/eks/aws - EKS cluster
  • terraform-aws-modules/rds/aws - RDS con tutti i parametri
  • terraform-aws-modules/s3-bucket/aws - S3 con encryption e policies
  • terraform-aws-modules/security-group/aws - 100+ regole preconfigurate

Conclusioni e Prossimi Passi

Progettare buoni moduli Terraform richiede la stessa cura di progettare una buona API: pensa ai tuoi consumatori, mantieni l'interfaccia stabile, documenta ogni parametro. Un modulo ben progettato viene usato da tutto il team per anni senza modifiche.

Il passo successivo nella tua maturita Terraform e affrontare la gestione dello state in team: il problema piu frequente e il piu rischioso nelle organizzazioni che scalano l'adozione di Terraform.

La Serie Completa: Terraform e IaC

  • Articolo 01 - Terraform da Zero: HCL, Provider e Plan-Apply-Destroy
  • Articolo 02 (questo) - Progettare Moduli Terraform Riusabili: Struttura, I/O e Registry
  • Articolo 03 - Terraform State: Backend Remoto con S3/GCS, Locking e Import
  • Articolo 04 - Terraform in CI/CD: GitHub Actions, Atlantis e Pull Request Workflow
  • Articolo 05 - Testing IaC: Terratest, Terraform Test Nativo e Contract Testing
  • Articolo 06 - Sicurezza IaC: Checkov, Trivy e OPA Policy-as-Code
  • Articolo 07 - Terraform Multi-Cloud: AWS + Azure + GCP con Moduli Condivisi
  • Articolo 08 - GitOps per Terraform: Flux TF Controller, Spacelift e Drift Detection
  • Articolo 09 - Terraform vs Pulumi vs OpenTofu: Confronto Definitivo 2026
  • Articolo 10 - Terraform Enterprise Patterns: Workspace, Sentinel e Team Scaling

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti