Infrastructure as Code con Terraform
"Si tu infraestructura está en una wiki o en la cabeza de alguien, no es infraestructura — es deuda."
Qué vas a aprender en este capítulo
Crear infraestructura cloud manualmente (clickeando en la consola) funciona para experimentar pero es desastroso en producción: nadie sabe exactamente qué está corriendo, no hay versionado, los cambios se pierden. Infrastructure as Code (IaC) resuelve esto: la infraestructura se define en archivos versionados en Git.
2.1 ¿Qué es IaC?
📐 Fundamento
Definición: describir la infraestructura (servidores, redes, bases de datos, etc.) en archivos de código que se pueden versionar, revisar (PR), y aplicar de forma automática.
Beneficios:
| Sin IaC | Con IaC |
|---|---|
| Cambios manuales no rastreados | Todo en Git con historial |
| Difícil reproducir entornos (dev=prod?) | terraform apply para crear igual |
| Configuración drift (real ≠ documentación) | El código ES la documentación |
| Onboarding lento | Nuevo dev ve el código y entiende todo |
| Recuperación de desastres difícil | Re-aplicar el código → restaurar todo |
Tipos de IaC:
| Tipo | Cómo se describe | Ejemplos |
|---|---|---|
| Imperativo | "Crear esta VM, agregar este disco..." | Bash scripts, AWS CLI scripts |
| Declarativo | "Quiero que existan estas VMs con estos discos" | Terraform, CloudFormation |
Declarativo es mejor: vos decís el estado deseado, la herramienta decide qué cambios aplicar para llegar ahí.
2.2 Terraform — el estándar de facto
📐 Fundamento
Terraform usa HCL (HashiCorp Configuration Language).
Estructura típica:
infra/
├── providers.tf # qué clouds usar
├── variables.tf # parámetros
├── main.tf # recursos principales
├── outputs.tf # valores a exportar
├── terraform.tfvars # valores específicos del entorno (no commitear si tiene secretos)
└── .gitignore # ignorar .terraform/, *.tfstate
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "la-esquina-tfstate"
key = "production/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks" # para concurrent locking
}
}
provider "aws" {
region = var.region
}
variable "region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "ambiente" {
description = "dev, staging, o production"
type = string
validation {
condition = contains(["dev", "staging", "production"], var.ambiente)
error_message = "Ambiente inválido"
}
}
variable "instance_type" {
type = string
default = "t3.micro"
}
main.tf — definir recursos:
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
tags = {
Name = "la-esquina-vpc"
Ambiente = var.ambiente
ManagedBy = "terraform"
}
}
# Subnet pública
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.region}a"
map_public_ip_on_launch = true
tags = { Name = "public-a" }
}
# Security Group para web servers
resource "aws_security_group" "web" {
name = "web-sg"
description = "Permitir HTTP/HTTPS"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# EC2 instance
resource "aws_instance" "api_server" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
subnet_id = aws_subnet.public_a.id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = <<-EOF
#!/bin/bash
apt update && apt install -y python3-pip
pip3 install fastapi uvicorn
# ... configurar y arrancar app
EOF
tags = {
Name = "api-server"
Ambiente = var.ambiente
}
}
# RDS PostgreSQL
resource "aws_db_instance" "postgres" {
identifier = "la-esquina-db-${var.ambiente}"
engine = "postgres"
engine_version = "16.1"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_encrypted = true
db_name = "esquina"
username = "admin"
password = var.db_password # variable sensible
vpc_security_group_ids = [aws_security_group.db.id]
skip_final_snapshot = false
backup_retention_period = 7
multi_az = var.ambiente == "production"
}
output "api_server_ip" {
value = aws_instance.api_server.public_ip
description = "IP pública del servidor API"
}
output "db_endpoint" {
value = aws_db_instance.postgres.endpoint
sensitive = true # no mostrar en logs
}
Comandos esenciales:
terraform init # descargar providers y configurar backend
terraform plan # mostrar qué cambios haría (siempre antes de apply)
terraform apply # aplicar cambios (pide confirmación)
terraform destroy # eliminar todo (cuidado!)
terraform state list # ver recursos gestionados
terraform import aws_instance.foo i-1234 # importar recurso existente
terraform fmt # formatear código
terraform validate # verificar sintaxis
2.3 State management
💡 Intuición
Terraform mantiene un state file que mapea: "el recurso aws_instance.api_server en el código corresponde a la instancia EC2 con ID i-12345".
Sin state, Terraform no sabe qué ya existe. Con state local en terraform.tfstate, solo vos podés modificarlo. En equipo, necesitás remote state (S3, Terraform Cloud, etc.).
📐 Fundamento
Remote state con S3 + DynamoDB:
backend "s3" {
bucket = "la-esquina-tfstate"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true # cifrar en reposo
dynamodb_table = "terraform-locks" # locking para evitar applies concurrentes
}
DynamoDB lock previene que dos personas hagan terraform apply al mismo tiempo (catastrófico).
Workspaces — múltiples ambientes:
terraform workspace new dev
terraform workspace new staging
terraform workspace new production
terraform workspace select production
terraform apply # aplica en production
O mejor: directorios separados por ambiente.
infra/
├── modules/ # código reutilizable
├── environments/
│ ├── dev/
│ │ └── main.tf # usa módulos con valores de dev
│ ├── staging/
│ └── production/
Nunca:
- Editar el state file a mano (corromper estructura).
- Commitear el state a Git (puede tener secretos en plain text).
- Hacer cambios manuales fuera de Terraform (causa "drift").
2.4 Módulos reutilizables
📐 Fundamento
Un módulo es código Terraform empaquetado para reutilización.
modules/
└── web-server/
├── main.tf # recursos
├── variables.tf # inputs
└── outputs.tf # outputs
# modules/web-server/variables.tf
variable "name" { type = string }
variable "instance_type" { type = string; default = "t3.micro" }
variable "subnet_id" { type = string }
# modules/web-server/main.tf
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
subnet_id = var.subnet_id
tags = { Name = var.name }
}
# modules/web-server/outputs.tf
output "instance_id" { value = aws_instance.web.id }
output "public_ip" { value = aws_instance.web.public_ip }
Usar el módulo:
# environments/production/main.tf
module "api_server" {
source = "../../modules/web-server"
name = "api-prod"
instance_type = "t3.medium"
subnet_id = aws_subnet.public_a.id
}
module "worker_server" {
source = "../../modules/web-server"
name = "worker-prod"
instance_type = "t3.large"
subnet_id = aws_subnet.private_a.id
}
output "api_ip" {
value = module.api_server.public_ip
}
Módulos públicos en Terraform Registry:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = "la-esquina-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # uno por AZ para alta disponibilidad
}
Hay módulos comunitarios para casi todo: VPC, EKS, RDS, ALB. Reutilizar > construir.
2.5 Alternativas a Terraform
📐 Fundamento
| Herramienta | Lenguaje | Pros | Cons |
|---|---|---|---|
| Terraform | HCL | Multi-cloud, comunidad enorme, maduro | DSL propio |
| AWS CloudFormation | YAML/JSON | Nativo de AWS, gratis | Solo AWS, verboso |
| AWS CDK | TypeScript/Python/Go | Lenguaje real, abstracciones | Solo AWS (CDK for Terraform multi-cloud) |
| Pulumi | TypeScript/Python/Go | Lenguajes reales, multi-cloud | Comunidad menor |
| OpenTofu | HCL | Fork open-source de Terraform (después de licencia BSL) | Joven |
| Ansible | YAML | Configuración + provisión | Más enfocado en config management |
Tendencias:
- OpenTofu está ganando tracción tras el cambio de licencia de Terraform a BSL (2023).
- CDK / Pulumi crecen porque permiten usar lenguajes reales (loops, condicionales, abstracciones).
- GitOps: aplicar IaC automáticamente desde Git (Atlantis, Terraform Cloud, GitHub Actions).
🛠️ En la práctica
La Esquina Cloud — pipeline de deploy completo:
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
pull_request:
push:
branches: [main]
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: 1.6.0 }
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
- name: Terraform Init
run: terraform init
working-directory: infra/environments/production
- name: Terraform Plan
run: terraform plan -out=tfplan
working-directory: infra/environments/production
- name: Comentar plan en el PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
// Postear el output de terraform plan como comment en el PR
- name: Terraform Apply (solo en main)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: terraform apply tfplan
working-directory: infra/environments/production
Flujo:
- Developer abre PR con cambio en infra.
- CI corre
terraform plany postea el resultado en el PR. - Reviewer ve qué se va a cambiar y aprueba.
- Merge a main → CI corre
terraform applyautomáticamente.
2.6 Ejercicios
✏️ Ejercicio 2.1 — Refactorizar a módulos
Tenés tres ambientes (dev, staging, production) cada uno con configuración Terraform muy similar (mismas redes, mismos tipos de recursos, solo cambian tamaños y nombres).
Diseñá la estructura de directorios y módulos para que:
a. El código se reutilice entre ambientes. b. Cada ambiente tenga su propio state file. c. Los cambios al "blueprint" se apliquen consistentemente a todos los ambientes.
Solución
infra/
├── modules/
│ ├── network/ # VPC, subnets, NAT
│ ├── compute/ # EC2, autoscaling
│ ├── database/ # RDS
│ └── monitoring/ # CloudWatch, alarms
│
├── environments/
│ ├── dev/
│ │ ├── main.tf # usa módulos con valores de dev
│ │ ├── variables.tf
│ │ ├── terraform.tfvars # instance_type=t3.micro, multi_az=false
│ │ └── backend.tf # state en s3://tfstate/dev/
│ │
│ ├── staging/
│ │ ├── main.tf # mismos módulos, valores de staging
│ │ ├── terraform.tfvars # instance_type=t3.small, multi_az=false
│ │ └── backend.tf # state en s3://tfstate/staging/
│ │
│ └── production/
│ ├── main.tf # mismos módulos, valores de production
│ ├── terraform.tfvars # instance_type=t3.large, multi_az=true
│ └── backend.tf # state en s3://tfstate/production/
Beneficios:
- Modificar el módulo
computeactualiza los 3 ambientes (conterraform applyen cada uno). - Cada ambiente tiene su state independiente — apply en dev no afecta a prod.
- Diferencias entre ambientes están localizadas en
terraform.tfvars.
Workflow recomendado:
- Cambio en módulo → testear en dev primero (
cd environments/dev && terraform apply). - Si funciona → aplicar en staging.
- Si funciona → PR a main → CI aplica en production.
2.7 Para profundizar
- HashiCorp Learn (developer.hashicorp.com/terraform/tutorials).
- Terraform Up & Running — Yevgeniy Brikman.
- Terraform Registry — registry.terraform.io
- Siguiente: Contenedores y Kubernetes.
Definiciones nuevas: Infrastructure as Code (IaC), HCL, declarativo vs imperativo, provider, resource, variable, output, state file, remote state, workspace, módulo, drift, GitOps, Terraform Cloud, Atlantis.