Herbruikbare Terraform-modules ontwerpen: structuur, I/O en register
Wanneer u met Terraform aan echte projecten gaat werken, komt er een crunch-tijd bij 500-600 regels HCL: de configuratie begint moeilijk leesbaar te worden, dezelfde patronen worden herhaald voor verschillende omgevingen, en refactoring wordt een operatie riskant. De oplossing is ik Terraform-modules: herbruikbare abstractie-eenheden die de complexiteit inkapselen en een schone interface blootleggen.
Deze gids behandelt het ontwerpen van professionele formulieren: niet alleen hoe u ze maakt, maar hoe denken naar de openbare interface, achterwaartse compatibiliteit beheren, testen en publiceren naar het Terraform-register. Een slecht ontworpen module en erger dan geen module: het creëert starre afhankelijkheden, maakt upgrades moeilijk en verbergt de complexiteit in plaats van deze te beheren.
Wat je gaat leren
- Canonieke structuur van een professionele Terraform-module
- Variabel ontwerp: typen, validaties, standaardwaarden en complexe objecten
- Gestandaardiseerde uitvoer: wat moet worden weergegeven en met welke naamgevingsconventie
- Onderliggende modules en samengestelde modules (samenstelling versus overerving)
- Semantisch versiebeheer en achterwaarts compatibiliteitsbeheer
- Publicatie in het openbare register van Terraform en in een privéregister
- Geavanceerde patronen: generieke vormen, dynamisch for_each, voorwaardelijke vormen
Canonieke structuur van een module
De structuur van een Terraform-module volgt goed gedefinieerde conventies die door het register en de gemeenschap erkennen. Afwijken van deze conventies schept verwarring bij consumenten van de vorm.
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
De naamgevingsconventie terraform-{provider}-{name} en verplicht voor
publicatie op het openbare Terraform-register en aanbevolen voor particuliere registers.
Echte voorbeelden: terraform-aws-eks, terraform-google-kubernetes-engine,
terraform-azurerm-network.
Variabel ontwerp: de publieke interface
De variabelen van een module vormen de publieke API. Goed variabel ontwerp volgt de principe van minimale verbazing: een verbruiker van de module zou moeten kunnen begrijpen wat hij moet doen door alleen de namen en beschrijvingen van de variabelen te lezen, zonder de interne code te lezen.
# 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
}
Interne logica: lokale bevolking en resourceontwerp
Binnen de module, i locals ze zijn het belangrijkste hulpmiddel om vast te houden
de DRY-code. Alle computationele logica moet lokaal aanwezig zijn en niet verspreid
in resourceblokken.
# 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: wat te exposeren en hoe
De outputs van een module zijn net zo belangrijk als de variabelen: zij vormen de interface die bovenliggende modules en andere modules gebruiken om informatie te krijgen over gemaakte bronnen. De gouden regel luidt: alles blootleggen wat een consument nodig heeft, maar niet meer.
# 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
}
}
Een module gebruiken: syntaxis en versiebeheer
Wanneer u een module gebruikt, ondersteunt Terraform verschillende bronnen: lokaal, Git, Terraform Registry. Versiebeheer is essentieel om onverwachte regressies te voorkomen.
# 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
}
Semantische versiebeheer voor modules
Terraform-modules volgen de semantisch versiebeheer (meerdere):
MAJOR.MINOR.PATCH. Het onderscheid tussen brekende verandering en niet-brekende verandering
verandering en kritiek voor degenen die de module consumeren.
# 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
Geavanceerde patronen: algemene modules
De krachtigste modules zijn degene die ze gebruiken for_each dynamisch om te creëren
volledig parametrische configuraties. Dit patroon is gebruikelijk in modules voor
vergelijkbare bronnensets beheren (bijvoorbeeld meerdere subnetten, regels voor meerdere beveiligingsgroepen).
# 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 = []
}
]
}
}
}
Automatische documentatie met terraform-docs
Het handmatig documenteren van de variabelen en outputs van een module is vervelend en vaak achterhaald. terraform-docs genereert automatisch documentatie op basis van 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", "."]
Publiceer naar het Terraform-register
Om een module naar het openbare Terraform-register te publiceren, moet de GitHub-repository dat doen specifieke conventies volgen. Het proces is vrijwel volledig geautomatiseerd 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 aanbevolen voor AWS
Voordat u een module helemaal opnieuw schrijft, moet u controleren of er al een volwassen versie in het register bestaat. De modules van terraform-aws-modules (door Anton Babenko) zijn de de facto standaard:
terraform-aws-modules/vpc/aws- compleet netwerkenterraform-aws-modules/eks/aws— EKS-clusterterraform-aws-modules/rds/aws— RDS met alle parametersterraform-aws-modules/s3-bucket/aws— S3 met encryptie en beleidterraform-aws-modules/security-group/aws— 100+ vooraf geconfigureerde regels
Conclusies en volgende stappen
Het ontwerpen van goede Terraform-modules vereist dezelfde zorg als het ontwerpen van een goede API: denk aan uw consumenten, houd de interface stabiel, documenteer elke parameter. Een goed ontworpen module wordt door het hele team jarenlang zonder wijzigingen gebruikt.
De volgende stap in uw Terraform-volwassenheid is het aanpakken van staatsbeheer in teams: het meest voorkomende en risicovolle probleem bij het opschalen van organisaties Terraform adopteren.
De complete serie: Terraform en IaC
- Artikel 01 - Terraform from Scratch: HCL, Provider en Plan-Apply-Destroy
- Artikel 02 (dit) — Herbruikbare Terraform-modules ontwerpen: structuur, I/O en register
- Artikel 03 — Terraform State: externe backend met S3/GCS, vergrendelen en importeren
- Artikel 04 — Terraform in CI/CD: GitHub-acties, Atlantis en Pull Request Workflow
- Artikel 05 — IaC-testen: Terratest, Terraform Native Test en Contracttesten
- Artikel 06 — IaC-beveiliging: Checkov, Trivy en OPA Policy-as-Code
- Artikel 07 — Terraform Multi-Cloud: AWS + Azure + GCP met gedeelde modules
- Artikel 08 — GitOps voor Terraform: Flux TF-controller, Spacelift en driftdetectie
- Artikel 09 — Terraform versus Pulumi versus OpenTofu: definitieve vergelijking 2026
- Artikel 10 — Terraform Enterprise-patronen: werkruimte, Sentinel en teamschaling







