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:

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:

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:

terraform -version
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:

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

nano modules/web_service/versions.tf
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:

nano modules/web_service/variables.tf
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

nano modules/web_service/main.tf
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:

nano modules/web_service/outputs.tf
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:

nano versions.tf
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:

nano main.tf
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:

nano outputs.tf
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:

terraform init
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:

terraform plan
terraform apply

Escribe yes para confirmar:

Apply complete! Resources: 4 added, 0 changed, 0 destroyed.

Outputs:

urls = {
  "docs" = "http://localhost:8082"
  "web" = "http://localhost:8081"
}

Comprueba que los servicios responden:

curl -sI http://localhost:8081 | head -1
curl -sI http://localhost:8082 | head -1
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:

terraform plan
│ 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:

nano modules/web_service/tests/web_service.tftest.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:

cd ~/tf-demo/modules/web_service
terraform init
terraform test
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:

cd ~/tf-demo
terraform fmt -check -recursive
terraform validate
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:

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:

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-<PROVEEDOR>-<NOMBRE> (como el de este ejemplo) y las versiones se toman de las etiquetas. En ese caso se usa el argumento version con restricciones:

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:

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:

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

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.