En 06-05 quedó todo dicho salvo lo esencial. Sabes cuál es el problema —la infraestructura de AlpinaShop existe solo porque Marta ejecutó los comandos correctos, y nadie sabría reproducirla—, sabes qué es la infraestructura como código, entiendes el estado y conoces el procedimiento de migración paso a paso. Lo que falta es la herramienta con la que hacerlo.

Esta lección la trae, y con ella se cierra el módulo. Al terminar, la VPC de AlpinaShop, sus subredes, sus reglas de firewall y sus buckets estarán escritos en ficheros que se revisan en un pull request, se aplican desde un pipeline y se pueden reproducir en otra región cambiando una variable. Y la respuesta a "¿cuánto se tarda en reconstruir el entorno?" pasará de "nadie lo sabe" a "unos veinte minutos".

Un aviso sobre el alcance: esta lección enseña Terraform lo suficiente para gestionar la infraestructura de AlpinaShop con criterio. Terraform da para un curso entero, y aquí se cubre lo que se usa el 95 % del tiempo, señalando lo que queda fuera.

Contenido

  1. Por qué Terraform ganó
  2. Los conceptos: proveedor, recurso, dato, variable, salida y dependencias
  3. El estado: qué contiene y por qué nunca va a Git
  4. El backend remoto en Cloud Storage
  5. El flujo de trabajo: init, validate, plan, apply, destroy
  6. Cómo se lee un plan
  7. Código real de AlpinaShop: la red y el bucket
  8. Variables, tfvars y entornos
  9. Módulos: escribir el módulo red-alpinashop
  10. Módulos del registro público, con criterio
  11. Importar lo que ya existe
  12. Terraform en CI/CD: plan en cada pull request
  13. Qué NO debe gestionar Terraform
  14. prevent_destroy y el peligro de terraform destroy
  15. Infrastructure Manager y alternativas
  16. El estado final de AlpinaShop

  1. Por qué Terraform ganó

En 06-05 se vio por qué Deployment Manager perdió. La otra cara es por qué ganó Terraform, y no es solo mérito técnico:

Razón Detalle
Multiproveedor GCP, AWS, Azure, Kubernetes, GitHub, Cloudflare, Datadog, PostgreSQL… con un mismo lenguaje
Ecosistema de módulos Miles de módulos públicos revisados y mantenidos, muchos por Google
Madurez Desde 2014, con los casos raros ya resueltos y documentados
Comunidad Ejemplos, libros, cursos y —muy importante— profesionales que ya lo conocen
Adoptado por el propio Google Infrastructure Manager es Terraform gestionado
Lenguaje legible HCL es más claro que YAML anidado o que JSON

La primera fila importa más de lo que parece incluso para quien solo usa GCP. La infraestructura real de AlpinaShop no es solo GCP: hay un registro DNS en el registrador del dominio, repositorios y protecciones de rama en GitHub, quizá mañana un CDN externo. Con Terraform, todo eso se declara en el mismo sitio y con el mismo flujo, y las dependencias entre proveedores funcionan igual que dentro de uno.

Una nota sobre la licencia, porque hay que ser honesto: en 2023 HashiCorp cambió Terraform de una licencia de código abierto a la Business Source License, lo que provocó la aparición de OpenTofu, una bifurcación bajo licencia abierta gestionada por una fundación. Para el uso normal —una empresa gestionando su propia infraestructura— la BSL no impone ninguna restricción práctica, y ambas herramientas son compatibles a nivel de código y de estado. Es algo que conviene conocer; para AlpinaShop no cambia nada.

  1. Los conceptos: proveedor, recurso, dato, variable, salida y dependencias

Terraform se escribe en HCL (HashiCorp Configuration Language), y tiene pocos tipos de bloque. Con estos seis se hace casi todo.

Proveedor (provider): el complemento que sabe hablar con una API. Se declara con su versión fijada:

terraform {
  required_version = ">= 1.9"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.0"     # permite 6.x, no salta a 7.x
    }
  }
}

provider "google" {
  project = var.proyecto
  region  = var.region
}

Fijar la versión del proveedor no es opcional. Sin version, Terraform descarga la última disponible, y una versión mayor puede cambiar el comportamiento de recursos existentes. La sintaxis ~> 6.0 permite actualizaciones menores y de parche pero no el salto a la 7, que es donde están los cambios incompatibles.

Recurso (resource): algo que Terraform crea y gestiona.

resource "google_compute_network" "principal" {
  #        └── tipo                  └── nombre LOCAL, solo dentro de Terraform
  name                    = "alpinashop-vpc"   # el nombre real en GCP
  auto_create_subnetworks = false
}

La distinción entre el nombre local (principal) y el atributo name (alpinashop-vpc) confunde al principio. El local es la etiqueta con la que otras partes del código referencian el recurso; el atributo name es lo que aparece en la consola de GCP.

Fuente de datos (data): consulta algo que existe pero que Terraform no gestiona.

data "google_project" "actual" {}

data "google_compute_image" "debian" {
  family  = "debian-12"
  project = "debian-cloud"     # imagen pública de Google
}

La diferencia con un resource es total: un data solo lee, nunca crea ni modifica. Sirve para obtener el número de proyecto, la última imagen de un sistema operativo o un recurso creado por otro equipo.

Variable (variable): entrada parametrizable.

variable "entorno" {
  description = "Entorno de despliegue"
  type        = string

  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "El entorno debe ser 'dev' o 'prod'."
  }
}

variable "cidr_web" {
  description = "Rango CIDR de la subred web"
  type        = string
  default     = "10.10.0.0/24"
}

El bloque validation es el equivalente a los esquemas de Deployment Manager de 06-05, y produce un error claro antes de tocar nada.

Salida (output): valor que se expone al terminar.

output "id_red" {
  description = "Identificador de la VPC principal"
  value       = google_compute_network.principal.id
}

output "password_bd" {
  value     = google_sql_user.app.password
  sensitive = true      # no se imprime en la consola
}

sensitive = true evita que el valor aparezca en la salida. No lo cifra ni lo oculta del estado, que es una distinción importante y que retomamos en el apartado 3.

Dependencias. La mayoría son implícitas: nacen de referenciar un recurso desde otro.

resource "google_compute_subnetwork" "web" {
  name          = "sn-web-euw1"
  ip_cidr_range = var.cidr_web
  region        = var.region
  # Esta referencia DECLARA que la subred depende de la red
  network       = google_compute_network.principal.id
}

Terraform construye un grafo, crea primero la red y después la subred, y paraleliza todo lo que no tiene dependencias entre sí. Las explícitas con depends_on son el último recurso, para dependencias que no se expresan mediante datos:

resource "google_compute_instance" "app" {
  # ...
  # La API debe estar habilitada antes, pero eso no se refleja en ningún atributo
  depends_on = [google_project_service.compute]
}

Consejo: usa depends_on lo mínimo posible. Un depends_on que se puede sustituir por una referencia es una oportunidad perdida de que el código exprese la relación real.

  1. El estado: qué contiene y por qué nunca va a Git

En 06-05 se explicó qué es el estado conceptualmente. Aquí toca la parte práctica, que es donde están los problemas reales.

El fichero terraform.tfstate es un JSON que contiene, para cada recurso gestionado: su nombre local, su tipo, su identificador real en GCP y todos sus atributos tal como estaban en la última operación.

{
  "version": 4,
  "terraform_version": "1.9.5",
  "serial": 42,
  "lineage": "8f3e...",
  "resources": [
    {
      "mode": "managed",
      "type": "google_sql_user",
      "name": "app",
      "instances": [{
        "attributes": {
          "name": "catalogo",
          "instance": "alpinashop-pedidos",
          "password": "P4ssw0rdEnClaro..."
        }
      }]
    }
  ]
}

Mira la última línea. La contraseña está en claro en el fichero de estado. No es un error de configuración: es el funcionamiento normal. Terraform guarda todos los atributos de cada recurso, y algunos atributos son secretos. Lo mismo ocurre con claves generadas, tokens y certificados.

De ahí las cuatro reglas del estado:

Regla Motivo
Nunca a Git Contiene secretos en claro, y Git no olvida (06-02)
Backend remoto compartido Si cada uno tiene su copia local, el equipo se pisa
Con bloqueo Dos apply simultáneos corrompen el estado
Con versionado Un estado corrupto o borrado se recupera de una versión anterior

Y una consecuencia importante que se deriva de todo esto: el acceso al estado es tan sensible como el acceso a producción. Quien puede leer el bucket del estado puede leer las contraseñas que Terraform gestione. Por eso el apartado 13 insiste en que Terraform no debe gestionar valores de secretos.

El .gitignore no es negociable:

*.tfstate
*.tfstate.*
*.tfstate.backup
.terraform/
.terraform.lock.hcl.bak
crash.log
*.tfvars.secret
override.tf

Con una excepción deliberada: .terraform.lock.hcl SÍ va a Git. Ese fichero fija las versiones exactas de los proveedores y sus sumas de verificación, y versionarlo garantiza que todo el equipo y el pipeline usan exactamente lo mismo. Es el equivalente a un package-lock.json.

  1. El backend remoto en Cloud Storage

El backend define dónde vive el estado. Para GCP, Cloud Storage.

Primero se crea el bucket, y esto es lo único que se hace a mano en todo el proceso —problema del huevo y la gallina—:

gcloud storage buckets create gs://alpinashop-terraform-estado \
  --project=alpinashop-cicd \
  --location=europe-west1 \
  --uniform-bucket-level-access \
  --public-access-prevention

# VERSIONADO: imprescindible, permite recuperar un estado corrupto
gcloud storage buckets update gs://alpinashop-terraform-estado --versioning

# Acceso restringido: quien lea esto lee secretos
gcloud storage buckets add-iam-policy-binding gs://alpinashop-terraform-estado \
  --member='group:[email protected]' \
  --role=roles/storage.objectAdmin \
  --project=alpinashop-cicd

Y se declara en el código:

terraform {
  backend "gcs" {
    bucket = "alpinashop-terraform-estado"
    prefix = "prod/red"       # una ruta distinta por entorno y componente
  }
}

El prefix es más importante de lo que parece: separa estados. prod/red, prod/datos, dev/red son estados independientes, y esa separación limita el radio de daño: un error operando la red no puede afectar al estado de la base de datos.

El bloqueo funciona solo, sin configuración. Terraform crea un objeto .tflock mientras opera; si otra persona lanza apply a la vez, obtiene un error claro en lugar de corromper el estado:

Error: Error acquiring the state lock
Lock Info:
  ID:        b3d4e5f6
  Who:       [email protected]
  Created:   2026-08-05 18:42:11 UTC

Si un proceso muere dejando el bloqueo puesto —una compilación cancelada, por ejemplo—, se libera con terraform force-unlock ID. Es un comando que hay que usar con cuidado: solo cuando estés seguro de que no hay ninguna operación en curso.

  1. El flujo de trabajo: init, validate, plan, apply, destroy

flowchart LR
    A[init<br/>descargar proveedores<br/>configurar backend] --> B[validate<br/>sintaxis y tipos]
    B --> C[plan<br/>QUÉ VA A PASAR]
    C --> D{¿El plan<br/>es correcto?}
    D -->|No| E[Corregir el código]
    E --> C
    D -->|Sí| F[apply<br/>ejecutar]
    F --> G[Infraestructura<br/>actualizada]
# 1. init: descarga proveedores y configura el backend. Se ejecuta al empezar
#    y cada vez que cambian proveedores o módulos.
terraform init

# 2. fmt y validate: formateo canónico y comprobación de sintaxis y tipos.
#    Rápidos, sin tocar nada, ideales para un gancho de pre-commit (06-02).
terraform fmt -recursive
terraform validate

# 3. plan: LA OPERACIÓN MÁS IMPORTANTE. Compara código, estado y realidad.
terraform plan -out=plan.tfplan

# 4. apply: ejecuta el plan guardado. Con el fichero, aplica EXACTAMENTE
#    lo que revisaste; sin él, recalcula y podría hacer algo distinto.
terraform apply plan.tfplan

# Utilidades
terraform show                 # ver el estado en formato legible
terraform state list           # listar recursos gestionados
terraform output id_red        # consultar una salida

El detalle de -out=plan.tfplan merece énfasis porque es la diferencia entre revisar y confiar. Sin él, terraform apply recalcula el plan en ese momento; si algo cambió entre medias, aplica algo distinto de lo que se revisó. Con el fichero, aplica exactamente lo aprobado. En un pipeline es obligatorio.

Y terraform destroy destruye todo lo del estado. Su uso legítimo es un entorno efímero de pruebas. En producción es el comando más peligroso de la herramienta, y el apartado 14 explica cómo protegerse.

  1. Cómo se lee un plan

Saber leer un plan es la habilidad más importante de esta lección. Un apply mal revisado es un incidente.

Los cuatro símbolos:

Símbolo Significado Nivel de atención
+ Crear un recurso nuevo Comprobar que es lo que esperas
~ Modificar en el sitio Leer qué campo cambia
- Destruir Alta: ¿por qué desaparece?
-/+ Destruir y recrear MÁXIMA: para y entiende por qué

Y la línea resumen al final:

Plan: 3 to add, 2 to change, 1 to destroy.

La regla de oro es la del -/+. Un reemplazo ocurre cuando cambia un atributo que la API de GCP no permite modificar en caliente. Terraform, sin más opciones, destruye y crea. Y en algunos recursos eso es catastrófico:

  # google_sql_database_instance.pedidos must be replaced
-/+ resource "google_sql_database_instance" "pedidos" {
      ~ region = "europe-west1" -> "europe-west4" # forces replacement
    }

Ese plan destruiría la base de datos de pedidos de AlpinaShop con todos sus datos. Terraform lo dice con claridad —# forces replacement— pero un apply automático o distraído lo ejecuta sin dudar.

Recurso ¿Un -/+ es aceptable? Por qué
Regla de firewall Se recrea en segundos
Plantilla de instancia Son inmutables por diseño
Subred No Desconecta todo lo que hay dentro
Cloud SQL NUNCA sin plan explícito Pérdida de datos
Bucket con contenido NUNCA Pérdida de objetos
Clúster de GKE No Downtime completo

Qué hacer ante un -/+ inesperado, en este orden: leer qué atributo lo fuerza —Terraform lo marca con # forces replacement—; decidir si ese cambio es realmente necesario; si lo es, buscar una vía sin destruir —crear el recurso nuevo, migrar y borrar el viejo—; y si no lo es, corregir el código. Nunca aplicar sin haber entendido la causa.

Otro caso que confunde: los planes que nunca salen vacíos aunque no cambies nada. Suelen deberse a un atributo que GCP normaliza —una lista que reordena, un valor por defecto que rellena— y se resuelven o bien escribiendo el valor real en el código, o bien, cuando el proveedor tiene un comportamiento incorrecto, con lifecycle { ignore_changes = [...] }. Esta segunda opción es un parche y conviene usarla con moderación: cada ignore_changes es una parte de la infraestructura que Terraform deja de vigilar.

  1. Código real de AlpinaShop: la red y el bucket

Ahora el código de verdad, explicado línea a línea.

# ============================================================
# red.tf — Red de AlpinaShop
# ============================================================

resource "google_compute_network" "principal" {
  name = "alpinashop-vpc"

  # false = subredes creadas por nosotros, con los rangos que decidamos.
  # Con true, GCP crearía una subred automática en CADA región (03-01).
  auto_create_subnetworks = false

  # REGIONAL: las rutas solo se propagan dentro de la región. Suficiente
  # para AlpinaShop y con menos superficie que GLOBAL.
  routing_mode = "REGIONAL"

  description = "VPC principal de AlpinaShop - gestionada con Terraform"
}

resource "google_compute_subnetwork" "web" {
  name          = "sn-web-euw1"
  ip_cidr_range = var.cidr_web
  region        = var.region

  # Referencia: crea la dependencia implícita con la red
  network = google_compute_network.principal.id

  # CRÍTICO: permite que las VM SIN IP pública alcancen las APIs de Google
  # (Cloud Storage, Secret Manager, Logging). Sin esto, se rompe medio módulo 3.
  private_ip_google_access = true

  # Logs de flujo con muestreo: diagnóstico de red sin coste desbocado (06-06)
  log_config {
    aggregation_interval = "INTERVAL_5_SEC"
    flow_sampling        = 0.25
    metadata             = "INCLUDE_ALL_METADATA"
  }
}

resource "google_compute_subnetwork" "datos" {
  name                     = "sn-datos-euw1"
  ip_cidr_range            = var.cidr_datos
  region                   = var.region
  network                  = google_compute_network.principal.id
  private_ip_google_access = true
}
# ============================================================
# firewall.tf — Reglas de firewall
# ============================================================

resource "google_compute_firewall" "permitir_salud" {
  name    = "fw-permitir-salud"
  network = google_compute_network.principal.name

  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }

  # Rangos FIJOS de las sondas de comprobación de estado de Google (03-02).
  # No son direcciones arbitrarias: si se quitan, hc-catalogo marca todas
  # las instancias como no sanas y el balanceador deja de enviar tráfico.
  source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
  target_tags   = ["catalogo-web"]

  description = "Permite las sondas de hc-catalogo hacia /salud en 8080"
}

resource "google_compute_firewall" "permitir_sql_interno" {
  name    = "fw-permitir-sql-interno"
  network = google_compute_network.principal.name

  allow {
    protocol = "tcp"
    ports    = ["5432"]
  }

  # Solo desde la subred web: la base de datos no se expone a nada más
  source_ranges = [var.cidr_web]
  target_tags   = ["base-datos"]

  description = "PostgreSQL accesible únicamente desde la subred web"
}

# Denegar y REGISTRAR el resto del tráfico entrante.
# Prioridad 65000: se evalúa la última, después de todas las permisivas.
resource "google_compute_firewall" "denegar_resto" {
  name     = "fw-denegar-resto"
  network  = google_compute_network.principal.name
  priority = 65000
  deny { protocol = "all" }
  source_ranges = ["0.0.0.0/0"]

  # Los intentos denegados se registran: base para investigar (06-06)
  log_config { metadata = "INCLUDE_ALL_METADATA" }
}
# ============================================================
# almacenamiento.tf — Bucket del catálogo
# ============================================================

resource "google_storage_bucket" "catalogo" {
  name     = "alpinashop-catalogo"
  location = var.region

  # Acceso uniforme: solo IAM, sin ACL por objeto. Mucho más simple de auditar.
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = true }

  # Ciclo de vida: las versiones antiguas bajan de clase y se borran (02-02)
  lifecycle_rule {
    condition {
      age                = 30
      with_state         = "ARCHIVED"
      num_newer_versions = 3
    }
    action { type = "Delete" }
  }

  lifecycle_rule {
    condition { age = 90 }
    action {
      type          = "SetStorageClass"
      storage_class = "NEARLINE"
    }
  }

  labels = {
    entorno      = var.entorno
    equipo       = "infraestructura"
    centro-coste = "tienda"
    aplicacion   = "catalogo"
  }

  # Protección: impide que un 'terraform destroy' borre el bucket (apartado 14)
  lifecycle {
    prevent_destroy = true
  }
}

Fíjate en dos cosas de este código que son la razón de ser del ejercicio. Primero, los comentarios explican el porqué, no el qué: que 35.191.0.0/16 son las sondas de Google, que private_ip_google_access rompe medio módulo 3 si falta. Ese conocimiento vivía en la cabeza de Marta y ahora vive en el repositorio. Y segundo, las etiquetas entorno, equipo, centro-coste y aplicacion son las de todo el curso: al ser código, dejan de aplicarse "cuando alguien se acuerda" y se aplican siempre.

  1. Variables, tfvars y entornos

El objetivo es que alpinashop-dev y alpinashop-prod compartan el mismo código con distintos valores, resolviendo la deriva de configuración de 06-05.

# variables.tf — el mismo para los dos entornos
variable "proyecto" {
  description = "ID del proyecto de GCP"
  type        = string
}

variable "entorno" {
  description = "Entorno: dev o prod"
  type        = string
  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "El entorno debe ser 'dev' o 'prod'."
  }
}

variable "region" {
  type    = string
  default = "europe-west1"
}

variable "cidr_web" {
  type    = string
  default = "10.10.0.0/24"
}

variable "cidr_datos" {
  type    = string
  default = "10.20.0.0/24"
}

variable "tamano_instancia_sql" {
  description = "Tipo de máquina de Cloud SQL"
  type        = string
  default     = "db-g1-small"     # el valor barato como defecto
}
# entornos/dev.tfvars
proyecto             = "alpinashop-dev"
entorno              = "dev"
cidr_web             = "10.110.0.0/24"     # rangos distintos: evita solapes
cidr_datos           = "10.120.0.0/24"
tamano_instancia_sql = "db-g1-small"
# entornos/prod.tfvars
proyecto             = "alpinashop-prod"
entorno              = "prod"
cidr_web             = "10.10.0.0/24"
cidr_datos           = "10.20.0.0/24"
tamano_instancia_sql = "db-custom-4-15360"
terraform plan -var-file=entornos/dev.tfvars -out=dev.tfplan
terraform apply dev.tfplan

Carpetas por entorno frente a workspaces, que es la decisión estructural que hay que tomar:

Aspecto Carpetas por entorno Workspaces
Estructura entornos/dev/, entornos/prod/ Un directorio, varios estados
Estado Backend distinto por carpeta Mismo backend, prefijos distintos
Divergencia entre entornos Posible y visible Difícil
Riesgo de equivocarse de entorno Bajo: estás en otra carpeta Alto: es un comando
Duplicación de código Alguna, se mitiga con módulos Ninguna
Recomendación Para prod y dev Entornos efímeros y muy iguales

AlpinaShop elige carpetas por entorno, y la razón principal es de seguridad operativa: con workspaces, la única diferencia entre aplicar en desarrollo y aplicar en producción es haber ejecutado terraform workspace select prod antes, y es demasiado fácil olvidarlo. Con carpetas, cada una tiene su propio backend y su propio provider, y aplicar en producción exige estar físicamente en el directorio de producción. Es una barrera pequeña que evita el error más caro.

La estructura resultante del repositorio alpinashop-infra:

alpinashop-infra/
├── modulos/
│   └── red-alpinashop/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── entornos/
│   ├── dev/
│   │   ├── main.tf          # invoca los módulos
│   │   ├── backend.tf       # prefix = "dev/"
│   │   └── dev.tfvars
│   └── prod/
│       ├── main.tf
│       ├── backend.tf       # prefix = "prod/"
│       └── prod.tfvars
└── paneles/                 # los JSON de 06-04

  1. Módulos: escribir el módulo red-alpinashop

Un módulo es un directorio con código Terraform que se invoca con parámetros. Es el mecanismo de reutilización.

# modulos/red-alpinashop/variables.tf
variable "nombre_red"  { type = string }
variable "region"      { type = string }
variable "cidr_web"    { type = string }
variable "cidr_datos"  { type = string }
variable "entorno"     { type = string }

variable "habilitar_nat" {
  description = "Crear Cloud NAT para salida sin IP pública"
  type        = bool
  default     = true
}
# modulos/red-alpinashop/main.tf
resource "google_compute_network" "esta" {
  name                    = var.nombre_red
  auto_create_subnetworks = false
  routing_mode            = "REGIONAL"
}

resource "google_compute_subnetwork" "web" {
  name                     = "sn-web-${substr(var.region, 0, 8)}"
  ip_cidr_range            = var.cidr_web
  region                   = var.region
  network                  = google_compute_network.esta.id
  private_ip_google_access = true
}

resource "google_compute_subnetwork" "datos" {
  name                     = "sn-datos-${substr(var.region, 0, 8)}"
  ip_cidr_range            = var.cidr_datos
  region                   = var.region
  network                  = google_compute_network.esta.id
  private_ip_google_access = true
}

# El router y el NAT solo se crean si se piden: count con una condición
resource "google_compute_router" "router" {
  count   = var.habilitar_nat ? 1 : 0
  name    = "${var.nombre_red}-router"
  region  = var.region
  network = google_compute_network.esta.id
}

resource "google_compute_router_nat" "nat" {
  count  = var.habilitar_nat ? 1 : 0
  name   = "${var.nombre_red}-nat"
  router = google_compute_router.router[0].name
  region = var.region

  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  log_config {
    enable = true
    filter = "ERRORS_ONLY"     # solo errores: el volumen completo es enorme
  }
}
# modulos/red-alpinashop/outputs.tf
output "id_red"      { value = google_compute_network.esta.id }
output "nombre_red"  { value = google_compute_network.esta.name }
output "id_sn_web"   { value = google_compute_subnetwork.web.id }
output "id_sn_datos" { value = google_compute_subnetwork.datos.id }

Y su uso desde cada entorno:

# entornos/prod/main.tf
module "red" {
  source = "../../modulos/red-alpinashop"

  nombre_red    = "alpinashop-vpc"
  region        = var.region
  cidr_web      = var.cidr_web
  cidr_datos    = var.cidr_datos
  entorno       = "prod"
  habilitar_nat = true
}

# Las salidas del módulo se consumen como cualquier otro valor
resource "google_compute_firewall" "permitir_salud" {
  name          = "fw-permitir-salud"
  network       = module.red.nombre_red
  source_ranges = ["35.191.0.0/16", "130.211.0.0/22"]
  target_tags   = ["catalogo-web"]
  allow {
    protocol = "tcp"
    ports    = ["8080"]
  }
}

Las tres reglas de un buen módulo:

  1. Una responsabilidad clara. red-alpinashop hace la red. Un módulo que crea la red, la base de datos y el balanceador es un monolito con otro nombre.
  2. Parametrizar lo que varía, fijar lo que no. private_ip_google_access = true está fijado a propósito: no es una decisión que cada entorno deba tomar, es una regla de AlpinaShop.
  3. Exponer las salidas que otros necesitan. Un módulo cuyas salidas no bastan obliga a romper su encapsulación.

Y una advertencia sobre el exceso de abstracción, que es el error más común al descubrir los módulos: un módulo con veinte variables para cubrir todos los casos imaginables es más difícil de usar que escribir el recurso directamente. Empieza con código plano, extrae un módulo cuando lo repitas por segunda vez, y no antes.

  1. Módulos del registro público, con criterio

El Terraform Registry aloja miles de módulos públicos, y Google mantiene una colección de módulos para GCP de calidad notable.

module "red" {
  source  = "terraform-google-modules/network/google"
  version = "~> 9.0"        # SIEMPRE fijar la versión

  project_id   = var.proyecto
  network_name = "alpinashop-vpc"

  subnets = [
    {
      subnet_name           = "sn-web-euw1"
      subnet_ip             = "10.10.0.0/24"
      subnet_region         = "europe-west1"
      subnet_private_access = "true"
    },
  ]
}

Los criterios para decidir si usar un módulo público o escribir el tuyo:

Criterio A favor de usarlo En contra
Quién lo mantiene Google, HashiCorp, organización conocida Una persona anónima
Actividad Commits recientes, incidencias atendidas Sin actualizar en dos años
Complejidad que ahorra Recursos con muchas piezas interrelacionadas Un recurso trivial
Ajuste a tu caso Cubre lo que necesitas Te obliga a contorsionarte
Legibilidad Puedes leer su código Cinco niveles de abstracción

La regla que uso: cuanto más complejo el recurso, más compensa el módulo público. Un módulo para crear una VPC con dos subredes ahorra poco y añade una dependencia externa; un módulo para montar una VPC compartida con reglas jerárquicas, o un clúster de GKE endurecido con todas sus buenas prácticas, ahorra semanas de trabajo y de errores.

Y dos advertencias serias. Fija siempre la versión con version = "~> X.Y": sin ella, un terraform init en el pipeline puede traer una versión nueva del módulo que cambie recursos de producción sin que nadie lo haya decidido. Y un módulo público es código de terceros con permisos sobre tu infraestructura: para módulos de organizaciones no conocidas, léelo antes, o bifúrcalo a tu propio repositorio. Es exactamente el mismo razonamiento de la cadena de suministro de software de 06-01.

  1. Importar lo que ya existe

Este es el momento de aplicar el procedimiento de 06-05 con la herramienta real. Todo lo que Marta creó a mano en los módulos 2 y 3 tiene que pasar a estar gestionado sin destruir ni recrear nada.

La forma moderna, con bloques import declarativos:

# importaciones.tf — fichero temporal, se borra al terminar la migración
import {
  to = google_compute_network.principal
  id = "projects/alpinashop-prod/global/networks/alpinashop-vpc"
}

import {
  to = google_compute_subnetwork.web
  id = "projects/alpinashop-prod/regions/europe-west1/subnetworks/sn-web-euw1"
}

import {
  to = google_compute_firewall.permitir_salud
  id = "projects/alpinashop-prod/global/firewalls/fw-permitir-salud"
}

import {
  to = google_storage_bucket.catalogo
  id = "alpinashop-prod/alpinashop-catalogo"
}

Y el truco que ahorra la mayor parte del trabajo manual:

# Genera el HCL correspondiente a los recursos importados
terraform plan -generate-config-out=generado.tf

Terraform escribe en generado.tf la configuración de los recursos declarados en los bloques import, leída de la realidad. Después toca limpiarla —quitar campos calculados, sustituir literales por referencias, añadir comentarios— exactamente como se explicó en 06-05.

El procedimiento completo, con el ciclo que hay que respetar:

# 1. Importar y generar
terraform plan -generate-config-out=generado.tf

# 2. Revisar y limpiar generado.tf, integrarlo en los ficheros definitivos

# 3. Aplicar: registra los recursos en el estado SIN modificarlos
terraform apply

# 4. EL CRITERIO DE ÉXITO
terraform plan
# → "No changes. Your infrastructure matches the configuration."

# 5. Borrar importaciones.tf, que ya no hace falta

Consejos para que la migración no se atragante:

  • De tres en tres, no de cincuenta en cincuenta. Un plan con cincuenta recursos importados tiene cientos de líneas y las diferencias importantes se pierden. Importa un grupo pequeño, verifica plan vacío, avanza.
  • Empieza por lo inofensivo. Reglas de firewall y buckets vacíos primero; Cloud SQL, el último.
  • Todo primero en alpinashop-dev. Un error allí no cuesta nada.
  • prevent_destroy antes del primer apply en cualquier recurso con datos.
  • Un -/+ te para siempre. La regla del apartado 6, aplicada con más razón durante una importación.

El formato del identificador varía por recurso y está documentado al final de la página de cada uno en el proveedor google. Es el detalle más tedioso, y no hay atajo.

  1. Terraform en CI/CD: plan en cada pull request

Aquí converge todo el módulo: el repositorio de 06-02, el pipeline de 06-01 y la infraestructura como código de 06-05.

El flujo que AlpinaShop implanta:

flowchart TD
    A[Marta abre PR<br/>en alpinashop-infra] --> B[Cloud Build:<br/>fmt, validate, plan]
    B --> C[El plan se publica<br/>como comentario del PR]
    C --> D{Revisión:<br/>CODEOWNERS de seguridad}
    D -->|Cambios pedidos| A
    D -->|Aprobado| E[Merge a main]
    E --> F[Disparador de apply<br/>con --require-approval]
    F --> G{Marta aprueba}
    G -->|Sí| H[terraform apply]
    G -->|No| I[No se aplica]

El pipeline de plan, que corre en cada pull request:

# cloudbuild-plan.yaml
steps:
  - name: 'hashicorp/terraform:1.9'
    id: 'formato-y-validacion'
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform fmt -check -recursive -diff || {
          echo "El código no está formateado. Ejecuta: terraform fmt -recursive"
          exit 1
        }
        terraform init -input=false
        terraform validate

  - name: 'hashicorp/terraform:1.9'
    id: 'plan'
    waitFor: ['formato-y-validacion']
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform plan -input=false -no-color \
          -var-file=prod.tfvars -out=/workspace/prod.tfplan | tee /workspace/plan.txt

        # Guarda de seguridad: avisar si el plan destruye algo
        if grep -qE '^Plan:.*to destroy' /workspace/plan.txt; then
          echo "=========================================="
          echo "ATENCION: este plan DESTRUYE recursos."
          echo "Revisión obligatoria de gcp-seguridad@."
          echo "=========================================="
          grep -E '^\s+#.*(destroyed|must be replaced)' /workspace/plan.txt
        fi

  - name: 'gcr.io/cloud-builders/curl'
    id: 'publicar-en-pr'
    waitFor: ['plan']
    entrypoint: 'bash'
    args: ['-c', 'scripts/comentar-pr.sh /workspace/plan.txt']

artifacts:
  objects:
    location: 'gs://alpinashop-artefactos/planes/$BUILD_ID/'
    paths: ['prod.tfplan', 'plan.txt']

options:
  logging: CLOUD_LOGGING_ONLY

El paso que publica el plan como comentario del pull request es el que cambia la cultura del equipo. Sin él, revisar un cambio de infraestructura obliga a leer HCL e imaginar el efecto. Con él, el revisor ve exactamente qué recursos se crean, se modifican y se destruyen, sin ejecutar nada. Es la aplicación literal del principio de 06-05: revisar la infraestructura como se revisa el código.

El pipeline de apply, con aprobación manual:

# cloudbuild-apply.yaml
steps:
  - name: 'hashicorp/terraform:1.9'
    entrypoint: 'sh'
    dir: 'entornos/prod'
    args:
      - '-c'
      - |
        terraform init -input=false
        terraform apply -input=false -auto-approve /workspace/prod.tfplan
timeout: '3600s'

Fíjate en que aplica el fichero de plan generado en el PR, no uno recalculado. Es lo que garantiza que se ejecuta exactamente lo aprobado. Y el -auto-approve no es peligroso aquí precisamente por eso: la aprobación humana ya ocurrió, en el disparador --require-approval de 06-01.

Y sin ninguna clave. La cuenta de servicio del pipeline se autentica con la identidad de Cloud Build; si el apply corriera en GitHub Actions, sería con Workload Identity Federation de 06-02. En ningún punto de este flujo existe un fichero JSON de credenciales.

Los permisos de la cuenta de servicio de apply merecen una nota: es la cuenta con más poder de toda la infraestructura, porque puede modificar redes, IAM y bases de datos. Debe estar acotada a los recursos que gestiona, no ser Editor del proyecto, y su uso debe estar auditado. Es la misma advertencia de 06-01 elevada un nivel.

  1. Qué NO debe gestionar Terraform

Tan importante como saber qué gestionar es saber qué dejar fuera. La regla, ya enunciada en 06-05: Terraform gestiona la forma, no el contenido.

No gestionar Por qué Quién lo gestiona
Valores de secretos Acabarían en claro en el estado Secret Manager, valor creado fuera (03-06)
Objetos de un bucket Son datos, cambian constantemente La aplicación
Filas y esquema de la BD Migraciones versionadas El pipeline de la app (06-01)
Instancias de un MIG Las gestiona el propio MIG El autoescalador
Pods y Deployments Ciclo de vida distinto Manifiestos y GKE (02-05)
Tablas creadas por pipelines Nacen y mueren solas Dataflow, BigQuery (04-02)
Modelos y artefactos de ML Los produce el pipeline Vertex AI (05-07)

La primera fila es la más importante y la más incumplida. Mira la diferencia:

# MAL: la contraseña acaba en claro en el fichero de estado
resource "google_sql_user" "app" {
  name     = "catalogo"
  instance = google_sql_database_instance.pedidos.name
  password = "P4ssw0rd-real"     # ← en el .tfstate, en claro
}

# BIEN: Terraform crea el CONTENEDOR del secreto; el valor se pone fuera
resource "google_secret_manager_secret" "db_password" {
  secret_id = "db-password-catalogo"
  replication {
    user_managed {
      replicas { location = "europe-west1" }
    }
  }
}
# Y el valor se establece con gcloud o desde la aplicación:
#   gcloud secrets versions add db-password-catalogo --data-file=-

Y un caso intermedio que conviene resolver bien: una contraseña generada aleatoriamente. random_password también queda en el estado, así que la solución correcta es que la genere el proveedor —Cloud SQL puede hacerlo— o un proceso externo, y que Terraform solo cree el contenedor.

La pregunta que resuelve las dudas: ¿esto cambia por sí solo, o cambia porque alguien lo decide? Lo que cambia solo —datos, instancias autoescaladas, tablas de pipelines— no es de Terraform. Lo que cambia porque alguien lo decide, sí.

  1. prevent_destroy y el peligro de terraform destroy

terraform destroy destruye todo lo que hay en el estado. Es una operación legítima para entornos efímeros y una catástrofe en producción, y hay tres formas de ejecutarla sin querer: escribirla en el directorio equivocado, un apply con un -/+ no revisado, y borrar recursos del código sin darse cuenta de que borrarlos significa destruirlos.

La protección de primer nivel, prevent_destroy, en todo lo que contenga datos:

resource "google_sql_database_instance" "pedidos" {
  name             = "alpinashop-pedidos"
  database_version = "POSTGRES_15"
  region           = var.region

  settings {
    tier              = var.tamano_instancia_sql
    availability_type = var.entorno == "prod" ? "REGIONAL" : "ZONAL"

    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
      start_time                     = "03:00"
    }
  }

  # Protección propia de la API de Cloud SQL, independiente de Terraform
  deletion_protection = true

  lifecycle {
    # Cualquier plan que destruya este recurso FALLA con un error explícito
    prevent_destroy = true
  }
}

Con prevent_destroy = true, un plan que intente destruir el recurso no se ejecuta:

Error: Instance cannot be destroyed
Resource google_sql_database_instance.pedidos has lifecycle.prevent_destroy set,
but the plan calls for this resource to be destroyed.

Es un error de la herramienta, no un aviso. Para destruirlo de verdad hay que editar el código, quitar la protección y volver a aplicar: dos pasos deliberados en lugar de uno accidental.

Las cinco capas de protección de AlpinaShop, defensa en profundidad:

Capa Qué protege Contra qué
prevent_destroy Recursos con datos destroy accidental y -/+
deletion_protection Cloud SQL y GKE Borrado por cualquier vía, incluida la consola
Revisión del plan en el PR Todo Errores humanos, con otro par de ojos
Aprobación manual del apply Producción Aplicar algo no revisado
IAM restrictivo Producción Que quien no debe pueda aplicar

Y una precisión sobre el alcance de prevent_destroy: protege el recurso, no el estado. Si alguien borra el bloque del código, Terraform no lo ve y no protege nada. La protección real la dan las capas 3, 4 y 5 juntas.

  1. Infrastructure Manager y alternativas

Ejecutar Terraform tú mismo implica gestionar el bucket del estado, el bloqueo, las versiones y los permisos del pipeline. Infrastructure Manager es el servicio gestionado de Google que hace todo eso ejecutando Terraform por debajo:

gcloud infra-manager deployments apply \
  projects/alpinashop-prod/locations/europe-west1/deployments/red-prod \
  --service-account=projects/alpinashop-prod/serviceAccounts/[email protected] \
  --git-source-repo=https://github.com/alpinashop/alpinashop-infra \
  --git-source-directory=entornos/prod \
  --git-source-ref=main \
  --input-values=entorno=prod
Aspecto Terraform propio Infrastructure Manager
Estado Tu bucket, tu responsabilidad Gestionado
Bloqueo Automático en GCS Gestionado
Autenticación Cuenta de servicio del pipeline Nativa de GCP
Historial En Cloud Build En el propio servicio
Multiproveedor Sí, pero pensado para GCP
Flexibilidad del pipeline Total Menor

Para AlpinaShop, que ya tiene el pipeline de 06-01 funcionando y quiere control total sobre el flujo, Terraform propio es la elección. Infrastructure Manager es excelente para equipos que prefieren no gestionar el estado y viven exclusivamente en GCP.

Las alternativas al propio Terraform, para tener el mapa completo:

Herramienta Modelo Fuerte en Débil en
Terraform / OpenTofu HCL declarativo Estándar, ecosistema, multiproveedor Estado propio, HCL limitado como lenguaje
Pulumi Python, TypeScript, Go Lenguajes reales, bucles y tipos Ecosistema menor, riesgo de código demasiado listo
Config Connector CRD de Kubernetes Reconciliación continua, GitOps Requiere clúster; solo GCP
Crossplane CRD de Kubernetes Multiproveedor, plataformas internas Complejo, para organizaciones grandes
CDK for Terraform Lenguajes sobre Terraform Combina ambos mundos Capa adicional de indirección

Recomendación para AlpinaShop: Terraform, por la razón que abre la lección —el ecosistema y la gente que ya lo conoce—. Y una observación sobre Pulumi que merece decirse: escribir infraestructura en un lenguaje de propósito general es tentador y tiene un riesgo real, porque es fácil escribir infraestructura demasiado inteligente. La limitación de HCL es en parte una virtud: obliga a que la infraestructura sea legible por alguien que no escribió el código.

  1. El estado final de AlpinaShop

Merece la pena mirar el antes y el después, porque el recorrido ha sido largo:

Aspecto Antes del módulo 6 Ahora
Código Portátil de Dani y Drive GitHub, revisado, con protección de ramas
Despliegue docker build a mano, etiqueta latest Cloud Build, imagen etiquetada con el SHA
Pruebas "Opcionales según las prisas" Obligatorias, bloquean el merge
Producción kubectl set image desde un portátil Promoción con aprobación manual
Eventos Sin implementar Cloud Functions, con idempotencia y DLQ
Saber si funciona Correo de un cliente Alertas en 5 minutos, panel, uptime checks
Diagnosticar Buscar texto en logs Logs estructurados, trazas, 17 minutos a la causa
Infraestructura Historial del terminal de Marta HCL versionado, revisado y reproducible
Reconstruir el entorno Nadie sabe terraform apply, unos veinte minutos
Deriva entre entornos Real y desconocida El mismo código, distintos tfvars
Quién puede desplegar Solo Dani Cualquiera abre un PR; aprueba quien debe

Ese cambio de la última fila es el resumen cultural del módulo. El conocimiento ha salido de las cabezas y ha entrado en el repositorio.

Errores Comunes y Consejos

Subir el .tfstate a Git. Contiene secretos en claro y Git no olvida. Backend remoto en Cloud Storage con versionado, y .gitignore desde el primer commit.

No fijar la versión del proveedor ni de los módulos. Un init en el pipeline puede traer una versión nueva que cambie recursos de producción sin que nadie lo haya decidido. version = "~> 6.0" siempre.

Ejecutar apply sin fichero de plan. Recalcula el plan y puede aplicar algo distinto de lo revisado. plan -out=fichero y apply fichero.

Ignorar un -/+. Es el error más caro de la herramienta. Un reemplazo sobre Cloud SQL o sobre un bucket con contenido destruye datos. Para siempre y entiende qué atributo lo fuerza.

Gestionar secretos con Terraform. El valor acaba en claro en el estado. Terraform crea el contenedor en Secret Manager; el valor se establece fuera.

Borrar recursos del código pensando que "dejan de gestionarse". Borrarlos del código significa destruirlos. Para dejar de gestionar sin destruir: terraform state rm.

Usar workspaces para separar producción de desarrollo. La única barrera es acordarse de workspace select, y algún día alguien no se acordará. Carpetas por entorno.

Crear módulos demasiado pronto. Un módulo con veinte variables para cubrir todos los casos es peor que el código plano. Extrae un módulo la segunda vez que repitas algo.

Importar cincuenta recursos de golpe. El plan resultante es ilegible y las diferencias peligrosas se pierden. De tres en tres, verificando plan vacío.

Olvidar prevent_destroy en recursos con datos. Son dos líneas y pueden salvar la base de datos.

Seguir tocando cosas a mano. El pecado original de la infraestructura como código. En cuanto el código y la realidad divergen, la herramienta deja de ser fiable y vuelves al punto de partida.

Consejo final: empieza por lo nuevo. Si la migración de todo lo existente parece inabordable, adopta la regla de que a partir de hoy, todo lo nuevo se crea con Terraform, y ve importando lo viejo cuando lo toques. En unos meses, la mayor parte estará gestionada sin haber hecho nunca un proyecto de migración.

Ejercicios

Ejercicio 1: escribir un módulo reutilizable

Escribe un módulo bucket-alpinashop que cree un bucket de Cloud Storage con las convenciones de AlpinaShop: acceso uniforme, prevención de acceso público, versionado configurable, las cuatro etiquetas obligatorias (entorno, equipo, centro-coste, aplicacion), una regla de ciclo de vida parametrizable y prevent_destroy para producción. Escribe sus variables con validación, sus salidas, y el bloque que lo invoca para crear alpinashop-datalake en producción. Explica qué decidiste parametrizar y qué decidiste fijar, y por qué.

Ejercicio 2: analizar un plan peligroso

Un pull request de alpinashop-infra genera este plan:

  # google_compute_firewall.permitir_ssh will be destroyed
  - resource "google_compute_firewall" "permitir_ssh" { ... }

  # google_compute_subnetwork.datos must be replaced
-/+ resource "google_compute_subnetwork" "datos" {
      ~ ip_cidr_range = "10.20.0.0/24" -> "10.20.0.0/22" # forces replacement
    }

  # google_sql_database_instance.pedidos will be updated in-place
  ~ resource "google_sql_database_instance" "pedidos" {
      ~ settings {
          ~ tier = "db-custom-4-15360" -> "db-custom-2-7680"
        }
    }

  # google_storage_bucket.datalake will be created
  + resource "google_storage_bucket" "datalake" { ... }

Plan: 1 to add, 1 to change, 1 to destroy, 1 to replace.

Analiza cada cambio, clasifícalo por riesgo, di si aprobarías el PR y qué pedirías al autor. Indica cuál es el más peligroso y por qué.

Ejercicio 3: diseñar el flujo completo de infraestructura como código

Marta te pide diseñar de principio a fin el flujo de trabajo de infraestructura de AlpinaShop, dando por hecho que la migración de 06-05 ya está completa. Define: la estructura del repositorio, la configuración del backend y la separación de estados, los pipelines de Cloud Build necesarios, los permisos IAM de cada cuenta de servicio implicada, las protecciones de rama y CODEOWNERS, y el procedimiento de emergencia para cuando haya que cambiar algo en producción a las 3 de la madrugada con el pipeline caído. Justifica las decisiones de seguridad.

Soluciones

Solución 1

# modulos/bucket-alpinashop/variables.tf
variable "nombre" {
  description = "Nombre del bucket, único globalmente"
  type        = string
  validation {
    condition     = can(regex("^alpinashop-[a-z0-9-]+$", var.nombre))
    error_message = "El nombre debe empezar por 'alpinashop-' y usar minúsculas."
  }
}

variable "entorno" {
  type = string
  validation {
    condition     = contains(["dev", "prod"], var.entorno)
    error_message = "El entorno debe ser 'dev' o 'prod'."
  }
}

variable "equipo"       { type = string }
variable "centro_coste" { type = string }
variable "aplicacion"   { type = string }

variable "region" {
  type    = string
  default = "europe-west1"
}

variable "versionado" {
  description = "Activar el versionado de objetos"
  type        = bool
  default     = true
}

variable "dias_a_nearline" {
  description = "Días tras los que pasar a NEARLINE; 0 desactiva la regla"
  type        = number
  default     = 90
  validation {
    condition     = var.dias_a_nearline >= 0
    error_message = "Debe ser 0 o un número positivo de días."
  }
}

variable "dias_borrado" {
  description = "Días tras los que borrar; 0 = no borrar nunca"
  type        = number
  default     = 0
}
# modulos/bucket-alpinashop/main.tf
resource "google_storage_bucket" "esta" {
  name     = var.nombre
  location = var.region

  # FIJOS: son política de AlpinaShop, no decisiones por bucket
  uniform_bucket_level_access = true
  public_access_prevention    = "enforced"

  versioning { enabled = var.versionado }

  dynamic "lifecycle_rule" {
    for_each = var.dias_a_nearline > 0 ? [1] : []
    content {
      condition { age = var.dias_a_nearline }
      action {
        type          = "SetStorageClass"
        storage_class = "NEARLINE"
      }
    }
  }

  dynamic "lifecycle_rule" {
    for_each = var.dias_borrado > 0 ? [1] : []
    content {
      condition { age = var.dias_borrado }
      action { type = "Delete" }
    }
  }

  # Limpieza de versiones antiguas si hay versionado
  dynamic "lifecycle_rule" {
    for_each = var.versionado ? [1] : []
    content {
      condition {
        with_state         = "ARCHIVED"
        num_newer_versions = 3
        age                = 30
      }
      action { type = "Delete" }
    }
  }

  labels = {
    entorno      = var.entorno
    equipo       = var.equipo
    centro-coste = var.centro_coste
    aplicacion   = var.aplicacion
  }

  lifecycle {
    prevent_destroy = true
  }
}
# modulos/bucket-alpinashop/outputs.tf
output "nombre" { value = google_storage_bucket.esta.name }
output "url"    { value = google_storage_bucket.esta.url }
output "id"     { value = google_storage_bucket.esta.id }
# entornos/prod/almacenamiento.tf
module "bucket_datalake" {
  source = "../../modulos/bucket-alpinashop"

  nombre          = "alpinashop-datalake"
  entorno         = "prod"
  equipo          = "datos"
  centro_coste    = "analitica"
  aplicacion      = "datalake"
  versionado      = true
  dias_a_nearline = 60
  dias_borrado    = 0        # el data lake no se borra solo
}

Qué parametricé y por qué:

Elemento Decisión Razón
Nombre, etiquetas Parámetro Distinto en cada bucket, obviamente
Versionado Parámetro Un bucket de logs efímeros no lo necesita
Días de ciclo de vida Parámetro El data lake y las imágenes tienen patrones distintos
Región Parámetro con defecto Casi siempre europe-west1, pero puede variar
Acceso uniforme FIJO Política de seguridad, no una opción
Prevención de acceso público FIJO Igual: nunca hay motivo para desactivarlo
prevent_destroy FIJO Ver la discusión de abajo

El principio de diseño: se parametriza lo que varía legítimamente entre casos; se fija lo que es una decisión de la organización. Convertir public_access_prevention en variable sería abrir la puerta a que alguien, con prisa, cree un bucket público "solo para una prueba". Si en algún momento hiciera falta un bucket público de verdad —un sitio estático—, se declara con google_storage_bucket directamente y ese caso excepcional recibe una revisión específica. Un módulo también sirve para hacer difícil lo que no debería hacerse.

Sobre prevent_destroy, una decisión discutible que conviene razonar. Lo he fijado a true siempre, en lugar de parametrizarlo por entorno. El motivo es que prevent_destroy no admite expresiones: el bloque lifecycle no puede usar variables, así que prevent_destroy = var.entorno == "prod" da error de sintaxis. Es una limitación real de Terraform. Las opciones son fijarlo a true para todos —lo que obliga a un paso manual para borrar un bucket de pruebas, molesto pero seguro— o crear dos módulos. He elegido lo primero: la molestia de borrar a mano un bucket de desarrollo es mucho menor que el riesgo de destruir uno de producción.

Y una advertencia sobre dynamic: los bloques dinámicos son potentes y hacen el código más difícil de leer. Con tres reglas de ciclo de vida condicionales, el módulo empieza a acercarse al límite de lo razonable. Si crecieran a ocho, sería mejor exponer una variable de lista de reglas y dejar que quien lo usa las declare explícitamente.

Solución 2

Análisis cambio a cambio:

# Cambio Riesgo Diagnóstico
1 - destruir fw-permitir-ssh Medio Puede ser intencionado y bueno, o un descuido
2 -/+ reemplazar sn-datos CRÍTICO Destruye la subred de la base de datos
3 ~ reducir el tier de Cloud SQL Alto No destruye datos, pero degrada producción
4 + crear el bucket datalake Bajo Un recurso nuevo, sin efectos colaterales

Cambio 1 — destruir la regla de SSH. Es el único cambio que podría ser una mejora: si esa regla permitía SSH desde rangos amplios, eliminarla es exactamente lo que recomienda 03-01. Pero un - destroy en un plan siempre exige preguntar por qué desaparece. Hay dos causas posibles con implicaciones opuestas: que el autor la haya borrado deliberadamente del código, o que la haya borrado por accidente al editar el fichero. Lo que hay que pedir: que la descripción del pull request lo explique, y confirmación de que nadie depende de ese acceso —un proveedor, un proceso de copias, un acceso de soporte—. Si nadie lo sabe, la alternativa segura es restringir el origen en lugar de eliminar la regla, y observar durante una semana con los logs de firewall de 06-06 antes de retirarla.

Cambio 2 — el reemplazo de la subred. Este es el que hace que el PR se rechace.

Ampliar 10.20.0.0/24 a 10.20.0.0/22 es una operación aparentemente razonable —más direcciones disponibles— y la API de GCP no permite cambiar el rango de una subred en caliente cuando hay recursos dentro. Terraform, sin más opciones, propone destruirla y crearla.

Lo que ocurriría al aplicar: la destrucción de sn-datos-euw1 afecta a todo lo que vive en ella, empezando por la instancia de Cloud SQL alpinashop-pedidos con su IP privada. En el mejor caso, la operación falla a mitad porque GCP se niega a borrar una subred con recursos, y el estado queda incoherente —Terraform cree haber empezado una operación que no puede terminar—. En el peor, si algo hubiera podido borrarse, las IP privadas cambiarían y todo lo que las referencia dejaría de funcionar. En ninguno de los dos casos el resultado es aceptable.

Y hay una alternativa que hace el reemplazo completamente innecesario, y es lo que hay que proponer al autor: GCP permite ampliar el rango de una subred existente sin recrearla, mediante gcloud compute networks subnets expand-ip-range. La operación es no destructiva y solo admite ampliar, nunca reducir:

gcloud compute networks subnets expand-ip-range sn-datos-euw1 \
  --region=europe-west1 --prefix-length=22 --project=alpinashop-prod

Después se actualiza el ip_cidr_range en el código y terraform plan sale vacío, porque la realidad ya coincide. Es un caso donde la operación correcta se hace fuera de Terraform y el código se limita a reflejarla, y conviene reconocerlo: forzar a la herramienta a hacer algo que la API no soporta bien es peor que hacer una excepción documentada.

Cambio 3 — reducir el tier de Cloud SQL. No destruye datos y por eso mucha gente lo daría por bueno. Pero pasar de db-custom-4-15360 a db-custom-2-7680 reduce a la mitad la CPU y la memoria de la base de datos de producción, y tiene dos consecuencias inmediatas: un reinicio de la instancia, es decir, corte de servicio de unos minutos, y una capacidad sensiblemente menor para el mismo tráfico.

Las preguntas al autor: ¿es intencionado o es un tfvars equivocado? Y si es intencionado, ¿está justificado con datos? Aquí es donde 06-04 entra en juego: si la CPU de Cloud SQL lleva meses por debajo del 20 %, es un ahorro perfectamente razonable. Si está al 60 %, reducir a la mitad la capacidad es provocar el próximo incidente. Un cambio de dimensionamiento sin la métrica que lo respalda es una conjetura. Y en cualquier caso, debe aplicarse en una ventana de mantenimiento, no un martes a las once de la mañana.

Una sospecha razonable, dicho sea de paso: que este cambio provenga de haber aplicado dev.tfvars sobre producción por error, porque db-custom-2-7680 tiene toda la pinta de ser el valor de desarrollo. Merece la pena comprobarlo.

Cambio 4 — crear el bucket. El único inofensivo. Se revisa que tenga acceso uniforme, prevención de acceso público, las cuatro etiquetas y su ciclo de vida, y ya está.

¿Aprobaría el PR? No. Y lo que pediría, en este orden:

  1. Dividir el pull request en tres. Este PR mezcla una mejora de seguridad, un cambio de red, un cambio de dimensionamiento y un recurso nuevo. Son cuatro intenciones distintas, con riesgos y momentos de aplicación distintos. Un PR con una sola intención se revisa bien, se aprueba rápido y se revierte limpiamente si sale mal.
  2. Sacar la ampliación de la subred de Terraform, hacerla con expand-ip-range y actualizar el código después.
  3. Justificar el cambio de tier con la métrica de CPU de los últimos dos meses, y programarlo en una ventana de mantenimiento.
  4. Explicar en la descripción por qué desaparece la regla de SSH y confirmar que nadie depende de ella.

El más peligroso es el cambio 2, sin discusión, y por una razón que conviene explicitar: es el único irreversible. El cambio 3 se deshace volviendo al tier anterior; el cambio 1 se deshace recreando la regla; el cambio 4 se deshace borrando el bucket. Un recurso destruido no vuelve, y con él se lleva por delante las direcciones IP privadas de todo lo que contenía.

Y la lección de procedimiento: este plan es exactamente el argumento a favor de publicar el plan como comentario del pull request. Leyendo solo el HCL modificado, el cambio de /24 a /22 parece una edición menor de un carácter. Es el plan el que revela que ese carácter destruye la subred de la base de datos.

Solución 3

Estructura del repositorio alpinashop-infra:

alpinashop-infra/
├── modulos/
│   ├── red-alpinashop/
│   ├── bucket-alpinashop/
│   └── servicio-web/
├── entornos/
│   ├── dev/
│   │   ├── backend.tf        # prefix = "dev/"
│   │   ├── main.tf
│   │   ├── red.tf
│   │   ├── datos.tf
│   │   └── dev.tfvars
│   └── prod/
│       ├── backend.tf        # prefix = "prod/"
│       └── ... (misma estructura)
├── paneles/                  # JSON de 06-04
├── alertas/                  # políticas de 06-04
├── cloudbuild-plan.yaml
├── cloudbuild-apply.yaml
├── .pre-commit-config.yaml
├── CODEOWNERS
└── .gitignore

Backend y separación de estados. Bucket alpinashop-terraform-estado en alpinashop-cicd, con versionado, acceso uniforme y prevención de acceso público. Y estados separados por entorno y por dominio:

Prefijo Contiene Motivo de la separación
prod/red VPC, subredes, firewall, NAT, DNS Cambia poco, radio de daño alto
prod/datos Cloud SQL, buckets, BigQuery Contiene datos: máxima protección
prod/cómputo MIG, GKE, balanceador Cambia a menudo
prod/observabilidad Paneles, alertas, sumideros Cambia mucho, riesgo nulo
dev/* Lo mismo para desarrollo Aislamiento total

La separación de estados es una decisión de seguridad, no de organización. Con un único estado, cualquier operación sobre cualquier recurso bloquea todo y un error tiene alcance total. Con estados separados, tocar un panel de Cloud Monitoring no puede afectar a la base de datos ni siquiera en el peor caso.

Pipelines:

Pipeline Disparador Qué hace Aprobación
infra-plan-dev PR contra main fmt, validate, plan de dev, comentario en el PR No
infra-plan-prod PR contra main Igual para prod No
infra-apply-dev Push a main apply en dev con el plan del PR No: automático
infra-apply-prod Manual tras el merge apply en prod Sí, --require-approval

Que desarrollo se aplique automáticamente es deliberado: es la forma de garantizar que alpinashop-dev refleja siempre main, que es justo lo que impide la deriva de 06-05. Producción exige aprobación por el impacto.

Permisos IAM:

Cuenta Roles Ámbito Justificación
sa-tf-plan viewer + storage.objectViewer sobre el estado prod y dev Solo lectura: un plan no modifica nada
sa-tf-apply-dev editor acotado Solo alpinashop-dev Sin acceso a producción
sa-tf-apply-prod Roles específicos por servicio Solo alpinashop-prod Nunca owner
Personas de gcp-infra@ viewer + roles/iam.serviceAccountTokenCreator prod Sin acceso directo de escritura

La decisión de seguridad más importante: sa-tf-plan es de solo lectura. El plan se ejecuta en cada pull request, y un pull request lo puede abrir cualquiera —incluido alguien con malas intenciones que modifique el cloudbuild-plan.yaml en su rama—. Si esa cuenta tuviera permisos de escritura, abrir un PR sería suficiente para modificar producción. Es exactamente el antipatrón que se explicó en 06-01, y aquí es aún más grave porque hablamos de infraestructura.

La segunda: las personas no tienen escritura directa en producción. Se impersona sa-tf-apply-prod con serviceAccountTokenCreator, lo que queda registrado en los logs de auditoría con el nombre de la persona. Es la aplicación literal de 03-04 y de "nadie es owner de prod".

Protección de ramas y CODEOWNERS:

# CODEOWNERS de alpinashop-infra
*                          @alpinashop/infraestructura
/entornos/prod/            @alpinashop/infraestructura @alpinashop/seguridad
/entornos/prod/red.tf      @alpinashop/seguridad
/entornos/*/iam.tf         @alpinashop/seguridad
/modulos/                  @alpinashop/infraestructura @alpinashop/seguridad

Sobre main: prohibido el push directo, una aprobación mínima (dos para entornos/prod/), CI en verde obligatoria, aprobaciones que caducan al haber cambios nuevos, historial lineal, sin excepciones para administradores y escaneo de secretos.

El procedimiento de emergencia, que es la parte del ejercicio que más gente resuelve mal.

La respuesta ingenua es "que Marta tenga permisos permanentes de emergencia". Es mala: un permiso permanente se acaba usando fuera de las emergencias y erosiona todo lo demás. La respuesta correcta tiene cuatro elementos:

1. Acceso de emergencia con impersonación registrada. Marta pertenece a un grupo gcp-emergencia@ que puede impersonar sa-tf-apply-prod sin pasar por el pipeline. No es un permiso oculto: es un camino documentado, auditado y con nombre.

gcloud config set auth/impersonate_service_account \
  [email protected]
cd entornos/prod
terraform plan -var-file=prod.tfvars -out=emergencia.tfplan   # PLAN SIEMPRE
terraform apply emergencia.tfplan

2. El plan no se salta ni en una emergencia. Es lo primero que la gente propone eliminar por prisa, y es exactamente al revés: a las 3 de la madrugada, con sueño y con presión, es cuando más falta hace ver qué se va a destruir. Cuesta veinte segundos y evita convertir un incidente en dos.

3. Alerta automática del uso del acceso de emergencia. Con el sumidero a Pub/Sub de 06-06, cualquier impersonación de sa-tf-apply-prod fuera del pipeline publica un mensaje que llega al canal de seguridad en el momento. No para impedirlo, sino para que todo el mundo sepa que ocurrió.

4. Regularización obligatoria en 24 horas. El cambio de emergencia se hizo con el código de main, así que el estado y el código coinciden solo si el cambio se hizo editando el código. Si se hizo con gcloud a mano, hay deriva. En ambos casos, en las 24 horas siguientes hay que abrir un pull request que documente el cambio y demuestre con un plan vacío que código y realidad coinciden de nuevo. Sin ese paso, la primera emergencia es el principio del regreso al caos, porque a partir de ahí nadie confía en que el código sea la verdad.

Y la reflexión que cierra el ejercicio. Un procedimiento de emergencia bien diseñado no elimina las protecciones: las sustituye por trazabilidad. En condiciones normales, la protección es preventiva —revisión, aprobación, permisos acotados—. En una emergencia, la protección es detectiva —alerta inmediata, registro de auditoría, regularización obligatoria—. Lo que nunca debe existir es un camino sin ninguna de las dos, porque ese camino se convierte en el habitual.

Conclusión

AlpinaShop tiene su infraestructura escrita, versionada, revisable y reproducible. La pregunta que abría 06-05 —cuánto se tarda en reconstruir el entorno— ya tiene respuesta: unos veinte minutos y un terraform apply.

Sabes por qué Terraform ganó: multiproveedor, ecosistema de módulos, madurez, comunidad, y el reconocimiento definitivo de que Infrastructure Manager es Terraform gestionado por Google. Con la nota honesta sobre la licencia y OpenTofu.

Dominas los conceptos: proveedor con su versión fijada porque no hacerlo es dejar que una actualización decida por ti; recurso con su nombre local distinto del nombre real; fuente de datos que solo lee; variable con validation; salida con sensitive, sabiendo que oculta pero no cifra; y dependencias implícitas que nacen de las referencias, con depends_on como último recurso.

Entiendes el estado de verdad: qué contiene —incluidas contraseñas en claro—, por qué nunca va a Git, y las cuatro reglas que se derivan de ello. Sabes montar el backend en Cloud Storage con versionado, bloqueo automático y prefijos que separan estados para limitar el radio de daño. Y sabes que .terraform.lock.hcl sí va a Git.

Manejas el flujo initvalidateplanapply, con el -out=fichero que garantiza que se aplica exactamente lo revisado. Y sobre todo sabes leer un plan: +, ~, - y el -/+ que debe pararte siempre, con la tabla de en qué recursos un reemplazo es aceptable y en cuáles significa perder datos.

Tienes el código real de AlpinaShop en HCL —la VPC, las subredes con private_ip_google_access, las reglas de firewall con los rangos de las sondas de Google explicados en un comentario, el bucket con su ciclo de vida y sus cuatro etiquetas— y con ello el conocimiento que vivía en la cabeza de Marta ha pasado al repositorio. Sabes parametrizar con variables y tfvars para que alpinashop-dev y alpinashop-prod compartan código, y por qué AlpinaShop elige carpetas por entorno frente a workspaces: porque la única barrera de un workspace es acordarse de seleccionarlo.

Sabes escribir un módulo con sus tres reglas —una responsabilidad, parametrizar lo que varía y fijar lo que es política, exponer las salidas necesarias— y cuándo consumir módulos del registro público, con la regla de que cuanto más complejo es el recurso más compensa, y las dos advertencias: fija la versión, y un módulo de terceros es código con permisos sobre tu infraestructura.

Sabes importar lo que ya existe con bloques import declarativos y -generate-config-out, de tres en tres, verificando el plan vacío, empezando por lo inofensivo y dejando Cloud SQL para el final. Tienes Terraform en el pipeline: plan automático en cada pull request publicado como comentario —lo que cambia la cultura del equipo, porque el revisor ve el efecto sin ejecutar nada—, apply solo tras aprobación y aplicando el plan ya revisado, y sin una sola clave JSON en todo el flujo.

Sabes qué no debe gestionar Terraform —valores de secretos, datos, objetos efímeros— con la regla que lo resume: gestiona la forma, no el contenido, y la pregunta que resuelve las dudas: ¿esto cambia solo, o porque alguien lo decide? Y tienes las cinco capas de protección contra el borrado accidental, con prevent_destroy y deletion_protection como las dos primeras y la revisión humana como la que de verdad importa.

Por último, conoces Infrastructure Manager como forma gestionada de ejecutarlo y el mapa de alternativas —Pulumi, Config Connector, Crossplane, CDKTF— con la observación de que la limitación de HCL es en parte una virtud, porque obliga a que la infraestructura sea legible.


Y aquí termina el módulo 6. Mira lo que ha cambiado.

Al empezar, AlpinaShop tenía una aplicación que se desplegaba desde un portátil con la etiqueta latest, unos manifiestos en Drive, una función pendiente desde el módulo 5, ninguna forma de saber si la tienda funcionaba salvo esperar el correo de un cliente, y una infraestructura que existía solo en el historial de un terminal.

Ahora hay entrega automatizada: el código en GitHub con revisión obligatoria, Cloud Build construyendo, probando y publicando imágenes etiquetadas con el commit, promoción a producción con aprobación, y el pipeline de ML por fin en nivel 2 de madurez. Hay piezas por eventos que reaccionan solas, con idempotencia, reintentos y dead letter. Hay observabilidad completa: métricas, paneles, alertas que avisan en cinco minutos, comprobaciones desde cuatro continentes, logs estructurados, trazas distribuidas y un recorrido probado que va de la alerta a la línea de código en diecisiete minutos. Y hay infraestructura como código: versionada, revisada en pull requests y reproducible.

El sistema ya no depende de que dos personas se acuerden de ejecutar las cosas.

Lo que queda son las decisiones que se fueron aplazando, y ahora hay base para tomarlas. El catálogo sigue en GKE, aunque DA-001 decidió hace tiempo que su sitio es Cloud Run —y ahora que hay pipeline, observabilidad e infraestructura como código, ese movimiento es por fin viable—. Hay un almacén físico con sistemas propios que alguien tendrá que integrar. Las redes se quedaron en lo básico y hay conversaciones pendientes sobre VPC compartida y conectividad híbrida. La seguridad se construyó pieza a pieza y nunca se revisó en conjunto. La factura ha crecido con cada módulo y nadie la ha mirado con detenimiento. Se han montado alertas, pero no se ha definido qué significa "funcionar bien": no hay SLO ni presupuestos de error. Y la organización tiene ya tres proyectos, cuatro repositorios, decenas de cuentas de servicio y ninguna política que gobierne el conjunto.

En el módulo 7, temas avanzados, se resuelven todos: Anthos para el híbrido y el multinube, Cloud Run para llevar el catálogo a donde siempre debió estar, redes avanzadas con VPC compartida, emparejamiento y conectividad híbrida, seguridad revisada de arriba abajo, gestión de costes para que la factura deje de ser una sorpresa mensual, fiabilidad con SLO y presupuestos de error que conviertan las alertas de 06-04 en objetivos medibles, y gobierno a escala con políticas de organización y auditoría.

AlpinaShop ya construye, despliega y se observa sola. Ahora toca hacerla robusta, económica y gobernable.

Curso de Google Cloud Platform (GCP)

Módulo 1: Introducción a Google Cloud Platform

Módulo 2: Servicios principales de GCP

Módulo 3: Redes y seguridad

Módulo 4: Datos y análisis

Módulo 5: Aprendizaje automático e IA

Módulo 6: DevOps y monitoreo

Módulo 7: Temas avanzados de GCP

Módulo 8: Proyecto final

© Copyright 2026. Todos los derechos reservados