🎯 Meta: el cap. 14 te enseñó a preparar un VPS "a mano" (SSH, comandos, checklist). Eso funciona para un servidor. En cuanto tienes que reproducir esa infraestructura (staging + producción, o recuperarte de un servidor borrado por error), hacerlo a mano es lento y propenso a errores. Terraform describe tu infraestructura en archivos versionados en git, y la aplica de forma reproducible: la definición de tu infraestructura ahora vive en el mismo repo que tu código, revisada en PRs como cualquier otro cambio.
Versión: Terraform 1.10 (HashiCorp) · Ejemplos con proveedor de DigitalOcean (el más simple para aprender) — los conceptos son idénticos en AWS/GCP/Hetzner.
📘 Requisitos: cap. 14 (sabes qué es un VPS, systemd, Nginx) y cap. 12 (Docker).
R.1 · El problema que resuelve (y el que no)
SIN Terraform CON Terraform
┌────────────────────────┐ ┌────────────────────────┐
│ "Crea un droplet" │ │ main.tf describe: │
│ (click en el panel web, │ │ - 1 servidor │
│ o comando suelto) │ │ - 1 firewall │
│ │ │ - 1 IP flotante │
│ ¿Cómo era exactamente? │ │ │
│ ¿Qué firewall tenía? │ │ terraform apply │
│ Nadie lo sabe con │ │ → reproduce EXACTAMENTE │
│ certeza salvo el panel │ │ lo descrito, siempre │
└────────────────────────┘ └────────────────────────┘Terraform NO configura software dentro del servidor (eso lo sigue haciendo tu Dockerfile, tu docker-compose.yml, o herramientas como Ansible). Terraform crea la infraestructura que rodea a tu app: servidores, redes, bases de datos gestionadas, DNS, balanceadores. La frontera es: "¿existe el recurso?" es Terraform; "¿qué corre dentro?" es Docker/cap. 12.
🧠 Declarativo, no imperativo: no le dices a Terraform los pasos ("crea esto, luego esto"). Describes el estado final deseado y Terraform calcula el plan para llegar ahí — exactamente la misma filosofía que Kubernetes (ap. G.2) pero para infraestructura, no contenedores.
R.2 · Instalación y primer recurso
# macOS/Linux
brew install terraform # o descarga el binario de terraform.io
terraform version# main.tf
terraform {
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.40"
}
}
}
provider "digitalocean" {
token = var.do_token # nunca hardcodees el token (R.6)
}
resource "digitalocean_droplet" "api" {
name = "tienda-api"
region = "fra1"
size = "s-1vcpu-1gb"
image = "ubuntu-24-04-x64"
ssh_keys = [var.ssh_key_id]
}terraform init # descarga el proveedor de DigitalOcean
terraform plan # ⚠️ SIEMPRE antes de apply: muestra QUÉ va a cambiar
terraform apply # pide confirmación y crea el recurso de verdadSalida de plan — el contrato de Terraform contigo:
Terraform will perform the following actions:
# digitalocean_droplet.api will be created
+ resource "digitalocean_droplet" "api" {
+ name = "tienda-api"
+ region = "fra1"
+ size = "s-1vcpu-1gb"
+ ipv4_address = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.⚠️ Lee el plan siempre, especialmente
-1 to destroyo~ to changeen producción. Terraform borra y recrea recursos cuando un cambio no se puede aplicar "en caliente" (cambiar elimagede un droplet, por ejemplo) — y eso significa downtime real si no lo esperas.
R.3 · El estado: la pieza que todos rompen al principio
Terraform necesita saber qué existe ya para calcular el plan. Esa información vive en el state (terraform.tfstate), un JSON que mapea tu código con los recursos reales:
tu código (main.tf) ──┐
├──▶ terraform plan compara ──▶ plan de cambios
recursos reales ────────┘ (vía el state)⚠️ El error nº1 de todo principiante: el state en local, sin compartir con el equipo. Si dos personas aplican cambios con states distintos, Terraform pierde la pista de qué existe de verdad — recursos duplicados, "phantom resources", desastre. La solución es un backend remoto desde el día 1 en cualquier proyecto con más de una persona:
# backend.tf — el state vive en un bucket compartido, con bloqueo (nadie aplica a la vez)
terraform {
backend "s3" { # también hay backends para GCS, Terraform Cloud, etc.
bucket = "mi-empresa-tfstate"
key = "tienda/terraform.tfstate"
region = "eu-west-1"
}
}🧠
.tfstatepuede contener secretos en claro (contraseñas generadas, tokens). Nunca lo subas a git (.gitignore), y en el backend remoto asegura que solo tu equipo tiene acceso — es tan sensible como tu.envde producción.
R.4 · Variables, outputs y un ejemplo completo
# variables.tf — parámetros del módulo, con valores por defecto donde tenga sentido
variable "do_token" {
type = string
sensitive = true # Terraform lo oculta en los logs
}
variable "entorno" {
type = string
default = "staging"
}
variable "tamano_servidor" {
type = string
default = "s-1vcpu-1gb"
}# main.tf — infraestructura completa de una API en producción
resource "digitalocean_droplet" "api" {
name = "tienda-${var.entorno}"
region = "fra1"
size = var.tamano_servidor
image = "ubuntu-24-04-x64"
ssh_keys = [var.ssh_key_id]
# user_data: se ejecuta UNA vez al crear el servidor (instalación base)
user_data = file("${path.module}/cloud-init.yaml")
}
resource "digitalocean_firewall" "api" {
name = "tienda-${var.entorno}-fw"
droplet_ids = [digitalocean_droplet.api.id]
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = ["0.0.0.0/0"] # en real: restringe a tu IP/VPN
}
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0"]
}
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0"]
}
}
resource "digitalocean_database_cluster" "postgres" {
name = "tienda-${var.entorno}-db"
engine = "pg"
version = "18"
size = "db-s-1vcpu-1gb"
region = "fra1"
node_count = 1
}# outputs.tf — lo que quieres VER después de aplicar (o usar en otro sitio, CI/CD…)
output "ip_servidor" {
value = digitalocean_droplet.api.ipv4_address
}
output "url_base_datos" {
value = digitalocean_database_cluster.postgres.uri
sensitive = true
}terraform apply -var="do_token=$DO_TOKEN" -var="entorno=produccion"
terraform output ip_servidor # consulta un output sin reaplicar nada💡
cloud-init.yamles el puente con el cap. 12/14: instala Docker, clona tu repo (o configura el runner de CI/CD para desplegar), y arrancadocker compose up. Terraform crea el servidor; eluser_datalo deja listo para que tu pipeline de CI (cap. 14.5) despliegue tu app encima.
R.5 · Módulos: no repitas staging y producción a mano
# módulos/api-server/main.tf — la "función" reutilizable
variable "nombre" {}
variable "tamano" {}
resource "digitalocean_droplet" "servidor" {
name = var.nombre
size = var.tamano
region = "fra1"
image = "ubuntu-24-04-x64"
}
output "ip" { value = digitalocean_droplet.servidor.ipv4_address }# main.tf de la raíz — usa el módulo dos veces, una por entorno
module "staging" {
source = "./modulos/api-server"
nombre = "tienda-staging"
tamano = "s-1vcpu-1gb"
}
module "produccion" {
source = "./modulos/api-server"
nombre = "tienda-produccion"
tamano = "s-2vcpu-4gb"
}🧠 Un módulo es exactamente lo que un componente reutilizable es en React (cap. 17) o un service en NestJS (cap. 06): defines una vez, parametrizas, reutilizas. Evita la tentación de copiar y pegar
main.tfpara cada entorno — el mismo problema que copiar componentes en vez de reutilizarlos.
R.6 · Secretos: nunca en el .tf
# ❌ NUNCA hardcodees el token en el código
provider "digitalocean" { token = "dop_v1_abc123..." } # esto acaba en git, siempre
# ✅ Variable de entorno con el prefijo TF_VAR_
export TF_VAR_do_token="dop_v1_abc123..."
terraform apply # Terraform la recoge sola
# ✅ O un gestor de secretos (Vault, AWS Secrets Manager) inyectando en CI/CD# .gitignore — obligatorio en cualquier proyecto Terraform
*.tfstate
*.tfstate.*
.terraform/
*.tfvars # si contiene valores sensiblesR.7 · Terraform en tu pipeline de CI/CD (conecta con el cap. 14.5)
# .github/workflows/infra.yml
name: Terraform
on:
pull_request:
paths: ['infra/**']
push:
branches: [main]
paths: ['infra/**']
jobs:
terraform:
runs-on: ubuntu-latest
defaults:
run: { working-directory: infra }
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -out=tfplan
env: { TF_VAR_do_token: ${{ secrets.DO_TOKEN }} }
# En PR: solo plan (revisable en el diff del PR, como código normal)
# En main: aplica automáticamente tras aprobar
- if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve tfplan
env: { TF_VAR_do_token: ${{ secrets.DO_TOKEN }} }💡 El
planen cada PR es el superpoder real: cualquiera del equipo ve, ANTES de aplicar, exactamente qué recursos se van a crear/cambiar/destruir — revisado como cualquier otro código, con el mismo rigor que revisas un cambio de esquema de BD (cap. 14.6).
R.8 · Terraform y Kubernetes: cuándo cada uno (repaso del ap. G)
Terraform → crea EL CLÚSTER de Kubernetes (o el VPS, la BD gestionada, el DNS…)
Kubernetes → gestiona LOS PODS que corren DENTRO de ese clúster (ap. G)
Docker → empaqueta LO QUE corre dentro de cada pod/servidor (cap. 12)De hecho, Terraform también puede crear recursos de Kubernetes (kubernetes_deployment) — pero lo más común y mantenible es: Terraform para el clúster y la infraestructura que lo rodea; kubectl apply/Helm (ap. G) para lo que corre dentro. Mezclar ambos en el mismo archivo suele generar más confusión que la que resuelve.
R.9 · Buenas prácticas
terraform plansiempre antes deapply, y léelo — especialmente losdestroy.- Backend remoto desde el primer commit si hay más de una persona tocando infra.
- Nunca
.tfstateen git. Nunca secretos hardcodeados en.tf. - Módulos para lo que se repite (staging/producción), variables para lo que cambia.
planen cada PR de infraestructura, revisado como código — es exactamente eso.- Terraform crea infraestructura; no configura software dentro. Esa frontera con Docker/Ansible mantiene cada herramienta simple.
- Empieza sin Terraform si eres tú solo con un VPS (cap. 14 a mano basta). Adóptalo cuando necesites reproducir el entorno o cuando el equipo crezca — la misma regla de "no sobre-ingenierizar" del ap. G para Kubernetes.
R.10 · Workspaces y detección de drift
Workspaces: una alternativa a módulos (R.5) para manejar varios entornos, con un state distinto por workspace dentro del mismo código:
terraform workspace new staging
terraform workspace new produccion
terraform workspace select staging
terraform apply # aplica al state de "staging", aislado del de "produccion"# Referencia el workspace activo dentro del código
resource "digitalocean_droplet" "api" {
name = "tienda-${terraform.workspace}"
size = terraform.workspace == "produccion" ? "s-2vcpu-4gb" : "s-1vcpu-1gb"
}⚠️ Módulos y workspaces resuelven problemas distintos, no son intercambiables. Un módulo es estructura (código reutilizable, R.5); un workspace es aislamiento de state con el mismo código. Para producción de verdad, la práctica más segura sigue siendo directorios/backends separados por entorno (R.5): un error de comando (
terraform destroysin comprobar el workspace activo) puede borrar producción por accidente. Reserva workspaces para entornos efímeros (una rama de feature, un entorno de pruebas temporal), no para staging/producción permanentes.
Detección de drift: alguien cambia algo a mano en el panel de DigitalOcean/AWS (o un script suelto) sin pasar por Terraform. El código ya no refleja la realidad — eso es drift:
# CI programado (cron), NO en cada push — solo para VIGILAR, sin aplicar nada
terraform plan -detailed-exitcode
# exit 0 = sin cambios (todo coincide)
# exit 1 = error
# exit 2 = HAY DRIFT: algo cambió fuera de Terraform — avisa al equipo, no apliques a ciegas# .github/workflows/drift-check.yml — corre cada noche, solo detecta y avisa
on:
schedule:
- cron: "0 3 * * *"
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -detailed-exitcode -out=tfplan || echo "::warning::Drift detectado"🧠 El drift es inevitable en equipos reales (alguien "arregla algo rápido" desde el panel web en una emergencia). Detectarlo pronto evita que el próximo
terraform applynormal deshaga ese cambio de emergencia sin que nadie lo espere. La regla de oro: si cambiaste algo a mano, refleja ese cambio en el.tfcuanto antes — el código, no el panel, debe ser la fuente de verdad.
✅ Ejercicio del apéndice
1. Cuenta gratuita de DigitalOcean (o Hetzner) + terraform init/plan/apply de un
único droplet con firewall (solo 22 y 443 abiertos).
2. cloud-init.yaml que instala Docker y clona tu repo del cap. 22 (Cantina).
3. Añade digitalocean_database_cluster para PostgreSQL gestionado; conecta tu
app vía el output (sensitive) de la connection string.
4. Convierte el droplet + firewall en un módulo reutilizable; instáncialo dos
veces (staging con s-1vcpu-1gb, producción con s-2vcpu-4gb).
5. Backend remoto: mueve el state a un bucket S3 (o Spaces de DO) con bloqueo.
6. Workflow de GitHub Actions: plan automático en cada PR que toque infra/,
apply automático solo al mergear a main.
7. Rompe algo a propósito (cambia el tamaño del droplet) y observa en el plan
si Terraform lo actualiza en caliente o lo destruye y recrea. Anota por qué.
8. Provoca drift: cambia una etiqueta o el firewall desde el panel web de
DigitalOcean directamente, y corre `terraform plan -detailed-exitcode` para
verlo detectado. Prueba el workflow de drift-check nocturno.
9. terraform destroy al terminar — no dejes recursos de pago corriendo.🧠 Autoevaluación
Terraform no configura software dentro de un servidor ni gestiona el ciclo de vida de tu aplicación (build, deploy, contenedores en marcha). Crea y gestiona la infraestructura que rodea a la app — servidores, redes, bases de datos gestionadas. Docker/CI se encargan de lo que corre dentro.
Terraform usa el state para saber qué existe realmente. Si cada persona tiene su propio state local, Terraform pierde la referencia común y puede duplicar recursos o entrar en conflicto. Un backend remoto con bloqueo evita que dos aplicaciones simultáneas corrompan el estado.
Que el cambio no se puede aplicar in situ: Terraform va a borrar el recurso y crear uno nuevo. En un servidor de producción esto significa downtime real — hay que planificar una ventana de mantenimiento o rediseñar el cambio para evitarlo.
Cuando eres una sola persona con un único VPS y el checklist manual del cap. 14 ya te basta. Terraform paga su complejidad cuando necesitas reproducir el entorno (staging + producción) o cuando el equipo crece y varias personas tocan la infraestructura — la misma regla de "no sobre-ingenierizar" que con Kubernetes (ap. G).
Con workspaces, un mismo código y un simple error de comando (olvidar comprobar terraform workspace show antes de un destroy) puede aplicar un cambio destructivo al entorno equivocado, porque cambiar de workspace es un paso fácil de saltarse por accidente. Directorios/backends separados hacen que producción sea, literalmente, otro state en otro sitio — mucho más difícil de tocar sin querer.
Código 2 significa que hay drift: algo en la infraestructura real ya no coincide con el .tf (probablemente un cambio manual desde el panel web). No deberías aplicar el plan a ciegas de forma automática — el drift necesita revisión humana, porque podría ser un cambio de emergencia legítimo que hay que reflejar en el código, no deshacer sin más.
Volver al: README.md · Relacionado: 14-servidores-devops.md, G-kubernetes.md, 12-docker.md