Cuando un proyecto de Terraform crece más allá de un único main.tf, necesita tres cosas para seguir siendo manejable: módulos reutilizables, un estado guardado en un lugar más seguro que tu portátil y una forma limpia de ejecutar el mismo código en varios entornos. En este tutorial instalarás Terraform en Ubuntu 24.04, crearás un módulo pequeño que aprovisiona un grupo de contenedores web, guardarás su estado en PostgreSQL con bloqueo y desplegarás los entornos dev, staging y prod por separado con workspaces.

Los ejemplos usan el proveedor de Docker para que todo funcione en un único servidor sin coste adicional. Las técnicas (módulos, count, bloques dinámicos, estado remoto, workspaces y bloques moved) son las mismas que usarás con cualquier proveedor de nube.

Requisitos previos

Para seguir este tutorial necesitas:

  • Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 1 GB de RAM.
  • Un usuario no root con privilegios sudo.
  • Docker Engine instalado desde el repositorio oficial de Docker, con tu usuario en el grupo docker para que Terraform pueda acceder a /var/run/docker.sock sin sudo.
  • Conocimientos básicos de Terraform: proveedores, recursos, plan y apply.

Confirma que tu usuario puede hablar con Docker:

docker info --format '{{.ServerVersion}}'

Si imprime un número de versión, todo está listo. Si obtienes un error de permisos, ejecuta sudo usermod -aG docker $USER, cierra sesión y vuelve a entrar.

Paso 1: Instalar Terraform desde el repositorio de HashiCorp

HashiCorp publica un repositorio APT oficial que mantiene Terraform actualizado junto con el resto de tus paquetes. Instala las herramientas necesarias para añadirlo:

sudo apt update
sudo apt install -y gnupg curl

Descarga la clave de firma de HashiCorp en /etc/apt/keyrings:

sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/hashicorp.gpg

Añade el repositorio para tu versión de Ubuntu (noble en 24.04):

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(. /etc/os-release && echo "$VERSION_CODENAME") main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

Instala Terraform:

sudo apt update
sudo apt install -y terraform

Comprueba la instalación:

terraform -version
Terraform v1.13.3
on linux_amd64

Probablemente tu versión sea más reciente. Cualquier versión a partir de la 1.5 admite todo lo que se usa en esta guía.

Paso 2: Preparar un backend de PostgreSQL para el estado remoto

Por defecto, Terraform escribe terraform.tfstate junto a tu código. Ese archivo relaciona tu configuración con los recursos reales, puede contener secretos y se corrompe si dos personas ejecutan apply a la vez. Un backend remoto con bloqueo resuelve los tres problemas.

Terraform incluye un backend pg que guarda el estado en PostgreSQL y usa bloqueos consultivos (advisory locks) para impedir ejecuciones simultáneas. También admite workspaces, que usarás en el paso 6. Instala PostgreSQL:

sudo apt install -y postgresql

Crea un rol dedicado. Se te pedirá una contraseña; elige una robusta y guárdala para el siguiente comando:

sudo -u postgres createuser --pwprompt terraform

Crea una base de datos cuyo propietario sea ese rol:

sudo -u postgres createdb -O terraform terraform_backend

En lugar de escribir credenciales en el código, el backend pg lee la cadena de conexión de la variable de entorno PG_CONN_STR. Expórtala en tu shell, sustituyendo your_strong_password por la contraseña que acabas de elegir:

export PG_CONN_STR="postgres://terraform:your_strong_password@localhost/terraform_backend?sslmode=disable"

sslmode=disable es aceptable aquí porque la base de datos solo escucha en localhost. Si la base de datos del estado está en otro servidor, usa TLS y sslmode=verify-full.

Comprueba que las credenciales funcionan:

psql "$PG_CONN_STR" -c 'SELECT current_user;'
 current_user
--------------
 terraform
(1 row)

Paso 3: Crear la estructura del proyecto

Crea un directorio de proyecto con una carpeta modules/ para el código reutilizable y un módulo raíz que lo llama:

mkdir -p ~/tf-webstack/modules/web_app
cd ~/tf-webstack

El proyecto terminado tendrá este aspecto:

RutaFunción
versions.tfVersiones de Terraform y del proveedor, configuración del backend
main.tfProveedor, ajustes por entorno y llamada al módulo
outputs.tfValores que se muestran tras apply
modules/web_app/variables.tfEntradas del módulo con validación
modules/web_app/main.tfRed, imagen y contenedores
modules/web_app/outputs.tfSalidas del módulo

Paso 4: Escribir un módulo reutilizable

Un módulo es simplemente un directorio de archivos .tf con entradas (variables) y salidas (outputs). Mantén los módulos centrados en una tarea: este aprovisiona una aplicación web como una red, una imagen y un número configurable de contenedores réplica.

Empieza por las entradas. Los bloques de validación detectan valores incorrectos durante el plan, en lugar de a mitad de un apply:

nano modules/web_app/variables.tf
variable "name" {
  description = "Prefijo para todos los recursos que crea este módulo"
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]+$", var.name))
    error_message = "name solo puede contener letras minúsculas, dígitos y guiones."
  }
}

variable "image" {
  description = "Imagen de contenedor que se ejecuta"
  type        = string
  default     = "nginx:stable-alpine"
}

variable "replicas" {
  description = "Número de contenedores que se ejecutan"
  type        = number
  default     = 1

  validation {
    condition     = var.replicas >= 1 && var.replicas <= 5
    error_message = "replicas debe estar entre 1 y 5."
  }
}

variable "base_port" {
  description = "Puerto del servidor para la primera réplica; cada réplica extra usa el siguiente"
  type        = number
}

variable "labels" {
  description = "Etiquetas de Docker adicionales para todos los contenedores"
  type        = map(string)
  default     = {}
}

Ahora los recursos. Aquí hay dos técnicas que conviene destacar. count crea un contenedor por réplica, y un bloque dynamic genera un bloque labels por cada entrada de un mapa, de modo que quien llama al módulo puede añadir etiquetas sin editarlo:

nano modules/web_app/main.tf
terraform {
  required_providers {
    docker = {
      source = "kreuzwerker/docker"
    }
  }
}

locals {
  labels = merge(var.labels, {
    "managed-by" = "terraform"
    "app"        = var.name
  })
}

resource "docker_network" "this" {
  name = "${var.name}-net"
}

resource "docker_image" "this" {
  name         = var.image
  keep_locally = true
}

resource "docker_container" "replica" {
  count   = var.replicas
  name    = "${var.name}-${count.index + 1}"
  image   = docker_image.this.image_id
  restart = "unless-stopped"

  networks_advanced {
    name = docker_network.this.name
  }

  ports {
    internal = 80
    external = var.base_port + count.index
  }

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }
}

El bloque required_providers dentro del módulo no es opcional: sin él, Terraform supone un proveedor llamado hashicorp/docker, que no existe, y init falla.

Expón lo que necesita quien llama al módulo mediante outputs:

nano modules/web_app/outputs.tf
output "container_names" {
  description = "Nombres de los contenedores réplica"
  value       = docker_container.replica[*].name
}

output "urls" {
  description = "URL local de cada réplica"
  value       = [for i in range(var.replicas) : "http://localhost:${var.base_port + i}"]
}

Paso 5: Conectar el módulo raíz y el backend

El módulo raíz fija las versiones, configura el backend y llama al módulo. Crea versions.tf:

nano versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 3.0"
    }
  }

  backend "pg" {}
}

El bloque vacío backend "pg" {} es intencionado. Terraform toma la cadena de conexión de PG_CONN_STR, así que ninguna credencial acaba en el control de versiones.

A continuación, main.tf. En lugar de copiar la llamada al módulo en un directorio por entorno, mantienes una sola configuración y eliges los ajustes de un mapa indexado por el nombre del workspace actual:

nano main.tf
provider "docker" {
  host = "unix:///var/run/docker.sock"
}

locals {
  environments = {
    dev = {
      replicas  = 1
      base_port = 8080
    }
    staging = {
      replicas  = 2
      base_port = 8180
    }
    prod = {
      replicas  = 3
      base_port = 8280
    }
  }

  env      = terraform.workspace
  settings = local.environments[local.env]
}

module "web" {
  source = "./modules/web_app"

  name      = "web-${local.env}"
  replicas  = local.settings.replicas
  base_port = local.settings.base_port

  labels = {
    environment = local.env
  }
}

Por último, reenvía las salidas del módulo:

nano outputs.tf
output "environment" {
  value = local.env
}

output "urls" {
  value = module.web.urls
}

Inicializa el proyecto. Esto descarga el proveedor de Docker, escribe .terraform.lock.hcl y se conecta a PostgreSQL:

terraform init
Initializing the backend...

Successfully configured the backend "pg"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...
- web in modules/web_app
Initializing provider plugins...
- Finding kreuzwerker/docker versions matching "~> 3.0"...
...
Terraform has been successfully initialized!

Da formato al código y valídalo antes de seguir:

terraform fmt -recursive
terraform validate
Success! The configuration is valid.

Sube .terraform.lock.hcl al control de versiones para que todos usen la misma versión del proveedor. No subas el directorio .terraform/.

Paso 6: Desplegar entornos con workspaces

Cada workspace tiene su propio estado, guardado como una fila independiente en PostgreSQL. Crea los tres entornos:

terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

workspace new también cambia al workspace que crea. Vuelve a dev y revisa el plan, guardándolo en un archivo para que apply ejecute exactamente lo que has revisado:

terraform workspace select dev
terraform plan -out=dev.tfplan
Plan: 3 to add, 0 to change, 0 to destroy.

Aplícalo:

terraform apply dev.tfplan
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Outputs:

environment = "dev"
urls = [
  "http://localhost:8080",
]

Repite con prod, que obtiene tres réplicas a partir del mismo código:

terraform workspace select prod
terraform plan -out=prod.tfplan
terraform apply prod.tfplan

Comprueba que los contenedores están en marcha y llevan las etiquetas generadas por el bloque dinámico:

docker ps --filter label=managed-by=terraform --format 'table {{.Names}}\t{{.Label "environment"}}'
NAMES        ENVIRONMENT
web-prod-3   prod
web-prod-2   prod
web-prod-1   prod
web-dev-1    dev

Haz una petición a una de las réplicas:

curl -sI http://localhost:8281 | head -n 1
HTTP/1.1 200 OK

Paso 7: Inspeccionar y verificar el estado remoto

Lista los recursos que Terraform controla en el workspace actual:

terraform state list
module.web.docker_container.replica[0]
module.web.docker_container.replica[1]
module.web.docker_container.replica[2]
module.web.docker_image.this
module.web.docker_network.this

Confirma que no se ha escrito ningún archivo de estado local y que cada workspace vive en PostgreSQL. El backend pg crea un esquema terraform_remote_state con una tabla states:

ls terraform.tfstate 2>/dev/null || echo "no hay estado local"
psql "$PG_CONN_STR" -c 'SELECT name FROM terraform_remote_state.states ORDER BY name;'
no hay estado local
  name
---------
 dev
 prod
 staging
(3 rows)

Según tu versión de Terraform, también puede aparecer una fila default.

Para ver el bloqueo en acción, abre una segunda terminal, exporta de nuevo PG_CONN_STR y ejecuta terraform plan en el mismo workspace mientras un apply espera confirmación en la primera. El segundo comando falla con Error acquiring the state lock en lugar de corromper el estado.

Paso 8: Refactorizar de forma segura con bloques moved

Renombrar la dirección de un recurso o de un módulo hace normalmente que Terraform planifique destruir el objeto antiguo y crear uno nuevo. Un bloque moved le indica a Terraform que el objeto solo ha cambiado de dirección.

Supón que quieres renombrar el módulo de web a frontend. En main.tf, cambia module "web" por module "frontend", actualiza outputs.tf para que use module.frontend.urls y añade este bloque a main.tf:

moved {
  from = module.web
  to   = module.frontend
}

Genera el plan en el workspace prod:

terraform plan
  # module.web.docker_container.replica[0] has moved to module.frontend.docker_container.replica[0]
...
Plan: 0 to add, 0 to change, 0 to destroy.

Cero cambios significa que el renombrado es seguro. Aplícalo en todos los workspaces y mantén el bloque moved en el código hasta que todos los entornos estén actualizados.

Solución de problemas

  • Invalid index en local.environments[local.env]: estás en el workspace default, que no tiene entrada en el mapa. Ejecuta terraform workspace select dev (o añade una entrada default).
  • Error acquiring the state lock sin que nadie más esté usando Terraform: una ejecución anterior se interrumpió. Comprueba que no queda ningún proceso en marcha y libera el bloqueo con terraform force-unlock LOCK_ID, usando el ID que aparece en el error.
  • permission denied while trying to connect to the Docker daemon socket: tu usuario no está en el grupo docker o no has iniciado una sesión nueva desde que lo añadiste.
  • Failed to query available provider packages para hashicorp/docker: a algún módulo le falta el bloque required_providers con source = "kreuzwerker/docker".
  • backend configuration changed tras editar el bloque backend: ejecuta terraform init -reconfigure, o terraform init -migrate-state si quieres mover el estado existente.

Conclusión

Ahora tienes un proyecto de Terraform que mantiene la lógica en un módulo validado y reutilizable, guarda el estado en PostgreSQL con bloqueo y despliega tres entornos desde una sola configuración mediante workspaces. También has usado bloques moved para refactorizar sin recrear recursos.

Como siguientes pasos, mueve el módulo a su propio repositorio Git y referencia una versión etiquetada (source = "git::https://example.com/modules.git//web_app?ref=v1.0.0"), ejecuta terraform plan automáticamente en cada pull request desde tu pipeline de CI, y aplica los mismos patrones con el proveedor de Terraform de tu nube.