--- title: "Módulos de Terraform: estructura, versiones y pruebas" description: "Crea un módulo de Terraform reutilizable con variables validadas y salidas, úsalo con for_each, versiónalo con etiquetas Git y pruébalo con terraform test." category: "Automatización e IaC" language: es source: https://cubepath.com/es/docs/automatización-e-iac/modulos-de-terraform-y-mejores-practicas updated: 2026-09-25 --- # Módulos de Terraform: estructura, versiones y pruebas Un módulo de Terraform es un directorio con archivos `.tf` que se usa como una pieza reutilizable: recibe variables, crea recursos y devuelve salidas. Bien diseñados, los módulos evitan copiar y pegar configuración entre entornos y proyectos. En este tutorial crearás en Ubuntu 24.04 un módulo que despliega un servicio web en un contenedor Docker, lo usarás varias veces desde una configuración raíz, lo probarás con `terraform test` y lo versionarás con etiquetas Git. Se usa el proveedor de Docker para que puedas aplicarlo todo en tu propio servidor, pero la estructura es la misma para cualquier proveedor de nube. ## Requisitos previos Para seguir esta guía necesitas: - Un servidor o máquina con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, y un usuario no root con privilegios `sudo`. - Docker Engine instalado y tu usuario en el grupo `docker` (consulta la guía de instalación de Docker en Ubuntu 24.04). Compruébalo con `docker ps` sin `sudo`. - Git instalado (`sudo apt install git`). - Conocimientos básicos de Terraform: `init`, `plan` y `apply`. ## Paso 1: Instalar Terraform Instala Terraform desde el repositorio oficial de HashiCorp. Primero añade su clave de firma: ```bash sudo apt update sudo apt install gnupg curl sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp-archive-keyring.gpg ``` Añade el repositorio y instala el paquete: ```bash echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(. /etc/os-release && echo "$VERSION_CODENAME") main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt update sudo apt install terraform ``` Comprueba la versión: ```bash terraform -version ``` ```output Terraform v1.13.3 on linux_amd64 ``` Necesitas la versión 1.6 o posterior para `terraform test`. Todo lo de esta guía funciona igual con OpenTofu cambiando `terraform` por `tofu`. ## Paso 2: Crear la estructura del proyecto Un proyecto con módulos tiene una **configuración raíz**, donde ejecutas Terraform y configuras los proveedores, y uno o varios **módulos hijos** en su propio directorio. Crea esta estructura: ```bash mkdir -p ~/tf-demo/modules/web_service/tests cd ~/tf-demo touch main.tf versions.tf outputs.tf touch modules/web_service/{main.tf,variables.tf,outputs.tf,versions.tf,README.md} ``` ```text tf-demo/ ├── main.tf # llamadas a módulos ├── versions.tf # versión de Terraform y proveedores, configuración del proveedor ├── outputs.tf └── modules/ └── web_service/ ├── main.tf # recursos ├── variables.tf # entradas ├── outputs.tf # salidas ├── versions.tf # requisitos del módulo ├── README.md └── tests/ ``` Separar entradas, recursos y salidas en archivos con estos nombres es la convención estándar: cualquiera que abra el módulo sabe dónde mirar. ## Paso 3: Declarar los requisitos del módulo El módulo indica qué versión de Terraform y qué proveedores necesita, pero **no** configura el proveedor: eso es responsabilidad de la configuración raíz. Así el mismo módulo funciona contra distintos hosts, cuentas o regiones. ```bash nano modules/web_service/versions.tf ``` ```hcl terraform { required_version = ">= 1.6" required_providers { docker = { source = "kreuzwerker/docker" version = "~> 3.0" } } } ``` `~> 3.0` acepta cualquier 3.x pero no una 4.0, que podría traer cambios incompatibles. ## Paso 4: Definir las variables de entrada Las variables son la interfaz pública del módulo. Dales siempre `type` y `description`, valores por defecto solo cuando haya uno razonable, y bloques `validation` para rechazar valores incorrectos en el `plan` en lugar de en mitad del `apply`: ```bash nano modules/web_service/variables.tf ``` ```hcl variable "name" { type = string description = "Nombre del servicio. Se usa como nombre del contenedor." validation { condition = can(regex("^[a-z][a-z0-9-]{1,30}$", var.name)) error_message = "name debe empezar por una letra y contener solo minúsculas, números y guiones (máximo 31 caracteres)." } } variable "image" { type = string description = "Imagen Docker con etiqueta explícita, por ejemplo nginx:1.27-alpine." validation { condition = !endswith(var.image, ":latest") && strcontains(var.image, ":") error_message = "image debe llevar una etiqueta explícita distinta de latest." } } variable "external_port" { type = number description = "Puerto del host en el que se publica el servicio." validation { condition = var.external_port >= 1024 && var.external_port <= 65535 error_message = "external_port debe estar entre 1024 y 65535." } } variable "internal_port" { type = number description = "Puerto en el que escucha la aplicación dentro del contenedor." default = 80 } variable "env" { type = map(string) description = "Variables de entorno del contenedor." default = {} } ``` Fíjate en `env`: recibe un mapa, más cómodo para quien usa el módulo, y el módulo lo convierte al formato que necesita el proveedor. Ocultar ese tipo de detalle es precisamente para lo que sirve un módulo. ## Paso 5: Escribir los recursos y las salidas ```bash nano modules/web_service/main.tf ``` ```hcl resource "docker_image" "this" { name = var.image keep_locally = true } resource "docker_container" "this" { name = var.name image = docker_image.this.image_id restart = "unless-stopped" env = [for key, value in var.env : "${key}=${value}"] ports { internal = var.internal_port external = var.external_port } } ``` Usar `this` como nombre local cuando el módulo crea un solo recurso de cada tipo es una convención habitual: el nombre significativo ya lo da el módulo (`module.web["api"]`). Las salidas exponen lo que otros módulos o la configuración raíz necesitan, sin obligarles a conocer los recursos internos: ```bash nano modules/web_service/outputs.tf ``` ```hcl output "container_name" { description = "Nombre del contenedor creado." value = docker_container.this.name } output "url" { description = "URL local del servicio." value = "http://localhost:${var.external_port}" } ``` Documenta en `modules/web_service/README.md` para qué sirve el módulo, sus variables, sus salidas y un ejemplo de uso. ## Paso 6: Usar el módulo desde la configuración raíz En la raíz sí se configura el proveedor. Sin argumentos, el proveedor de Docker se conecta al socket local `/var/run/docker.sock`: ```bash nano versions.tf ``` ```hcl terraform { required_version = ">= 1.6" required_providers { docker = { source = "kreuzwerker/docker" version = "~> 3.0" } } } provider "docker" {} ``` Llama al módulo con `for_each` para crear varios servicios con la misma definición, cambiando solo los datos: ```bash nano main.tf ``` ```hcl locals { services = { web = { image = "nginx:1.27-alpine" external_port = 8081 env = {} } docs = { image = "nginx:1.27-alpine" external_port = 8082 env = { NGINX_ENTRYPOINT_QUIET_LOGS = "1" } } } } module "web" { source = "./modules/web_service" for_each = local.services name = each.key image = each.value.image external_port = each.value.external_port env = each.value.env } ``` Reexpón las salidas del módulo en la raíz: ```bash nano outputs.tf ``` ```hcl output "urls" { description = "URL de cada servicio." value = { for name, svc in module.web : name => svc.url } } ``` Inicializa el proyecto, que descarga el proveedor e instala el módulo local: ```bash terraform init ``` ```output Initializing modules... - web in modules/web_service Initializing provider plugins... - Finding kreuzwerker/docker versions matching "~> 3.0"... - Installing kreuzwerker/docker v3.6.2... ... Terraform has been successfully initialized! ``` Revisa el plan y aplícalo: ```bash terraform plan terraform apply ``` Escribe `yes` para confirmar: ```output Apply complete! Resources: 4 added, 0 changed, 0 destroyed. Outputs: urls = { "docs" = "http://localhost:8082" "web" = "http://localhost:8081" } ``` Comprueba que los servicios responden: ```bash curl -sI http://localhost:8081 | head -1 curl -sI http://localhost:8082 | head -1 ``` ```output HTTP/1.1 200 OK HTTP/1.1 200 OK ``` Añadir un tercer servicio es ahora una entrada más en `local.services`, sin tocar el módulo. ## Paso 7: Probar la validación Comprueba que las validaciones protegen el módulo. Cambia temporalmente en `main.tf` el puerto de `docs` a `80` y ejecuta: ```bash terraform plan ``` ```output │ Error: Invalid value for variable │ │ on main.tf line 22, in module "web": │ 22: external_port = each.value.external_port │ │ external_port debe estar entre 1024 y 65535. ``` El error aparece antes de tocar ninguna infraestructura. Vuelve a poner `8082`. ## Paso 8: Escribir pruebas con terraform test `terraform test` ejecuta archivos `.tftest.hcl` que planifican o aplican el módulo con variables de prueba y comprueban condiciones. Cada archivo se ejecuta contra el módulo del directorio en el que lanzas el comando. Crea las pruebas del módulo: ```bash nano modules/web_service/tests/web_service.tftest.hcl ``` ```hcl variables { name = "tftest" image = "nginx:1.27-alpine" external_port = 8090 } run "usa_el_puerto_indicado" { command = plan assert { condition = docker_container.this.ports[0].external == 8090 error_message = "El puerto externo del contenedor no coincide con external_port." } } run "rechaza_puertos_privilegiados" { command = plan variables { external_port = 80 } expect_failures = [var.external_port] } run "rechaza_latest" { command = plan variables { image = "nginx:latest" } expect_failures = [var.image] } run "crea_el_contenedor" { command = apply assert { condition = output.url == "http://localhost:8090" error_message = "La salida url no es la esperada." } } ``` Las tres primeras pruebas solo planifican y son rápidas. La última crea el contenedor de verdad y Terraform lo destruye al terminar. Ejecuta las pruebas desde el directorio del módulo: ```bash cd ~/tf-demo/modules/web_service terraform init terraform test ``` ```output tests/web_service.tftest.hcl... in progress run "usa_el_puerto_indicado"... pass run "rechaza_puertos_privilegiados"... pass run "rechaza_latest"... pass run "crea_el_contenedor"... pass tests/web_service.tftest.hcl... tearing down tests/web_service.tftest.hcl... pass Success! 4 passed, 0 failed. ``` Antes de cada cambio, pasa también el formateador y el validador. `-check` hace que `fmt` falle si algún archivo no está formateado, lo que es útil en CI: ```bash cd ~/tf-demo terraform fmt -check -recursive terraform validate ``` ```output Success! The configuration is valid. ``` ## Paso 9: Versionar y publicar el módulo Un módulo que usan varios proyectos debe tener versiones, para que un cambio no se aplique a todos a la vez. La forma más simple es un repositorio Git propio con etiquetas semánticas: sube de versión mayor (`v2.0.0`) cuando cambies o elimines una variable, menor (`v1.1.0`) cuando añadas una opcional y de parche (`v1.0.1`) para correcciones. Mueve el módulo a su propio repositorio y etiqueta la primera versión: ```bash cp -r ~/tf-demo/modules/web_service ~/terraform-docker-web-service cd ~/terraform-docker-web-service git init git add . git commit -m "Initial version of web_service module" git tag v1.0.0 ``` Sube el repositorio a tu servidor Git y consume el módulo por etiqueta desde cualquier proyecto: ```hcl module "web" { source = "git::https://github.com/your_org/terraform-docker-web-service.git?ref=v1.0.0" # ... } ``` Si publicas en el Terraform Registry público o en un registro privado, el repositorio debe llamarse `terraform--` (como el de este ejemplo) y las versiones se toman de las etiquetas. En ese caso se usa el argumento `version` con restricciones: ```hcl module "web" { source = "your_org/web-service/docker" version = "~> 1.0" # ... } ``` Tras cambiar `source` o `version`, ejecuta `terraform init -upgrade` para descargar la nueva versión. ## Paso 10: Refactorizar sin destruir recursos Cuando renombras un recurso dentro de un módulo, o mueves recursos de la raíz a un módulo, Terraform interpreta que el antiguo desaparece y planifica destruirlo y crear otro. El bloque `moved` le indica que es el mismo objeto. Por ejemplo, si renombras `docker_container.this` a `docker_container.app` dentro del módulo (y actualizas las referencias en `outputs.tf`), añade en `modules/web_service/main.tf`: ```hcl moved { from = docker_container.this to = docker_container.app } ``` El siguiente `terraform plan` mostrará el cambio de dirección en lugar de una destrucción: ```output # module.web["web"].docker_container.this has moved to module.web["web"].docker_container.app ``` Mantén los bloques `moved` en el módulo al menos durante una versión mayor para que todos sus usuarios pasen por ella. ## Limpieza Para eliminar los contenedores creados en este tutorial: ```bash cd ~/tf-demo terraform destroy ``` ## Conclusión Has creado un módulo de Terraform con requisitos de versión, variables tipadas y validadas y salidas documentadas, lo has reutilizado con `for_each`, lo has probado con `terraform test` y lo has versionado con etiquetas Git. Como siguientes pasos, ejecuta `terraform fmt -check`, `terraform validate` y `terraform test` en tu pipeline de CI, guarda el estado de la configuración raíz en un backend remoto con bloqueo y aplica esta misma estructura a los módulos de tu proveedor de nube.