Introducción

En este módulo aprenderás la sintaxis de HCL, el lenguaje que usa Terraform para definir infraestructura. Comprender bien HCL es fundamental: es la base sobre la que construirás todas tus configuraciones, desde las más simples hasta las más complejas.


¿Qué es HCL? Historia y propósito

HCL (HashiCorp Configuration Language) es un lenguaje de configuración creado por HashiCorp, la empresa detrás de herramientas como Terraform, Vault, Consul y Nomad. Fue diseñado con un objetivo claro: ser legible tanto por humanos como por máquinas.

Historia breve

  • 2014: HashiCorp lanza Terraform y con él la primera versión de HCL.
  • 2019: Se lanza HCL2, una reescritura completa que añade expresiones, funciones y mayor flexibilidad.
  • Actualidad: HCL2 es el estándar utilizado en Terraform desde la versión 0.12.

¿Por qué HCL y no JSON o YAML?

Característica HCL JSON YAML
Legibilidad humana Alta Media Alta
Soporte para comentarios Sí No Sí
Expresiones y funciones Sí No No
Interpolación de strings Sí No Limitado
Tipado dinámico Sí Parcial Parcial
Compatible con JSON Sí (HCL puede leer JSON) Nativo No

HCL permite escribir comentarios, usar expresiones complejas, y tiene una sintaxis más cercana a los lenguajes de programación modernos, lo que lo hace más potente que JSON o YAML para definir infraestructura.


Estructura básica de HCL: bloques, argumentos y expresiones

Bloques

Un bloque es la unidad fundamental de HCL. Todo en Terraform se organiza en bloques. Un bloque tiene:

  1. Un tipo de bloque (por ejemplo: resource, variable, output).
  2. Opcionalmente, una o más etiquetas que identifican el bloque.
  3. Un cuerpo delimitado por llaves {} que contiene los argumentos.
# Estructura general de un bloque
tipo_de_bloque "etiqueta_1" "etiqueta_2" {
  argumento_1 = valor_1
  argumento_2 = valor_2
}

Ejemplo real con un recurso de AWS:

# Tipo de bloque: resource
# Etiqueta 1: "aws_instance" (tipo de recurso de AWS)
# Etiqueta 2: "servidor_web" (nombre local que le damos nosotros)
resource "aws_instance" "servidor_web" {
  # Argumentos que configuran la instancia EC2
  ami           = "ami-0c55b159cbfafe1f0"  # ID de la imagen de sistema operativo
  instance_type = "t2.micro"               # Tipo de instancia (tamaño del servidor)
}

Argumentos

Los argumentos asignan un valor a un nombre dentro de un bloque. Tienen la forma:

nombre_argumento = expresión

Los argumentos pueden tener valores simples o expresiones complejas.

Expresiones

Las expresiones son los valores que se asignan a los argumentos. Pueden ser literales, referencias a otros recursos, llamadas a funciones, etc.

# Literal simple
region = "us-east-1"

# Referencia a otro recurso
subnet_id = aws_subnet.principal.id

# Llamada a función
nombre = upper("produccion")

# Expresión condicional
instance_type = var.entorno == "produccion" ? "t3.medium" : "t2.micro"

Tipos de datos en HCL

HCL soporta varios tipos de datos primitivos y de colección.

Tipos primitivos

Tipo Descripción Ejemplo
string Cadena de texto "hola mundo"
number Número (entero o decimal) 42, 3.14
bool Valor booleano true, false

Tipos de colección

Tipo Descripción Ejemplo
list Lista ordenada de valores del mismo tipo ["a", "b", "c"]
set Conjunto sin orden ni duplicados Igual que list pero sin orden garantizado
map Mapa clave-valor con valores del mismo tipo { nombre = "juan", ciudad = "Madrid" }

Tipos estructurales

Tipo Descripción Uso
object Estructura con atributos con tipos definidos Configuraciones complejas
tuple Lista con tipos distintos por posición Raramente usado directamente

Ejemplos de cada tipo de dato

# STRING: cadena de texto entre comillas dobles
variable "nombre_proyecto" {
  type    = string        # Declaramos que el tipo es string
  default = "mi-proyecto" # Valor por defecto
}

# NUMBER: número entero o decimal
variable "numero_instancias" {
  type    = number # Tipo numérico
  default = 3      # Valor por defecto: 3 instancias
}

# BOOL: verdadero o falso
variable "habilitar_monitoreo" {
  type    = bool  # Tipo booleano
  default = true  # Por defecto el monitoreo está habilitado
}

# LIST(STRING): lista de cadenas de texto
variable "zonas_disponibilidad" {
  type    = list(string)             # Lista que solo acepta strings
  default = ["us-east-1a", "us-east-1b", "us-east-1c"] # Tres zonas
}

# MAP(STRING): mapa con claves y valores de tipo string
variable "etiquetas_comunes" {
  type = map(string) # Mapa donde todos los valores son strings
  default = {
    entorno    = "desarrollo"  # Clave: entorno, valor: desarrollo
    equipo     = "backend"     # Clave: equipo, valor: backend
    proyecto   = "ecommerce"   # Clave: proyecto, valor: ecommerce
  }
}

# OBJECT: estructura con atributos tipados
variable "configuracion_servidor" {
  type = object({
    tipo_instancia = string # El tipo de instancia debe ser string
    disco_gb       = number # El tamaño del disco debe ser number
    publico        = bool   # Si es público debe ser bool
  })
  default = {
    tipo_instancia = "t2.micro"
    disco_gb       = 20
    publico        = false
  }
}

# LIST(OBJECT): lista de objetos (muy común en configuraciones reales)
variable "reglas_seguridad" {
  type = list(object({
    puerto   = number  # Puerto de la regla
    protocolo = string # Protocolo (tcp, udp, etc.)
    origen   = string  # CIDR de origen
  }))
  default = [
    {
      puerto    = 80
      protocolo = "tcp"
      origen    = "0.0.0.0/0"
    },
    {
      puerto    = 443
      protocolo = "tcp"
      origen    = "0.0.0.0/0"
    }
  ]
}

Comentarios en HCL

HCL soporta dos estilos de comentarios. Los comentarios son ignorados por Terraform y sirven para documentar el código.

# Este es un comentario de una sola línea
# Se usa la almohadilla (#) al inicio

// Este también es un comentario de una sola línea
// Se puede usar doble barra (//) como alternativa

/*
  Este es un comentario
  de múltiples líneas.
  Útil para explicaciones largas
  o para desactivar bloques de código temporalmente.
*/

resource "aws_vpc" "principal" {
  cidr_block = "10.0.0.0/16" # El rango de IPs de la red virtual

  # Habilitamos DNS en la VPC para que los recursos
  # puedan resolver nombres de dominio internos
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "vpc-principal" # Nombre visible en la consola de AWS
  }
}

Consejo: Usa comentarios generosamente al principio. Con el tiempo sabrás qué necesita explicación y qué es obvio.


Referencias a otros recursos y atributos

Una de las características más poderosas de Terraform es poder referenciar atributos de un recurso desde otro. Esto crea dependencias implícitas que Terraform resuelve automáticamente.

Sintaxis de referencia

tipo_recurso.nombre_local.atributo

Ejemplo completo con referencias

# Primero creamos la VPC
resource "aws_vpc" "principal" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = "vpc-principal"
  }
}

# La subnet referencia el ID de la VPC creada arriba
# Terraform sabe que debe crear la VPC antes que la subnet
resource "aws_subnet" "publica" {
  vpc_id            = aws_vpc.principal.id         # Referencia al ID de la VPC
  cidr_block        = "10.0.1.0/24"               # Subred dentro del rango de la VPC
  availability_zone = "us-east-1a"                 # Zona de disponibilidad

  tags = {
    Name = "subnet-publica"
    VPC  = aws_vpc.principal.id # También podemos referenciar en tags
  }
}

# El security group también pertenece a la misma VPC
resource "aws_security_group" "web" {
  name   = "sg-web"
  vpc_id = aws_vpc.principal.id  # Referencia a la misma VPC

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.principal.cidr_block] # Referencia al CIDR de la VPC
  }
}

En este ejemplo, Terraform construye automáticamente el orden de creación:

  1. Primero crea aws_vpc.principal
  2. Luego crea aws_subnet.publica y aws_security_group.web (pueden ser en paralelo)

Interpolación de strings con ${}

La interpolación permite insertar expresiones dentro de cadenas de texto.

# Interpolación básica: insertar el valor de una variable en un string
variable "entorno" {
  type    = string
  default = "produccion"
}

variable "region" {
  type    = string
  default = "us-east-1"
}

resource "aws_s3_bucket" "logs" {
  # Interpolamos variables dentro del nombre del bucket
  # Resultado: "logs-produccion-us-east-1-mi-empresa"
  bucket = "logs-${var.entorno}-${var.region}-mi-empresa"
}

resource "aws_instance" "app" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  tags = {
    # Combinamos texto literal con referencias
    Name        = "servidor-${var.entorno}"           # "servidor-produccion"
    Descripcion = "Instancia en región ${var.region}" # "Instancia en región us-east-1"
  }
}

# También podemos interpolar expresiones más complejas
locals {
  prefijo     = "app"
  version_app = "2.1"

  # Interpolación con expresión
  nombre_completo = "${local.prefijo}-v${local.version_app}-${var.entorno}"
  # Resultado: "app-v2.1-produccion"
}

Expresiones condicionales

La expresión condicional en HCL tiene la misma sintaxis que el operador ternario en muchos lenguajes de programación:

condicion ? valor_si_verdadero : valor_si_falso
variable "entorno" {
  type    = string
  default = "desarrollo"
}

variable "es_produccion" {
  type    = bool
  default = false
}

resource "aws_instance" "app" {
  ami = "ami-0c55b159cbfafe1f0"

  # Si el entorno es produccion, usar t3.medium; si no, t2.micro
  # Esto nos ahorra dinero en entornos de desarrollo
  instance_type = var.entorno == "produccion" ? "t3.medium" : "t2.micro"

  # Si es producción, usar 100GB de disco; si no, 20GB
  root_block_device {
    volume_size = var.es_produccion ? 100 : 20
  }

  tags = {
    Name = "servidor-${var.entorno}"
    # El tag de backup solo tiene valor útil en producción
    Backup = var.es_produccion ? "habilitado" : "deshabilitado"
  }
}

# Ejemplo con recursos opcionales usando count y condicional
resource "aws_eip" "ip_estatica" {
  # Solo creamos IP estática en producción (count 1 = crear, count 0 = no crear)
  count    = var.es_produccion ? 1 : 0
  instance = aws_instance.app.id
}

Funciones built-in básicas de HCL

Terraform incluye muchas funciones predefinidas. Aquí las más usadas:

Funciones de colecciones

locals {
  # length: devuelve el número de elementos en una lista o mapa
  lista_zonas   = ["us-east-1a", "us-east-1b", "us-east-1c"]
  num_zonas     = length(local.lista_zonas) # Resultado: 3

  # concat: une dos o más listas en una sola
  lista_a       = ["a", "b"]
  lista_b       = ["c", "d"]
  lista_unida   = concat(local.lista_a, local.lista_b) # ["a", "b", "c", "d"]

  # tolist: convierte un set a list (los sets no tienen orden garantizado)
  conjunto      = toset(["z", "a", "m"])
  como_lista    = tolist(local.conjunto)

  # toset: convierte una lista a set (elimina duplicados)
  con_duplicados  = ["a", "b", "a", "c", "b"]
  sin_duplicados  = toset(local.con_duplicados) # {"a", "b", "c"}

  # flatten: aplana una lista de listas en una lista simple
  lista_anidada   = [["a", "b"], ["c", "d"], ["e"]]
  lista_plana     = flatten(local.lista_anidada) # ["a", "b", "c", "d", "e"]

  # distinct: elimina duplicados de una lista manteniendo el orden
  lista_dup       = ["x", "y", "x", "z", "y"]
  lista_unica     = distinct(local.lista_dup) # ["x", "y", "z"]
}

Funciones de strings

locals {
  texto_original = "  Hola Mundo  "

  # upper / lower: convierte a mayúsculas o minúsculas
  en_mayusculas = upper("hola")    # "HOLA"
  en_minusculas = lower("MUNDO")   # "mundo"

  # trimspace: elimina espacios al inicio y al final
  sin_espacios  = trimspace(local.texto_original) # "Hola Mundo"

  # replace: reemplaza texto dentro de un string
  reemplazado   = replace("hola-mundo", "-", "_") # "hola_mundo"

  # split: divide un string en una lista usando un separador
  ips_string    = "10.0.1.0,10.0.2.0,10.0.3.0"
  lista_ips     = split(",", local.ips_string) # ["10.0.1.0", "10.0.2.0", "10.0.3.0"]

  # join: une una lista en un string con un separador
  lista_nombres = ["api", "web", "db"]
  nombres_str   = join("-", local.lista_nombres) # "api-web-db"

  # format: formatea un string (similar a printf en otros lenguajes)
  nombre_bucket = format("bucket-%s-%d", "logs", 2024) # "bucket-logs-2024"

  # substr: extrae una subcadena
  subcadena     = substr("terraform", 0, 5) # "terra"

  # contains: verifica si un string contiene una subcadena
  tiene_prod    = strcontains("entorno-produccion", "produccion") # true
}

Funciones numéricas y de tipo

locals {
  # max / min: valor máximo o mínimo de una lista de números
  maximo = max(5, 3, 8, 1, 9) # 9
  minimo = min(5, 3, 8, 1, 9) # 1

  # ceil / floor: redondeo hacia arriba o hacia abajo
  redondeado_arriba = ceil(4.2)  # 5
  redondeado_abajo  = floor(4.8) # 4

  # tostring: convierte número a string
  numero_como_texto = tostring(42) # "42"

  # tonumber: convierte string a número
  texto_como_numero = tonumber("42") # 42

  # tobool: convierte string a booleano
  texto_como_bool = tobool("true") # true
}

Ejemplo completo con explicación detallada

Veamos un ejemplo real que combina todos los conceptos aprendidos:

# Declaramos las variables de entrada del módulo
variable "entorno" {
  type        = string       # Solo acepta texto
  description = "Entorno de despliegue: desarrollo, staging o produccion"
  default     = "desarrollo" # Valor por defecto si no se especifica

  # Validamos que el valor sea uno de los permitidos
  validation {
    condition     = contains(["desarrollo", "staging", "produccion"], var.entorno)
    error_message = "El entorno debe ser: desarrollo, staging o produccion."
  }
}

variable "num_servidores" {
  type        = number
  description = "Número de servidores a crear"
  default     = 1
}

variable "etiquetas_extra" {
  type        = map(string)  # Mapa de texto a texto
  description = "Etiquetas adicionales para los recursos"
  default     = {}           # Por defecto un mapa vacío
}

# Valores locales: cálculos internos que no son variables de entrada
locals {
  # Combinamos las etiquetas comunes con las extra usando merge
  # merge une dos mapas (el segundo sobreescribe el primero en caso de conflicto)
  etiquetas_completas = merge(
    {
      # Etiquetas que siempre queremos en todos los recursos
      Entorno   = var.entorno
      Proyecto  = "mi-aplicacion"
      Gestionado = "terraform"
      # Fecha de creación usando la función formatdate y timestamp
    },
    var.etiquetas_extra  # Añadimos las etiquetas adicionales del usuario
  )

  # Decidimos el tipo de instancia según el entorno
  # produccion -> t3.medium (más potente)
  # cualquier otro -> t2.micro (más barato)
  tipo_instancia = var.entorno == "produccion" ? "t3.medium" : "t2.micro"

  # El nombre base para los recursos
  nombre_base = "app-${var.entorno}"
}

# Recurso: instancia de servidor
resource "aws_instance" "servidor" {
  # count crea múltiples instancias del mismo recurso
  count = var.num_servidores

  ami           = "ami-0c55b159cbfafe1f0"   # Imagen base de Ubuntu
  instance_type = local.tipo_instancia       # Tipo según entorno

  tags = merge(
    local.etiquetas_completas,  # Todas las etiquetas comunes
    {
      # Añadimos un nombre único para cada servidor usando el índice
      # count.index empieza en 0, por eso sumamos 1
      Name = "${local.nombre_base}-servidor-${count.index + 1}"
    }
  )
}

# Output: valor que queremos exportar
output "ids_servidores" {
  description = "Lista de IDs de todos los servidores creados"
  # Usamos [*] para obtener el atributo 'id' de todos los elementos de count
  value = aws_instance.servidor[*].id
}

output "nombre_entorno" {
  description = "El entorno donde se desplegó la infraestructura"
  value       = upper(var.entorno) # Lo mostramos en mayúsculas
}

Ejercicio práctico

Enunciado

Crea un archivo variables_ejercicio.tf que contenga:

  1. Una variable proyecto de tipo string con valor por defecto "practica-hcl".
  2. Una variable regiones de tipo list(string) con tres regiones de AWS.
  3. Una variable config_db de tipo object con los campos: motor (string), version (string), puerto (number), multiaz (bool).
  4. Un bloque locals que:
    • Calcule el número de regiones usando length.
    • Cree un nombre de bucket S3 concatenando el proyecto y la primera región.
    • Determine si el proyecto es grande basándose en si el número de regiones es mayor a 2.
  5. Tres outputs que muestren el nombre del proyecto en mayúsculas, el nombre del bucket S3 y si el proyecto es grande.

Solución

# =============================================
# Archivo: variables_ejercicio.tf
# Ejercicio de práctica de sintaxis HCL
# =============================================

# 1. Variable de tipo string
variable "proyecto" {
  type        = string
  description = "Nombre del proyecto"
  default     = "practica-hcl"
}

# 2. Variable de tipo list(string) con regiones de AWS
variable "regiones" {
  type        = list(string)
  description = "Lista de regiones de AWS donde se desplegará el proyecto"
  default     = [
    "us-east-1",    # Virginia del Norte (la más común)
    "eu-west-1",    # Irlanda
    "ap-southeast-1" # Singapur
  ]
}

# 3. Variable de tipo object para configuración de base de datos
variable "config_db" {
  type = object({
    motor   = string # Motor de base de datos (mysql, postgres, etc.)
    version = string # Versión del motor
    puerto  = number # Puerto de conexión
    multiaz = bool   # Si se despliega en múltiples zonas de disponibilidad
  })
  description = "Configuración de la base de datos"
  default = {
    motor   = "mysql"
    version = "8.0"
    puerto  = 3306
    multiaz = false
  }
}

# 4. Bloque locals con cálculos
locals {
  # Calculamos el número de regiones en la lista
  num_regiones = length(var.regiones)
  # Resultado con los defaults: 3

  # Creamos el nombre del bucket usando la primera región (índice 0)
  # replace convierte "us-east-1" en "us-east-1" (ya está bien formateado)
  # pero lo usamos para demostrar la interpolación
  nombre_bucket = "${var.proyecto}-${var.regiones[0]}"
  # Resultado con los defaults: "practica-hcl-us-east-1"

  # Determinamos si el proyecto es "grande" (más de 2 regiones)
  es_proyecto_grande = local.num_regiones > 2
  # Resultado con los defaults: true (3 > 2)
}

# 5. Outputs que exportan los valores calculados

# Output 1: nombre del proyecto en mayúsculas
output "nombre_proyecto_mayusculas" {
  description = "Nombre del proyecto en mayúsculas"
  value       = upper(var.proyecto)
  # Resultado: "PRACTICA-HCL"
}

# Output 2: nombre del bucket S3
output "nombre_bucket_s3" {
  description = "Nombre que tendría el bucket S3 del proyecto"
  value       = local.nombre_bucket
  # Resultado: "practica-hcl-us-east-1"
}

# Output 3: si el proyecto es grande
output "es_proyecto_grande" {
  description = "Indica si el proyecto está desplegado en más de 2 regiones"
  value       = local.es_proyecto_grande
  # Resultado: true
}

# Output adicional (bonus): información resumida del proyecto
output "resumen_proyecto" {
  description = "Resumen completo del proyecto"
  value = {
    nombre         = upper(var.proyecto)
    num_regiones   = local.num_regiones
    bucket_s3      = local.nombre_bucket
    es_grande      = local.es_proyecto_grande
    motor_db       = var.config_db.motor
    puerto_db      = var.config_db.puerto
  }
}

Para validar los outputs sin crear infraestructura real, puedes usar terraform console:

# Inicializar el directorio de trabajo
terraform init

# Abrir la consola interactiva de Terraform
terraform console

# Dentro de la consola, prueba:
> var.proyecto
"practica-hcl"

> length(var.regiones)
3

> upper(var.proyecto)
"PRACTICA-HCL"

> local.nombre_bucket
"practica-hcl-us-east-1"

Errores comunes de sintaxis en HCL

Error 1: Olvidar las comillas en strings

# INCORRECTO: Terraform interpretará "produccion" como una referencia
entorno = produccion

# CORRECTO: Los strings siempre entre comillas dobles
entorno = "produccion"

Error 2: Usar comillas simples

# INCORRECTO: HCL no acepta comillas simples
nombre = 'mi-recurso'

# CORRECTO: Solo comillas dobles
nombre = "mi-recurso"

Error 3: Mezclar tipos incompatibles

# INCORRECTO: no puedes asignar un número donde se espera una lista
variable "puertos" {
  type    = list(number)
  default = 80  # Error: se espera una lista, no un número
}

# CORRECTO:
variable "puertos" {
  type    = list(number)
  default = [80]  # Lista con un elemento
}

Error 4: Referencia circular

# INCORRECTO: recurso_a referencia recurso_b y viceversa -> bucle infinito
resource "tipo" "recurso_a" {
  valor = tipo.recurso_b.id  # A depende de B
}

resource "tipo" "recurso_b" {
  valor = tipo.recurso_a.id  # B depende de A -> ERROR!
}

Error 5: Interpolación incorrecta

# INCORRECTO: falta el $ antes de las llaves
nombre = "{var.proyecto}-web"

# INCORRECTO: las llaves no están bien cerradas
nombre = "${var.proyecto-web"

# CORRECTO:
nombre = "${var.proyecto}-web"

Error 6: Indentación con tabulaciones mixtas

# HCL acepta tanto espacios como tabulaciones, pero no mezcles ambos
# Por convención, usa 2 espacios por nivel de indentación
resource "aws_instance" "app" {
  ami           = "ami-123"
  instance_type = "t2.micro"  # 2 espacios de indentación
}

Tip: Usa el comando terraform fmt para formatear automáticamente tu código HCL con el estilo oficial.

# Formatea todos los archivos .tf del directorio actual
terraform fmt

# Formatea y muestra los archivos que fueron modificados
terraform fmt -diff

# Verifica el formato sin modificar (útil en CI/CD)
terraform fmt -check

Resumen

En este módulo has aprendido:

  • HCL es un lenguaje de configuración diseñado para ser legible y expresivo, superior a JSON o YAML para definir infraestructura.
  • La estructura básica de HCL se basa en bloques (con tipo y etiquetas), argumentos y expresiones.
  • Los tipos de datos incluyen primitivos (string, number, bool) y de colección (list, set, map, object, tuple).
  • Los comentarios se escriben con # o // para líneas simples, y /* */ para múltiples líneas.
  • Las referencias (tipo.nombre.atributo) crean dependencias implícitas que Terraform resuelve automáticamente.
  • La interpolación (${...}) permite insertar expresiones dentro de strings.
  • Las expresiones condicionales (condicion ? valor_verdadero : valor_falso) permiten configuración dinámica.
  • Las funciones built-in como length, concat, upper, merge, etc., son herramientas esenciales para manipular datos.

Preparación para el siguiente módulo

En el próximo módulo estudiaremos Variables y Salidas en profundidad. Verás que todo lo aprendido aquí sobre tipos de datos y expresiones es la base para definir variables de entrada reutilizables y outputs que comunican información entre módulos. La sintaxis HCL que has aprendido la usarás constantemente, así que practica escribiendo algunos bloques antes de continuar.

© Copyright 2026. Todos los derechos reservados