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
dockerpara que Terraform pueda acceder a/var/run/docker.socksinsudo. - Conocimientos básicos de Terraform: proveedores, recursos,
planyapply.
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:
| Ruta | Función |
|---|---|
versions.tf | Versiones de Terraform y del proveedor, configuración del backend |
main.tf | Proveedor, ajustes por entorno y llamada al módulo |
outputs.tf | Valores que se muestran tras apply |
modules/web_app/variables.tf | Entradas del módulo con validación |
modules/web_app/main.tf | Red, imagen y contenedores |
modules/web_app/outputs.tf | Salidas 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
NotaDocker publica los puertos directamente mediante iptables, saltándose las reglas de UFW. Si estos puertos no deben ser accesibles desde internet, vincúlalos a
127.0.0.1añadiendoip = "127.0.0.1"al bloqueports.
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 indexenlocal.environments[local.env]: estás en el workspacedefault, que no tiene entrada en el mapa. Ejecutaterraform workspace select dev(o añade una entradadefault).Error acquiring the state locksin 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 conterraform 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 grupodockero no has iniciado una sesión nueva desde que lo añadiste.Failed to query available provider packagesparahashicorp/docker: a algún módulo le falta el bloquerequired_providersconsource = "kreuzwerker/docker".backend configuration changedtras editar el bloquebackend: ejecutaterraform init -reconfigure, oterraform init -migrate-statesi 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.
