--- title: "Módulos, workspaces y estado remoto de Terraform en Ubuntu" description: "Crea un módulo reutilizable de Terraform, guarda el estado en PostgreSQL con bloqueo y despliega dev, staging y prod con workspaces en Ubuntu 24.04." category: "Herramientas de Automatización" language: es source: https://cubepath.com/es/docs/herramientas-de-automatización/aprovisionamiento-de-infraestructura-con-terraform updated: 2026-09-25 --- # Módulos, workspaces y estado remoto de Terraform en Ubuntu 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: ```bash 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: ```bash sudo apt update sudo apt install -y gnupg curl ``` Descarga la clave de firma de HashiCorp en `/etc/apt/keyrings`: ```bash 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): ```bash 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: ```bash sudo apt update sudo apt install -y terraform ``` Comprueba la instalación: ```bash terraform -version ``` ```output 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: ```bash 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: ```bash sudo -u postgres createuser --pwprompt terraform ``` Crea una base de datos cuyo propietario sea ese rol: ```bash 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: ```bash 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: ```bash psql "$PG_CONN_STR" -c 'SELECT current_user;' ``` ```output 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: ```bash 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`: ```bash nano modules/web_app/variables.tf ``` ```hcl 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: ```bash nano modules/web_app/main.tf ``` ```hcl 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: ```bash nano modules/web_app/outputs.tf ``` ```hcl 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`: ```bash nano versions.tf ``` ```hcl 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: ```bash nano main.tf ``` ```hcl 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: ```bash nano outputs.tf ``` ```hcl 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: ```bash terraform init ``` ```output 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: ```bash terraform fmt -recursive terraform validate ``` ```output 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: ```bash 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: ```bash terraform workspace select dev terraform plan -out=dev.tfplan ``` ```output Plan: 3 to add, 0 to change, 0 to destroy. ``` Aplícalo: ```bash terraform apply dev.tfplan ``` ```output 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: ```bash 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: ```bash docker ps --filter label=managed-by=terraform --format 'table {{.Names}}\t{{.Label "environment"}}' ``` ```output 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: ```bash curl -sI http://localhost:8281 | head -n 1 ``` ```output HTTP/1.1 200 OK ``` > Nota: Docker 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.1` añadiendo `ip = "127.0.0.1"` al bloque `ports`. ## Paso 7: Inspeccionar y verificar el estado remoto Lista los recursos que Terraform controla en el workspace actual: ```bash terraform state list ``` ```output 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`: ```bash 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;' ``` ```output 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`: ```hcl moved { from = module.web to = module.frontend } ``` Genera el plan en el workspace `prod`: ```bash terraform plan ``` ```output # 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.