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

providers.tf:

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
}

variables.tf:

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"
}

outputs.tf:

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:

  1. Developer abre PR con cambio en infra.
  2. CI corre terraform plan y postea el resultado en el PR.
  3. Reviewer ve qué se va a cambiar y aprueba.
  4. Merge a main → CI corre terraform apply automá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.


2.7 Para profundizar


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.