Por qué conviene dominar la sintaxis de Terraform desde el principio
Terraform se ha convertido en una de las herramientas de referencia para gestionar infraestructura en la nube de forma declarativa. En lugar de crear servidores, redes y bases de datos haciendo clic en una consola web, describes el estado que quieres en archivos de texto y Terraform se encarga de calcular qué debe crear, modificar o eliminar para llegar a ese estado.
El problema es que muchas personas se lanzan a copiar módulos de internet sin entender el lenguaje en el que están escritos. Cuando algo falla, el mensaje de error resulta incomprensible y se pierde una tarde entera. Por eso, antes de construir arquitecturas complejas, merece la pena dedicar tiempo a la sintaxis de Terraform para principiantes y entender cada pieza: qué es un bloque, qué es un argumento, cómo se referencian unos recursos desde otros y qué herramientas ofrece el lenguaje para evitar duplicar código.
En esta guía usarás Terraform 1.16, la rama estable actual, y el proveedor de AWS en su serie 6. Los ejemplos crean recursos sencillos en Amazon Web Services, pero la sintaxis es idéntica para cualquier otro proveedor, ya sea Azure, Google Cloud, Kubernetes o GitHub.
Preparar el entorno de trabajo
Para seguir los ejemplos necesitas tres cosas: el binario de Terraform, credenciales de AWS y un editor con soporte para HCL. La forma más cómoda de instalar Terraform en Linux es mediante el repositorio oficial de HashiCorp. En Ubuntu o Debian:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
En macOS puedes usar Homebrew:
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
Comprueba la instalación:
terraform version
Terraform v1.16.7
on linux_amd64
Para las credenciales, lo más sencillo durante el aprendizaje es configurar un perfil con la CLI de AWS (aws configure) o exportar las variables de entorno AWS_ACCESS_KEY_ID y AWS_SECRET_ACCESS_KEY. Nunca escribas claves de acceso dentro de los archivos .tf: acabarían en el control de versiones.
Como editor, Visual Studio Code con la extensión oficial de HashiCorp ofrece resaltado de sintaxis, autocompletado de recursos y validación en tiempo real.
Anatomía del lenguaje: bloques, etiquetas y argumentos
Terraform utiliza HCL (HashiCorp Configuration Language), un lenguaje declarativo diseñado para ser legible por personas. Todo en HCL se construye con dos elementos: bloques y argumentos.
Un bloque es un contenedor que agrupa configuración. Empieza con un tipo, puede llevar una o varias etiquetas entre comillas y encierra su contenido entre llaves. Un argumento asigna un valor a un nombre dentro de un bloque usando el signo igual.
tipo_de_bloque "etiqueta_1" "etiqueta_2" {
argumento_texto = "valor"
argumento_numero = 3
argumento_bool = true
argumento_lista = ["a", "b", "c"]
bloque_anidado {
otro_argumento = "valor"
}
}
El número de etiquetas depende del tipo de bloque. Un bloque resource lleva dos: el tipo de recurso y el nombre local que le das. Un bloque provider lleva una: el nombre del proveedor. Un bloque locals no lleva ninguna.
HCL admite varios tipos de datos: cadenas (string), números (number), booleanos (bool), listas (list), conjuntos (set), mapas (map) y objetos (object). Los comentarios pueden escribirse con #, con // o, para varias líneas, entre /* y */.
Un detalle que provoca muchos errores al copiar código de blogs: las comillas deben ser rectas ("). Las comillas tipográficas que introducen algunos procesadores de texto (“ ”) hacen que el analizador falle con un error de sintaxis poco evidente.
Tu primera configuración: una instancia EC2
Vamos a crear una carpeta de proyecto y un archivo main.tf que lance una máquina virtual en AWS. En lugar de escribir a mano un identificador de imagen (AMI), que cambia con cada región y se queda obsoleto en pocos meses, usaremos un bloque data para buscar automáticamente la última imagen de Amazon Linux 2023.
terraform {
required_version = ">= 1.16"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "us-east-2"
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "servidor_web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "servidor-web-demo"
Proyecto = "aprendiendo-terraform"
}
}
Fíjate en la referencia data.aws_ami.amazon_linux.id. Las referencias siguen siempre el patrón tipo.nombre.atributo para recursos y data.tipo.nombre.atributo para fuentes de datos. Gracias a esta referencia, Terraform sabe que primero debe consultar la AMI y después crear la instancia.
El ciclo de trabajo básico consta de cuatro comandos:
terraform init # Descarga el proveedor y prepara el directorio
terraform plan # Muestra qué cambios se aplicarían
terraform apply # Ejecuta los cambios tras pedir confirmación
terraform destroy # Elimina todo lo creado
La salida de plan resume las acciones previstas:
Terraform will perform the following actions:
# aws_instance.servidor_web will be created
+ resource "aws_instance" "servidor_web" {
+ ami = "ami-0a1b2c3d4e5f67890"
+ instance_type = "t3.micro"
+ tags = {
+ "Name" = "servidor-web-demo"
+ "Proyecto" = "aprendiendo-terraform"
}
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
El símbolo + indica creación, ~ modificación en sitio, - eliminación y -/+ sustitución completa del recurso. Acostúmbrate a leer el plan con atención antes de confirmar cualquier apply.
El bloque terraform y el bloqueo de versiones
El bloque terraform del ejemplo anterior no crea nada en la nube: configura el comportamiento de la propia herramienta. Su argumento required_version impide que alguien ejecute el proyecto con una versión incompatible de Terraform, y el bloque required_providers declara de dónde se descarga cada proveedor y qué versiones se aceptan.
El operador ~> es el más habitual. ~> 6.0 acepta cualquier versión 6.x pero rechaza la 7.0, que podría traer cambios incompatibles. Es un equilibrio razonable entre recibir correcciones y evitar sorpresas.
Cuando ejecutas terraform init, se genera el archivo .terraform.lock.hcl, que registra la versión exacta descargada y sus sumas de verificación. Este archivo debe incluirse en el repositorio para que todo el equipo, y también la integración continua, use exactamente la misma versión del proveedor. Para actualizarlo de forma deliberada:
terraform init -upgrade
Proveedores: los traductores entre Terraform y la nube
Terraform por sí solo no sabe nada de AWS, Azure o Kubernetes. Toda esa lógica vive en los proveedores, complementos que se descargan desde el Terraform Registry durante terraform init. Cada proveedor traduce los bloques HCL en llamadas a la API correspondiente y define qué tipos de recursos y fuentes de datos están disponibles.
Esta arquitectura de complementos tiene una consecuencia práctica: el nombre de cada tipo de recurso empieza por el identificador de su proveedor seguido de un guion bajo. Por eso aws_instance pertenece al proveedor aws, azurerm_virtual_network al proveedor azurerm y kubernetes_deployment al proveedor kubernetes.
Los bloques de proveedores y recursos se relacionan de forma implícita: Terraform asigna cada recurso al proveedor cuyo nombre coincide con el prefijo de su tipo. Dentro del bloque provider se configuran opciones globales como la región, el perfil de credenciales o etiquetas por defecto que se añadirán a todos los recursos:
provider "aws" {
region = "us-east-2"
profile = "laboratorio"
default_tags {
tags = {
Entorno = "desarrollo"
GestionPor = "terraform"
}
}
}
El bloque default_tags ahorra mucho trabajo en cuentas grandes, porque garantiza que cada recurso quede etiquetado para facturación y auditoría sin tener que repetirlo en cada bloque.
Recursos y fuentes de datos
Los recursos son los objetos de infraestructura que Terraform crea y gestiona durante todo su ciclo de vida: instancias, buckets, redes, reglas de cortafuegos o registros DNS. Cuando eliminas un bloque resource del código y ejecutas apply, Terraform destruye el objeto correspondiente.
Las fuentes de datos, declaradas con bloques data, son de solo lectura. Consultan información existente que Terraform no gestiona, como la AMI más reciente, la VPC por defecto de la cuenta o la lista de zonas de disponibilidad. Son la herramienta adecuada para no escribir identificadores fijos en el código.
data "aws_availability_zones" "disponibles" {
state = "available"
}
resource "aws_subnet" "privada" {
vpc_id = aws_vpc.principal.id
cidr_block = "10.20.1.0/24"
availability_zone = data.aws_availability_zones.disponibles.names[0]
}
Cada recurso expone atributos que puedes leer una vez creado: id, arn, public_ip y muchos más. La documentación de cada tipo en el Registry enumera tanto los argumentos que aceptas como los atributos que devuelve.
Variables de entrada: configuraciones reutilizables
Escribir valores fijos como la región o el tipo de instancia funciona para un experimento, pero no para un proyecto real. Las variables de entrada en Terraform actúan como los parámetros de una función: definen qué puede personalizarse sin tocar la lógica principal.
variable "region" {
description = "Región de AWS donde se despliega la infraestructura"
type = string
default = "us-east-2"
}
variable "tipo_instancia" {
description = "Tamaño de la máquina virtual"
type = string
default = "t3.micro"
validation {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.tipo_instancia)
error_message = "Solo se permiten instancias t3.micro, t3.small o t3.medium."
}
}
variable "contrasena_bd" {
description = "Contraseña del usuario administrador de la base de datos"
type = string
sensitive = true
}
Las variables se referencian con el prefijo var., por ejemplo var.tipo_instancia. El bloque validation rechaza valores incorrectos antes de que lleguen a la API, y el argumento sensitive oculta el valor en la salida de plan y apply.
Hay varias formas de asignar valores, y Terraform las aplica con una precedencia definida, de menor a mayor: valores por defecto, variables de entorno con prefijo TF_VAR_, el archivo terraform.tfvars, archivos *.auto.tfvars y, por último, las opciones -var y -var-file en la línea de comandos.
# produccion.tfvars
region = "us-west-2"
tipo_instancia = "t3.medium"
export TF_VAR_contrasena_bd="cambia-esto"
terraform plan -var-file="produccion.tfvars"
Valores locales y outputs
Los valores locales, declarados en un bloque locals, sirven para calcular un valor una sola vez y reutilizarlo en varios lugares. Son ideales para nombres compuestos o etiquetas comunes:
locals {
prefijo = "tienda-${var.entorno}"
etiquetas_comunes = {
Equipo = "plataforma"
CentroCoste = "ecommerce"
}
}
resource "aws_s3_bucket" "catalogo" {
bucket = "${local.prefijo}-catalogo-imagenes"
tags = local.etiquetas_comunes
}
Observa que el bloque se llama locals, en plural, pero las referencias usan local., en singular. Es una confusión muy frecuente.
Los outputs, por su parte, exponen información cuando termina apply. Sirven para mostrar datos útiles en la terminal, como una IP pública, y para que otros módulos o proyectos consuman esos valores.
output "ip_publica_servidor" {
description = "IP pública de la instancia web"
value = aws_instance.servidor_web.public_ip
}
output "cadena_conexion_bd" {
value = "postgres://admin:${var.contrasena_bd}@${aws_db_instance.principal.address}"
sensitive = true
}
El argumento obligatorio de un output es value. Puedes consultar los outputs en cualquier momento:
terraform output
terraform output -raw ip_publica_servidor
cadena_conexion_bd = <sensitive>
ip_publica_servidor = "3.142.87.19"
Organizar el código en varios archivos
Terraform lee todos los archivos con extensión .tf de un directorio y los trata como una única configuración. El nombre de cada archivo no tiene ningún significado especial para la herramienta, pero seguir una convención facilita que cualquier persona se oriente en el proyecto:
infra-tienda/
├── versions.tf # Bloque terraform y required_providers
├── providers.tf # Configuración de proveedores
├── variables.tf # Declaración de variables
├── locals.tf # Valores locales
├── main.tf # Recursos principales
├── outputs.tf # Outputs
├── terraform.tfvars # Valores del entorno (fuera del control de versiones)
└── .terraform.lock.hcl # Versiones exactas de proveedores
Cuando el proyecto crece, el siguiente paso es agrupar recursos relacionados en módulos, carpetas reutilizables que se invocan con un bloque module y reciben parámetros igual que una función.
module "red" {
source = "./modules/red"
cidr_vpc = "10.20.0.0/16"
entorno = var.entorno
}
Meta-argumentos: controlar cómo se crean los recursos
Los meta-argumentos son argumentos especiales que funcionan en cualquier tipo de recurso, independientemente del proveedor. Modifican el comportamiento de Terraform en lugar de la configuración del objeto en la nube.
Dependencias explícitas con depends_on
Terraform deduce el orden de creación a partir de las referencias entre recursos. Cuando existe una dependencia que no se refleja en ninguna referencia, se declara explícitamente con depends_on. Un caso típico es una instancia que necesita que una política de IAM esté adjunta antes de arrancar:
resource "aws_instance" "procesador" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.tipo_instancia
depends_on = [aws_iam_role_policy_attachment.lectura_s3]
}
Úsalo con moderación: abusar de depends_on hace los planes más lentos y menos precisos.
Proteger recursos con el bloque lifecycle
El bloque lifecycle ajusta cómo Terraform reemplaza o protege un recurso:
resource "aws_db_instance" "principal" {
# ...
lifecycle {
prevent_destroy = true
create_before_destroy = true
ignore_changes = [tags["UltimaRevision"]]
}
}
prevent_destroy bloquea cualquier plan que intente eliminar el recurso, una red de seguridad imprescindible para bases de datos. create_before_destroy crea el sustituto antes de borrar el original, reduciendo cortes de servicio. ignore_changes indica qué atributos pueden cambiar fuera de Terraform sin que este intente revertirlos.
Varias regiones con alias de proveedor
Si necesitas desplegar en dos regiones desde el mismo proyecto, defines un segundo bloque provider con un alias y lo seleccionas en el recurso:
provider "aws" {
alias = "europa"
region = "eu-west-1"
}
resource "aws_s3_bucket" "replica" {
provider = aws.europa
bucket = "tienda-replica-eu"
}
Desde la versión 6 del proveedor de AWS existe una alternativa más directa para muchos recursos: el argumento region a nivel de recurso, que evita declarar un proveedor adicional por cada región. Los alias siguen siendo necesarios cuando lo que cambia son las credenciales o la cuenta de destino.
Crear múltiples recursos con count y for_each
Los meta-argumentos count y for_each permiten crear varias copias de un recurso sin duplicar bloques. count recibe un número y expone el índice mediante count.index:
resource "aws_instance" "trabajador" {
count = 3
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "trabajador-${count.index + 1}"
}
}
Estos recursos se direccionan como aws_instance.trabajador[0], aws_instance.trabajador[1], etcétera. El inconveniente de count aparece al eliminar un elemento intermedio: los índices se desplazan y Terraform puede querer recrear recursos que no han cambiado.
for_each resuelve ese problema identificando cada instancia por una clave estable. Acepta un mapa o un conjunto de cadenas y expone each.key y each.value:
variable "colas" {
type = map(number)
default = {
pedidos = 300
notificaciones = 60
facturacion = 900
}
}
resource "aws_sqs_queue" "cola" {
for_each = var.colas
name = "tienda-${each.key}"
visibility_timeout_seconds = each.value
}
Ahora los recursos se llaman aws_sqs_queue.cola["pedidos"] o aws_sqs_queue.cola["facturacion"], y eliminar una entrada del mapa solo afecta a esa cola. Como regla práctica, usa count para crear copias idénticas o para activar y desactivar un recurso con count = var.habilitar ? 1 : 0, y for_each en todos los demás casos.
Expresiones, funciones y operadores splat
Las expresiones hacen que una configuración sea dinámica. Además de las referencias que ya conoces, HCL ofrece interpolación de cadenas, operadores aritméticos y lógicos, condicionales y expresiones for:
locals {
es_produccion = var.entorno == "produccion"
tipo_final = local.es_produccion ? "t3.medium" : "t3.micro"
nombres_colas = [for nombre, _ in var.colas : upper(nombre)]
colas_lentas = { for nombre, segundos in var.colas : nombre => segundos if segundos > 120 }
}
La expresión splat [*] extrae un atributo de todos los elementos de una lista. Con los recursos creados con count resulta muy cómoda:
output "ips_trabajadores" {
value = aws_instance.trabajador[*].private_ip
}
Para recursos creados con for_each, que se comportan como mapas, se usa una expresión for en su lugar:
output "urls_colas" {
value = { for clave, cola in aws_sqs_queue.cola : clave => cola.url }
}
Terraform incluye un amplio catálogo de funciones integradas: de cadenas (upper, replace, format), numéricas (max, ceil), de colecciones (merge, lookup, flatten), de archivos (file, templatefile), de codificación (jsonencode, base64encode) y de red (cidrsubnet). No es posible definir funciones propias en HCL, pero las existentes cubren la gran mayoría de necesidades. La consola interactiva es perfecta para experimentar con ellas:
terraform console
> cidrsubnet("10.20.0.0/16", 8, 3)
"10.20.3.0/24"
> merge({a = 1}, {b = 2})
{
"a" = 1
"b" = 2
}
Inicializar máquinas: user_data frente a provisioners
Una vez creada una instancia, normalmente quieres instalar software o ajustar su configuración inicial. Terraform ofrece provisioners como remote-exec o local-exec, que ejecutan comandos tras crear el recurso. Sin embargo, la propia documentación oficial los presenta como último recurso, porque su resultado no queda reflejado en el estado y fallan con facilidad por problemas de red o SSH.
La alternativa recomendada en AWS es el argumento user_data, que la propia instancia ejecuta en el primer arranque mediante cloud-init:
resource "aws_instance" "servidor_web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.tipo_instancia
user_data = templatefile("${path.module}/scripts/arranque.sh.tftpl", {
puerto = 8080
})
}
#!/bin/bash
# scripts/arranque.sh.tftpl
dnf install -y nginx
sed -i "s/listen 80;/listen ${puerto};/" /etc/nginx/nginx.conf
systemctl enable --now nginx
Cuando sí necesitas ejecutar algo desde la máquina local, la opción moderna es asociar el provisioner a un recurso terraform_data, que sustituye al antiguo null_resource sin requerir un proveedor externo. Para la configuración continua de servidores, herramientas como Ansible siguen siendo la elección adecuada.
Errores comunes y cómo evitarlos
Algunos fallos aparecen una y otra vez en proyectos que empiezan:
- Escribir AMI, IDs de subred o ARN fijos en el código. Cambian entre regiones y cuentas; usa fuentes de datos o variables.
- No fijar la versión del proveedor. Sin
required_providers, una actualización mayor puede romper el proyecto de un día para otro. - Subir
terraform.tfstateo archivos.tfvarscon secretos al repositorio. El estado puede contener contraseñas en texto plano. - Usar
countsobre listas que cambian a menudo, lo que provoca recreaciones inesperadas. - Modificar recursos a mano en la consola. La siguiente ejecución detectará la desviación e intentará revertirla.
- Ejecutar
applysin leer el plan, sobre todo cuando aparecen acciones-/+. - Olvidar
terraform destroyen entornos de prueba y recibir una factura inesperada a final de mes.
Un .gitignore mínimo para proyectos de Terraform debería incluir:
.terraform/
*.tfstate
*.tfstate.*
crash.log
*.tfvars
!ejemplo.tfvars
Solución de problemas frecuentes
Proveedor no instalado o directorio sin inicializar:
Error: Inconsistent dependency lock file
- provider registry.terraform.io/hashicorp/aws: required by this configuration but no version is selected
Ejecuta terraform init o, si cambiaste la restricción de versión, terraform init -upgrade.
Referencia a un atributo inexistente o mal escrito:
Error: Reference to undeclared resource
on main.tf line 24: A managed resource "aws_instance" "servidorweb" has not been declared in the root module.
Revisa que el nombre local coincida exactamente; Terraform distingue guiones bajos y mayúsculas.
Credenciales ausentes:
Error: No valid credential sources found
Comprueba con aws sts get-caller-identity que la CLI encuentra tus credenciales y que el perfil configurado en el proveedor existe.
Estado bloqueado tras una ejecución interrumpida:
Error: Error acquiring the state lock
Asegúrate de que nadie más está ejecutando Terraform y, solo entonces, libera el bloqueo con terraform force-unlock <ID>.
Para errores de formato y validación, dos comandos ahorran mucho tiempo antes de cada commit:
terraform fmt -recursive
terraform validate
Buenas prácticas para proyectos en producción
Cuando la infraestructura como código con Terraform pasa de un experimento personal a un entorno de equipo, conviene adoptar algunas prácticas:
- Guarda el estado en un backend remoto con bloqueo, como un bucket S3 con bloqueo nativo, HCP Terraform o el backend de tu plataforma de CI.
- Separa entornos (desarrollo, preproducción, producción) en directorios o espacios de trabajo distintos, con estados independientes.
- Ejecuta
planautomáticamente en cada pull request y aplica solo desde la integración continua. - Usa módulos versionados para los patrones repetidos.
- Activa
prevent_destroyen recursos con datos persistentes. - Gestiona secretos con un servicio dedicado, como AWS Secrets Manager o Vault, en lugar de variables en texto plano.
- Analiza el código con herramientas como TFLint o Checkov para detectar errores y malas configuraciones de seguridad.
Un backend remoto en S3 se declara así:
terraform {
backend "s3" {
bucket = "tienda-estado-terraform"
key = "produccion/terraform.tfstate"
region = "us-east-2"
encrypt = true
use_lockfile = true
}
}
El argumento use_lockfile activa el bloqueo nativo de S3, que sustituye a la antigua tabla de DynamoDB que antes era obligatoria para evitar ejecuciones simultáneas.
Terraform frente a otras alternativas
Terraform no es la única opción para gestionar infraestructura como código, y conocer las alternativas ayuda a elegir con criterio:
| Herramienta | Lenguaje | Multinube | Cuándo elegirla |
|---|---|---|---|
| Terraform | HCL | Sí | Ecosistema de proveedores más amplio y equipos que prefieren un lenguaje declarativo |
| OpenTofu | HCL | Sí | Bifurcación de código abierto bajo la Linux Foundation, compatible con la sintaxis de Terraform |
| AWS CloudFormation | YAML o JSON | No | Proyectos exclusivamente en AWS que quieren soporte nativo |
| Pulumi | TypeScript, Python, Go | Sí | Equipos que prefieren lenguajes de programación generales |
| AWS CDK | TypeScript, Python | No | Desarrolladores de AWS que quieren abstracciones de alto nivel |
La ventaja de aprender HCL es que lo aprendido sirve tanto para Terraform como para OpenTofu, ya que ambos comparten la gran mayoría de la sintaxis. La elección entre uno y otro suele depender más de la licencia y del soporte comercial que de diferencias técnicas.
Conclusiones
La sintaxis de Terraform se apoya en muy pocas piezas: bloques, etiquetas, argumentos y referencias. Una vez interiorizadas, todo lo demás, desde los proveedores hasta los módulos, son variaciones del mismo patrón. El bloque terraform fija las versiones, los proveedores conectan con cada plataforma, los recursos y fuentes de datos describen la infraestructura, y las variables, locales y outputs convierten una configuración rígida en una reutilizable.
Los meta-argumentos te dan control sobre el comportamiento de la herramienta: depends_on para dependencias ocultas, lifecycle para proteger recursos críticos, alias de proveedor para trabajar con varias cuentas, y count y for_each para crear múltiples objetos sin duplicar código. Las expresiones, las funciones integradas y la consola interactiva completan un lenguaje que, sin ser de propósito general, resulta sorprendentemente expresivo.
El siguiente paso natural es construir un módulo propio, configurar un backend remoto y automatizar plan y apply en tu pipeline de integración continua. Y recuerda la regla de oro mientras practicas en una cuenta real: ejecuta terraform destroy al terminar cada experimento.
