Introducción al Proyecto

En este proyecto construiremos una infraestructura web de producción en AWS utilizando Terraform. Implementaremos una arquitectura de 3 capas (presentación, aplicación y datos) siguiendo las mejores prácticas de la industria: alta disponibilidad, seguridad por capas y escalabilidad automática.

Al finalizar este proyecto habrás desplegado una infraestructura completa y funcional que podría alojar cualquier aplicación web moderna. Este proyecto integra todos los conceptos aprendidos en el curso.


Descripción del Proyecto

Objetivo

Crear una infraestructura en AWS que incluya:

  • Red privada virtual (VPC) con subredes públicas y privadas
  • Balanceador de carga para distribuir el tráfico
  • Servidores web con escalado automático
  • Base de datos MySQL en alta disponibilidad
  • Almacenamiento de objetos estáticos
  • Monitorización básica con alarmas

Requisitos Previos

  • AWS CLI configurado con credenciales válidas
  • Terraform >= 1.0 instalado
  • Conocimientos básicos de AWS (VPC, EC2, RDS)
  • Una cuenta de AWS con permisos suficientes

Diagrama de Arquitectura

                          INTERNET
                             |
                    [Internet Gateway]
                             |
              +--------------+--------------+
              |                             |
    AZ-a (eu-west-1a)           AZ-b (eu-west-1b)
    +-------------------+    +-------------------+
    | Subred Pública A  |    | Subred Pública B  |
    | 10.0.1.0/24       |    | 10.0.2.0/24       |
    |  [ALB Node]       |    |  [ALB Node]       |
    |  [NAT Gateway A]  |    |  [NAT Gateway B]  |
    +-------------------+    +-------------------+
              |                             |
    +-------------------+    +-------------------+
    | Subred Privada A  |    | Subred Privada B  |
    | 10.0.10.0/24      |    | 10.0.20.0/24      |
    |  [EC2 App Server] |    |  [EC2 App Server] |
    +-------------------+    +-------------------+
              |                             |
    +-------------------+    +-------------------+
    | Subred BD A       |    | Subred BD B       |
    | 10.0.100.0/24     |    | 10.0.200.0/24     |
    |  [RDS Primary]    |    |  [RDS Standby]    |
    +-------------------+    +-------------------+

    [S3 Bucket] <--- Archivos estáticos
    [CloudWatch] <--- Métricas y alarmas

Tabla de Recursos del Proyecto

Recurso Tipo Terraform Propósito
VPC aws_vpc Red privada virtual principal
Subredes públicas (x2) aws_subnet Alojan ALB y NAT Gateways
Subredes privadas (x2) aws_subnet Alojan servidores de aplicación
Subredes de BD (x2) aws_subnet Alojan instancias RDS
Internet Gateway aws_internet_gateway Acceso a internet desde subredes públicas
NAT Gateways (x2) aws_nat_gateway Acceso a internet desde subredes privadas
Security Group ALB aws_security_group Permite tráfico HTTP/HTTPS entrante
Security Group EC2 aws_security_group Permite tráfico desde ALB
Security Group RDS aws_security_group Permite tráfico MySQL desde EC2
Application Load Balancer aws_lb Distribuye tráfico entre instancias
Auto Scaling Group aws_autoscaling_group Escala EC2 automáticamente
Launch Template aws_launch_template Configuración base de EC2
RDS MySQL aws_db_instance Base de datos relacional
S3 Bucket aws_s3_bucket Almacenamiento de archivos estáticos
CloudWatch Alarm aws_cloudwatch_metric_alarm Alertas de monitorización

Estructura del Proyecto

terraform-aws-infrastructure/
├── main.tf              # Configuración principal y provider
├── variables.tf         # Declaración de variables
├── outputs.tf           # Outputs del proyecto
├── terraform.tfvars     # Valores de variables (no commitear secrets)
├── vpc.tf               # VPC, subredes, gateways
├── security_groups.tf   # Todos los Security Groups
├── alb.tf               # Application Load Balancer
├── ec2.tf               # Launch Template y Auto Scaling Group
├── rds.tf               # Base de datos RDS MySQL
├── s3.tf                # Bucket S3
├── cloudwatch.tf        # Alarmas de CloudWatch
└── user_data.sh         # Script de inicialización de EC2

Código Terraform Completo

main.tf - Configuración Principal

# main.tf
# Configuración del provider de Terraform
# Define qué versión de Terraform y providers necesitamos

terraform {
  required_version = ">= 1.0"  # Versión mínima de Terraform

  required_providers {
    aws = {
      source  = "hashicorp/aws"  # Proveedor oficial de AWS
      version = "~> 5.0"         # Compatible con la versión 5.x
    }
  }

  # Backend remoto para el estado (descomentar en producción)
  # backend "s3" {
  #   bucket = "mi-terraform-state-bucket"
  #   key    = "aws-infrastructure/terraform.tfstate"
  #   region = "eu-west-1"
  # }
}

# Configuración del provider de AWS
provider "aws" {
  region = var.aws_region  # Región donde se crearán los recursos

  # Tags por defecto aplicados a todos los recursos
  default_tags {
    tags = {
      Project     = var.project_name
      Environment = var.environment
      ManagedBy   = "Terraform"
    }
  }
}

# Data source para obtener las AZs disponibles en la región
# Esto hace el código más portable (no hardcodeamos nombres de AZ)
data "aws_availability_zones" "available" {
  state = "available"  # Solo AZs que están operativas
}

# Data source para obtener la AMI de Amazon Linux 2023 más reciente
# En lugar de hardcodear el ID de AMI (que cambia por región),
# buscamos dinámicamente la más reciente
data "aws_ami" "amazon_linux" {
  most_recent = true  # Obtener la versión más reciente
  owners      = ["amazon"]  # Solo AMIs oficiales de Amazon

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]  # Patrón del nombre de Amazon Linux 2023
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]  # Hardware Virtual Machine (más moderno)
  }
}

variables.tf - Declaración de Variables

# variables.tf
# Todas las variables del proyecto con descripciones y valores por defecto

# --- Variables de Configuración General ---

variable "project_name" {
  description = "Nombre del proyecto (se usa en nombres de recursos y tags)"
  type        = string
  default     = "mi-webapp"

  # Validación: el nombre solo puede contener letras, números y guiones
  validation {
    condition     = can(regex("^[a-z0-9-]+$", var.project_name))
    error_message = "El nombre del proyecto solo puede contener letras minúsculas, números y guiones."
  }
}

variable "environment" {
  description = "Entorno de despliegue (dev, staging, prod)"
  type        = string
  default     = "dev"

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

variable "aws_region" {
  description = "Región de AWS donde se creará la infraestructura"
  type        = string
  default     = "eu-west-1"
}

# --- Variables de Red ---

variable "vpc_cidr" {
  description = "Bloque CIDR para la VPC (ej: 10.0.0.0/16)"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidrs" {
  description = "Lista de CIDRs para subredes públicas (una por AZ)"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "private_subnet_cidrs" {
  description = "Lista de CIDRs para subredes privadas de aplicación (una por AZ)"
  type        = list(string)
  default     = ["10.0.10.0/24", "10.0.20.0/24"]
}

variable "database_subnet_cidrs" {
  description = "Lista de CIDRs para subredes privadas de base de datos (una por AZ)"
  type        = list(string)
  default     = ["10.0.100.0/24", "10.0.200.0/24"]
}

# --- Variables de EC2 ---

variable "instance_type" {
  description = "Tipo de instancia EC2 para los servidores de aplicación"
  type        = string
  default     = "t3.micro"
}

variable "min_size" {
  description = "Número mínimo de instancias en el Auto Scaling Group"
  type        = number
  default     = 1
}

variable "max_size" {
  description = "Número máximo de instancias en el Auto Scaling Group"
  type        = number
  default     = 4
}

variable "desired_capacity" {
  description = "Número deseado de instancias en condiciones normales"
  type        = number
  default     = 2
}

# --- Variables de RDS ---

variable "db_name" {
  description = "Nombre de la base de datos MySQL"
  type        = string
  default     = "appdb"
}

variable "db_username" {
  description = "Usuario administrador de la base de datos"
  type        = string
  default     = "admin"
  sensitive   = true  # No mostrar en los logs de Terraform
}

variable "db_password" {
  description = "Contraseña del administrador de la base de datos"
  type        = string
  sensitive   = true  # Nunca mostrar esta variable en logs

  # Sin valor por defecto: DEBE proporcionarse explícitamente
  # Esto evita usar contraseñas por defecto en producción
}

variable "db_instance_class" {
  description = "Tipo de instancia RDS"
  type        = string
  default     = "db.t3.micro"
}

variable "db_allocated_storage" {
  description = "Almacenamiento inicial de RDS en GB"
  type        = number
  default     = 20
}

# --- Variables de Alertas ---

variable "alarm_email" {
  description = "Email para recibir alertas de CloudWatch"
  type        = string
  default     = ""
}

terraform.tfvars - Valores de Variables

# terraform.tfvars
# Valores específicos para este despliegue
# IMPORTANTE: No incluir este archivo en el control de versiones
# si contiene secretos. Añadir al .gitignore

project_name = "mi-webapp"
environment  = "dev"
aws_region   = "eu-west-1"

# Red
vpc_cidr              = "10.0.0.0/16"
public_subnet_cidrs   = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs  = ["10.0.10.0/24", "10.0.20.0/24"]
database_subnet_cidrs = ["10.0.100.0/24", "10.0.200.0/24"]

# EC2
instance_type    = "t3.micro"
min_size         = 1
max_size         = 3
desired_capacity = 2

# RDS - En producción usar variables de entorno o AWS Secrets Manager
db_name              = "appdb"
db_username          = "admin"
db_password          = "CambiaEstaContraseña123!"  # Cambiar en producción
db_instance_class    = "db.t3.micro"
db_allocated_storage = 20

# Alertas
alarm_email = "[email protected]"

vpc.tf - Red Virtual

# vpc.tf
# Crea toda la infraestructura de red:
# VPC, subredes, Internet Gateway, NAT Gateways y tablas de rutas

# VPC Principal
# Una VPC es nuestra red privada virtual en AWS
resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr  # Rango de IPs para toda la VPC

  # enable_dns_hostnames: permite que las instancias tengan nombres DNS
  # Necesario para que RDS funcione correctamente
  enable_dns_hostnames = true
  enable_dns_support   = true  # Habilitar resolución DNS dentro de la VPC

  tags = {
    Name = "${var.project_name}-vpc"
  }
}

# Internet Gateway
# Conecta nuestra VPC con Internet
# Sin esto, ningún recurso puede comunicarse con el exterior
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id  # Asociar con nuestra VPC

  tags = {
    Name = "${var.project_name}-igw"
  }
}

# Subredes Públicas (para ALB y NAT Gateways)
# Las subredes públicas tienen acceso directo a Internet via IGW
resource "aws_subnet" "public" {
  # Crear una subred por cada CIDR en la lista
  count = length(var.public_subnet_cidrs)

  vpc_id            = aws_vpc.main.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  # Distribuir subredes en diferentes AZs para alta disponibilidad
  availability_zone = data.aws_availability_zones.available.names[count.index]

  # Las instancias en subredes públicas reciben IP pública automáticamente
  map_public_ip_on_launch = true

  tags = {
    Name = "${var.project_name}-public-${count.index + 1}"
    # Tag requerido por el controlador de ALB de Kubernetes (si aplica)
    "kubernetes.io/role/elb" = "1"
  }
}

# Subredes Privadas (para servidores de aplicación)
# Sin acceso directo a Internet; salen via NAT Gateway
resource "aws_subnet" "private" {
  count = length(var.private_subnet_cidrs)

  vpc_id            = aws_vpc.main.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = data.aws_availability_zones.available.names[count.index]

  # NO asignar IPs públicas a instancias en subredes privadas
  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project_name}-private-${count.index + 1}"
  }
}

# Subredes de Base de Datos (más restrictivas, solo acceso interno)
resource "aws_subnet" "database" {
  count = length(var.database_subnet_cidrs)

  vpc_id            = aws_vpc.main.id
  cidr_block        = var.database_subnet_cidrs[count.index]
  availability_zone = data.aws_availability_zones.available.names[count.index]

  map_public_ip_on_launch = false

  tags = {
    Name = "${var.project_name}-database-${count.index + 1}"
  }
}

# Elastic IPs para los NAT Gateways
# Una IP pública estática para cada NAT Gateway
resource "aws_eip" "nat" {
  count  = length(var.public_subnet_cidrs)  # Una EIP por AZ
  domain = "vpc"  # EIP para uso en VPC (no EC2-Classic)

  # La EIP depende del Internet Gateway
  depends_on = [aws_internet_gateway.main]

  tags = {
    Name = "${var.project_name}-nat-eip-${count.index + 1}"
  }
}

# NAT Gateways (uno por AZ para alta disponibilidad)
# Permiten que las instancias privadas accedan a Internet (para descargas, actualizaciones)
# sin que Internet pueda conectarse directamente a ellas
resource "aws_nat_gateway" "main" {
  count = length(var.public_subnet_cidrs)

  allocation_id = aws_eip.nat[count.index].id  # IP pública del NAT
  subnet_id     = aws_subnet.public[count.index].id  # DEBE estar en subred pública

  tags = {
    Name = "${var.project_name}-nat-${count.index + 1}"
  }
}

# Tabla de Rutas Pública
# Define cómo enrutar el tráfico desde subredes públicas
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  # Ruta por defecto: todo el tráfico desconocido va al Internet Gateway
  route {
    cidr_block = "0.0.0.0/0"           # Cualquier destino
    gateway_id = aws_internet_gateway.main.id  # Sale por el IGW
  }

  tags = {
    Name = "${var.project_name}-public-rt"
  }
}

# Asociar subredes públicas con la tabla de rutas pública
resource "aws_route_table_association" "public" {
  count = length(var.public_subnet_cidrs)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Tablas de Rutas Privadas (una por AZ)
# Cada subred privada tiene su propia tabla apuntando al NAT de su AZ
# Esto garantiza que si falla un NAT Gateway, solo afecta a esa AZ
resource "aws_route_table" "private" {
  count  = length(var.private_subnet_cidrs)
  vpc_id = aws_vpc.main.id

  # Enrutar tráfico de Internet a través del NAT Gateway de la misma AZ
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main[count.index].id
  }

  tags = {
    Name = "${var.project_name}-private-rt-${count.index + 1}"
  }
}

# Asociar subredes privadas con sus tablas de rutas
resource "aws_route_table_association" "private" {
  count = length(var.private_subnet_cidrs)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private[count.index].id
}

# Grupo de subredes para RDS
# RDS requiere un "subnet group" con al menos 2 subredes en diferentes AZs
resource "aws_db_subnet_group" "main" {
  name       = "${var.project_name}-db-subnet-group"
  subnet_ids = aws_subnet.database[*].id  # Todas las subredes de BD

  tags = {
    Name = "${var.project_name}-db-subnet-group"
  }
}

security_groups.tf - Grupos de Seguridad

# security_groups.tf
# Define las reglas de firewall para cada capa de la arquitectura
# Principio de mínimo privilegio: solo permitir el tráfico necesario

# Security Group para el Application Load Balancer
# El ALB es el único punto de entrada desde Internet
resource "aws_security_group" "alb" {
  name        = "${var.project_name}-alb-sg"
  description = "Security group para el Application Load Balancer"
  vpc_id      = aws_vpc.main.id

  # ENTRADA: Permitir HTTP desde cualquier IP
  ingress {
    from_port   = 80          # Puerto HTTP
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Cualquier IP (Internet público)
    description = "HTTP desde Internet"
  }

  # ENTRADA: Permitir HTTPS desde cualquier IP
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
    description = "HTTPS desde Internet"
  }

  # SALIDA: El ALB puede enviar tráfico a cualquier destino
  # Necesario para que el ALB reenvíe solicitudes a las instancias EC2
  egress {
    from_port   = 0           # Todos los puertos
    to_port     = 0
    protocol    = "-1"        # Todos los protocolos
    cidr_blocks = ["0.0.0.0/0"]
    description = "Salida sin restricciones"
  }

  tags = {
    Name = "${var.project_name}-alb-sg"
  }
}

# Security Group para instancias EC2 (servidores de aplicación)
# Solo recibe tráfico desde el ALB, no directamente desde Internet
resource "aws_security_group" "ec2" {
  name        = "${var.project_name}-ec2-sg"
  description = "Security group para instancias EC2 de aplicación"
  vpc_id      = aws_vpc.main.id

  # ENTRADA: Solo HTTP desde el ALB (no desde Internet directamente)
  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    # Referencia al SG del ALB, no a IPs específicas
    # Esto es más seguro: si el ALB cambia de IP, la regla sigue funcionando
    security_groups = [aws_security_group.alb.id]
    description     = "HTTP solo desde el ALB"
  }

  # ENTRADA: Puerto de la aplicación (ej: Node.js en 3000, Python en 8000)
  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
    description     = "Puerto de aplicación desde el ALB"
  }

  # SALIDA: Las instancias pueden conectarse a Internet (para actualizaciones)
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
    description = "Acceso a Internet via NAT Gateway"
  }

  tags = {
    Name = "${var.project_name}-ec2-sg"
  }
}

# Security Group para RDS MySQL
# Solo acepta conexiones desde las instancias EC2
resource "aws_security_group" "rds" {
  name        = "${var.project_name}-rds-sg"
  description = "Security group para RDS MySQL"
  vpc_id      = aws_vpc.main.id

  # ENTRADA: Solo MySQL (3306) desde instancias EC2
  ingress {
    from_port       = 3306    # Puerto estándar de MySQL
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.ec2.id]  # Solo desde EC2
    description     = "MySQL solo desde instancias EC2"
  }

  # SALIDA: Sin restricciones para respuestas
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-rds-sg"
  }
}

alb.tf - Application Load Balancer

# alb.tf
# Configura el Application Load Balancer para distribuir el tráfico

# El ALB es el punto de entrada de nuestra aplicación
# Recibe peticiones HTTP/HTTPS y las distribuye entre las instancias EC2
resource "aws_lb" "main" {
  name               = "${var.project_name}-alb"
  internal           = false  # false = orientado a Internet (público)
  load_balancer_type = "application"  # Capa 7 (HTTP/HTTPS)

  # El ALB debe estar en las subredes públicas para ser accesible desde Internet
  subnets = aws_subnet.public[*].id

  # Asociar el Security Group que permite HTTP/HTTPS
  security_groups = [aws_security_group.alb.id]

  # Si una AZ falla, el ALB deja de enviar tráfico allí
  enable_deletion_protection = false  # En prod: true para evitar borrados accidentales

  tags = {
    Name = "${var.project_name}-alb"
  }
}

# Target Group: El grupo de instancias que recibirán el tráfico
# El ALB envía las solicitudes a las instancias de este grupo
resource "aws_lb_target_group" "app" {
  name     = "${var.project_name}-tg"
  port     = 80      # Puerto en las instancias EC2
  protocol = "HTTP"
  vpc_id   = aws_vpc.main.id

  # Health Check: Verifica que cada instancia está sana antes de enviarle tráfico
  health_check {
    enabled             = true
    healthy_threshold   = 2     # Checks exitosos para marcar como sana
    unhealthy_threshold = 3     # Checks fallidos para marcar como no sana
    timeout             = 5     # Segundos para esperar respuesta
    interval            = 30    # Segundos entre checks
    path                = "/"   # URL a verificar
    matcher             = "200" # Código HTTP esperado
  }

  tags = {
    Name = "${var.project_name}-tg"
  }
}

# Listener: Escucha en el puerto 80 y reenvía al Target Group
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.main.arn  # Asociar con nuestro ALB
  port              = "80"
  protocol          = "HTTP"

  # Acción por defecto: reenviar al target group
  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

ec2.tf - Servidores de Aplicación

# ec2.tf
# Launch Template y Auto Scaling Group para los servidores de aplicación

# IAM Role para las instancias EC2
# Permite que EC2 se comunique con otros servicios de AWS (S3, CloudWatch, etc.)
resource "aws_iam_role" "ec2" {
  name = "${var.project_name}-ec2-role"

  # Política de confianza: permite que EC2 asuma este rol
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })
}

# Adjuntar política para acceso a SSM (permite conexión sin SSH)
resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# Adjuntar política para CloudWatch Agent
resource "aws_iam_role_policy_attachment" "cloudwatch" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}

# Política personalizada para acceder a S3
resource "aws_iam_role_policy" "s3_access" {
  name = "${var.project_name}-s3-access"
  role = aws_iam_role.ec2.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.static.arn,
          "${aws_s3_bucket.static.arn}/*"
        ]
      }
    ]
  })
}

# Instance Profile: "contenedor" del IAM Role que se adjunta a EC2
resource "aws_iam_instance_profile" "ec2" {
  name = "${var.project_name}-ec2-profile"
  role = aws_iam_role.ec2.name
}

# Launch Template: Plantilla de configuración para nuevas instancias
# El Auto Scaling Group usa esta plantilla para crear instancias
resource "aws_launch_template" "app" {
  name_prefix   = "${var.project_name}-lt-"
  image_id      = data.aws_ami.amazon_linux.id  # AMI de Amazon Linux 2023
  instance_type = var.instance_type

  # Adjuntar el IAM Profile para acceso a AWS APIs
  iam_instance_profile {
    name = aws_iam_instance_profile.ec2.name
  }

  # Security Group para las instancias
  vpc_security_group_ids = [aws_security_group.ec2.id]

  # Script que se ejecuta al iniciar la instancia por primera vez
  user_data = base64encode(templatefile("${path.module}/user_data.sh", {
    db_host = aws_db_instance.main.address  # Dirección del servidor de BD
    db_name = var.db_name
    db_user = var.db_username
    s3_bucket = aws_s3_bucket.static.id
  }))

  # Configuración del disco principal
  block_device_mappings {
    device_name = "/dev/xvda"
    ebs {
      volume_size           = 20   # GB
      volume_type           = "gp3"  # SSD de propósito general (más barato que gp2)
      delete_on_termination = true   # Borrar disco cuando se termina la instancia
      encrypted             = true   # Encriptar el disco
    }
  }

  # Metadatos de instancia versión 2 (más seguro)
  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"  # IMDSv2 obligatorio
    http_put_response_hop_limit = 1
  }

  # Monitorización detallada (datos cada minuto en lugar de cada 5 min)
  monitoring {
    enabled = true
  }

  # Las instancias reciben una nueva versión al cambiar la plantilla
  lifecycle {
    create_before_destroy = true
  }

  tags = {
    Name = "${var.project_name}-launch-template"
  }
}

# Auto Scaling Group
# Gestiona automáticamente el número de instancias EC2
resource "aws_autoscaling_group" "app" {
  name                = "${var.project_name}-asg"
  vpc_zone_identifier = aws_subnet.private[*].id  # Subredes privadas

  # Límites del grupo
  min_size         = var.min_size
  max_size         = var.max_size
  desired_capacity = var.desired_capacity

  # Tiempo de espera entre el lanzamiento y las primeras comprobaciones de salud
  health_check_grace_period = 300  # 5 minutos

  # Usar health checks del ALB (más inteligente que EC2 checks)
  health_check_type = "ELB"

  # Registrar nuevas instancias en el Target Group del ALB automáticamente
  target_group_arns = [aws_lb_target_group.app.arn]

  # Usar el Launch Template que definimos
  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"  # Siempre usar la versión más reciente
  }

  # Política de sustitución de instancias: reemplazar las más antiguas primero
  instance_refresh {
    strategy = "Rolling"
    preferences {
      min_healthy_percentage = 50  # Mantener al menos 50% de instancias sanas durante actualización
    }
  }

  tag {
    key                 = "Name"
    value               = "${var.project_name}-ec2"
    propagate_at_launch = true  # Aplicar tag a las instancias que se lancen
  }
}

# Política de Auto Scaling: escalar según uso de CPU
resource "aws_autoscaling_policy" "cpu_scaling" {
  name                   = "${var.project_name}-cpu-scaling"
  autoscaling_group_name = aws_autoscaling_group.app.name
  policy_type            = "TargetTrackingScaling"  # AWS ajusta automáticamente

  target_tracking_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }
    target_value = 70.0  # Mantener CPU en ~70%
  }
}

user_data.sh - Script de Inicialización

#!/bin/bash
# user_data.sh
# Script que se ejecuta al iniciar cada instancia EC2
# Se usa para instalar la aplicación y configurarla

# Actualizar el sistema
yum update -y

# Instalar Nginx como servidor web
yum install -y nginx

# Instalar Node.js (para aplicaciones Node)
curl -fsSL https://rpm.nodesource.com/setup_18.x | bash -
yum install -y nodejs

# Instalar el agente de CloudWatch para métricas detalladas
yum install -y amazon-cloudwatch-agent

# Crear una aplicación web simple de prueba
cat > /var/www/html/index.html << 'HTML'
<!DOCTYPE html>
<html>
<head><title>Mi WebApp en AWS</title></head>
<body>
  <h1>Infraestructura desplegada con Terraform</h1>
  <p>Servidor: $(hostname)</p>
  <p>Base de datos: ${db_host}</p>
</body>
</html>
HTML

# Configurar variables de entorno de la aplicación
cat > /etc/app.env << EOF
DB_HOST=${db_host}
DB_NAME=${db_name}
DB_USER=${db_user}
S3_BUCKET=${s3_bucket}
EOF

# Iniciar y habilitar Nginx
systemctl start nginx
systemctl enable nginx

# Señal de que user_data completó correctamente
echo "User data completed successfully" >> /var/log/user-data.log

rds.tf - Base de Datos MySQL

# rds.tf
# Instancia de base de datos RDS MySQL en alta disponibilidad

resource "aws_db_instance" "main" {
  identifier = "${var.project_name}-mysql"  # Nombre único de la instancia

  # Motor de base de datos
  engine         = "mysql"
  engine_version = "8.0"  # MySQL 8.0

  # Tipo y almacenamiento
  instance_class        = var.db_instance_class
  allocated_storage     = var.db_allocated_storage
  max_allocated_storage = 100  # Auto-escala hasta 100GB si es necesario

  # Configuración de la base de datos
  db_name  = var.db_name
  username = var.db_username
  password = var.db_password

  # Ubicación en la red
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.rds.id]
  publicly_accessible    = false  # No accesible desde Internet

  # Alta disponibilidad: replica en otra AZ automáticamente
  multi_az = var.environment == "prod" ? true : false

  # Backups automáticos: retener 7 días
  backup_retention_period = 7
  backup_window          = "03:00-04:00"  # Hora de backup (UTC)
  maintenance_window     = "Mon:04:00-Mon:05:00"  # Mantenimiento programado

  # Seguridad
  storage_encrypted = true  # Encriptar datos en reposo

  # Actualizaciones automáticas de parches de seguridad menores
  auto_minor_version_upgrade = true

  # Protección contra borrado accidental
  deletion_protection = var.environment == "prod" ? true : false

  # Al destruir en dev, no hacer snapshot final
  skip_final_snapshot       = var.environment != "prod"
  final_snapshot_identifier = var.environment == "prod" ? "${var.project_name}-final-snapshot" : null

  tags = {
    Name = "${var.project_name}-mysql"
  }
}

s3.tf - Almacenamiento de Objetos

# s3.tf
# Bucket S3 para almacenamiento de archivos estáticos

resource "aws_s3_bucket" "static" {
  # Los nombres de S3 son globales y únicos en todo AWS
  bucket = "${var.project_name}-static-${data.aws_caller_identity.current.account_id}"

  tags = {
    Name    = "${var.project_name}-static"
    Purpose = "Almacenamiento de archivos estáticos"
  }
}

# Obtener el ID de cuenta AWS actual
data "aws_caller_identity" "current" {}

# Bloquear acceso público al bucket (solo EC2 puede acceder)
resource "aws_s3_bucket_public_access_block" "static" {
  bucket = aws_s3_bucket.static.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Encriptación del bucket
resource "aws_s3_bucket_server_side_encryption_configuration" "static" {
  bucket = aws_s3_bucket.static.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"  # Encriptación AES de 256 bits
    }
  }
}

# Versionado de objetos: permite recuperar versiones anteriores
resource "aws_s3_bucket_versioning" "static" {
  bucket = aws_s3_bucket.static.id

  versioning_configuration {
    status = "Enabled"
  }
}

cloudwatch.tf - Monitorización

# cloudwatch.tf
# Alarmas de CloudWatch para monitorizar la salud de la infraestructura

# SNS Topic para enviar notificaciones por email
resource "aws_sns_topic" "alerts" {
  name = "${var.project_name}-alerts"
}

# Suscripción de email al topic SNS
resource "aws_sns_topic_subscription" "email" {
  count     = var.alarm_email != "" ? 1 : 0  # Solo si se proporcionó email
  topic_arn = aws_sns_topic.alerts.arn
  protocol  = "email"
  endpoint  = var.alarm_email
}

# Alarma: CPU alta en el Auto Scaling Group
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
  alarm_name          = "${var.project_name}-high-cpu"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2      # Evaluar 2 períodos consecutivos
  metric_name         = "CPUUtilization"
  namespace           = "AWS/EC2"
  period              = 300    # Período de 5 minutos
  statistic           = "Average"
  threshold           = 80     # Alertar si CPU > 80%

  dimensions = {
    AutoScalingGroupName = aws_autoscaling_group.app.name
  }

  alarm_description = "CPU del ASG supera el 80% durante 10 minutos"
  alarm_actions     = [aws_sns_topic.alerts.arn]
  ok_actions        = [aws_sns_topic.alerts.arn]  # Notificar cuando se recupere
}

# Alarma: Base de datos con pocas conexiones disponibles
resource "aws_cloudwatch_metric_alarm" "db_connections" {
  alarm_name          = "${var.project_name}-db-connections"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 1
  metric_name         = "DatabaseConnections"
  namespace           = "AWS/RDS"
  period              = 300
  statistic           = "Average"
  threshold           = 80  # Alertar si hay más de 80 conexiones

  dimensions = {
    DBInstanceIdentifier = aws_db_instance.main.id
  }

  alarm_description = "Alto número de conexiones a la base de datos"
  alarm_actions     = [aws_sns_topic.alerts.arn]
}

# Alarma: ALB con alta latencia
resource "aws_cloudwatch_metric_alarm" "alb_latency" {
  alarm_name          = "${var.project_name}-high-latency"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "TargetResponseTime"
  namespace           = "AWS/ApplicationELB"
  period              = 60
  statistic           = "Average"
  threshold           = 2  # Alertar si la latencia supera 2 segundos

  dimensions = {
    LoadBalancer = aws_lb.main.arn_suffix
  }

  alarm_description = "La latencia del ALB supera los 2 segundos"
  alarm_actions     = [aws_sns_topic.alerts.arn]
}

outputs.tf - Salidas del Proyecto

# outputs.tf
# Información importante que queremos ver tras aplicar el plan

output "alb_dns_name" {
  description = "DNS del Application Load Balancer (URL de acceso a la aplicación)"
  value       = aws_lb.main.dns_name
}

output "alb_zone_id" {
  description = "Zone ID del ALB (necesario para crear registros DNS de tipo ALIAS)"
  value       = aws_lb.main.zone_id
}

output "vpc_id" {
  description = "ID de la VPC principal"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs de las subredes públicas"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "IDs de las subredes privadas"
  value       = aws_subnet.private[*].id
}

output "database_subnet_ids" {
  description = "IDs de las subredes de base de datos"
  value       = aws_subnet.database[*].id
}

output "rds_endpoint" {
  description = "Endpoint de conexión a la base de datos RDS"
  value       = aws_db_instance.main.address
  sensitive   = true  # No mostrar en los logs
}

output "rds_port" {
  description = "Puerto de la base de datos RDS"
  value       = aws_db_instance.main.port
}

output "s3_bucket_name" {
  description = "Nombre del bucket S3 para archivos estáticos"
  value       = aws_s3_bucket.static.id
}

output "s3_bucket_arn" {
  description = "ARN del bucket S3"
  value       = aws_s3_bucket.static.arn
}

output "asg_name" {
  description = "Nombre del Auto Scaling Group"
  value       = aws_autoscaling_group.app.name
}

Instrucciones de Despliegue Paso a Paso

Paso 1: Preparar el Entorno

# Verificar que Terraform está instalado
terraform version

# Verificar que AWS CLI está configurado
aws sts get-caller-identity

# Clonar o crear el directorio del proyecto
mkdir terraform-aws-infrastructure
cd terraform-aws-infrastructure

Paso 2: Crear los Archivos del Proyecto

# Crear todos los archivos .tf según el contenido de este tutorial
# Asegúrate de que user_data.sh tiene permisos de ejecución
chmod +x user_data.sh

Paso 3: Inicializar Terraform

# Inicializar el directorio de trabajo
# Descarga los providers necesarios (AWS en este caso)
terraform init

# Salida esperada:
# Initializing the backend...
# Initializing provider plugins...
# - Finding hashicorp/aws versions matching "~> 5.0"...
# - Installing hashicorp/aws v5.x.x...
# Terraform has been successfully initialized!

Paso 4: Validar la Configuración

# Comprobar que la sintaxis es correcta
terraform validate

# Salida esperada:
# Success! The configuration is valid.

# Formatear el código (buena práctica)
terraform fmt -recursive

Paso 5: Planificar el Despliegue

# Ver qué recursos se crearán SIN crear nada aún
terraform plan -out=tfplan

# Revisar el plan cuidadosamente:
# + indica recursos que se crearán
# ~ indica recursos que se modificarán
# - indica recursos que se destruirán

Paso 6: Aplicar el Plan

# Crear la infraestructura (pedirá confirmación)
terraform apply tfplan

# O aplicar directamente (pedirá "yes" de confirmación)
terraform apply

# El proceso tarda aproximadamente 15-20 minutos
# RDS es el recurso más lento en crearse

Paso 7: Verificar los Outputs

# Ver todos los outputs
terraform output

# Ver un output específico
terraform output alb_dns_name

# Acceder a la aplicación (copiar el DNS del ALB)
curl http://$(terraform output -raw alb_dns_name)

Verificar que Todo Funciona

Verificar la VPC

# Listar VPCs en la región
aws ec2 describe-vpcs --filters "Name=tag:Project,Values=mi-webapp"

# Verificar subredes
aws ec2 describe-subnets \
  --filters "Name=tag:Project,Values=mi-webapp" \
  --query 'Subnets[*].[SubnetId,CidrBlock,AvailabilityZone,MapPublicIpOnLaunch]' \
  --output table

Verificar el ALB y los Targets

# Ver el estado de los targets (deben aparecer como "healthy")
ALB_ARN=$(aws elbv2 describe-load-balancers \
  --names "mi-webapp-alb" \
  --query 'LoadBalancers[0].LoadBalancerArn' \
  --output text)

TG_ARN=$(aws elbv2 describe-target-groups \
  --load-balancer-arn $ALB_ARN \
  --query 'TargetGroups[0].TargetGroupArn' \
  --output text)

aws elbv2 describe-target-health --target-group-arn $TG_ARN

Probar la Aplicación Web

# Obtener el DNS del ALB
ALB_DNS=$(terraform output -raw alb_dns_name)

# Probar que responde
curl -v http://$ALB_DNS/

# Probar varias veces para verificar que el load balancer distribuye
for i in {1..5}; do
  curl -s http://$ALB_DNS/ | grep "Servidor:"
done

Verificar el Auto Scaling

# Ver el estado del ASG
aws autoscaling describe-auto-scaling-groups \
  --auto-scaling-group-names "mi-webapp-asg" \
  --query 'AutoScalingGroups[0].{Min:MinSize,Max:MaxSize,Desired:DesiredCapacity,Instances:Instances[*].LifecycleState}'

Destruir la Infraestructura

# IMPORTANTE: Esto eliminará TODOS los recursos creados
# En producción, verifica que has hecho backup de los datos

# Primero, crear un plan de destrucción para revisar
terraform plan -destroy

# Destruir todo
terraform destroy

# Confirmar con "yes" cuando lo solicite
# El proceso tarda aproximadamente 10-15 minutos

Qué Destruir Primero si Hay Problemas

# Si la destrucción falla, puedes destruir recursos específicos:

# Primero el ASG (para que no cree más instancias)
terraform destroy -target=aws_autoscaling_group.app

# Luego el ALB
terraform destroy -target=aws_lb.main

# Luego RDS (el más lento)
terraform destroy -target=aws_db_instance.main

# Finalmente el resto
terraform destroy

Errores Comunes y Soluciones

Error: "Error creating DB Instance: InvalidParameterCombination"

Error: Error creating DB Instance: InvalidParameterCombination:
Cannot specify a publicly accessible DB instance in this VPC.

Solución: Verificar que publicly_accessible = false y que el db_subnet_group usa subredes privadas.

Error: "Error creating Load Balancer: ValidationError: At least two subnets in two different Availability Zones must be specified"

Solución: El ALB requiere al menos 2 subredes en 2 AZs distintas. Asegúrate de que public_subnet_cidrs tiene al menos 2 elementos.

Error: "Error: Error modifying DB Instance: InvalidParameterCombination: Cannot upgrade..."

Solución: RDS no permite hacer downgrade de versión de motor. Solo upgrades menores son automáticos.


Ejercicios de Extensión

Ejercicio 1: Añadir HTTPS con ACM

Añade un certificado SSL/TLS usando AWS Certificate Manager y modifica el ALB para escuchar en HTTPS.

Pistas:

  • Recurso aws_acm_certificate para solicitar el certificado
  • Recurso aws_acm_certificate_validation para validar
  • Añadir un listener HTTPS al ALB
  • Redirigir HTTP a HTTPS

Solución:

# Solicitar certificado SSL (requiere un dominio en Route 53)
resource "aws_acm_certificate" "main" {
  domain_name       = "app.tudominio.com"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

# Listener HTTPS en el ALB
resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.main.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.main.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

# Redirigir HTTP a HTTPS
resource "aws_lb_listener_rule" "http_redirect" {
  listener_arn = aws_lb_listener.http.arn

  action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }

  condition {
    path_pattern {
      values = ["/*"]
    }
  }
}

Ejercicio 2: Añadir WAF (Web Application Firewall)

# WAF Web ACL con reglas gestionadas por AWS
resource "aws_wafv2_web_acl" "main" {
  name  = "${var.project_name}-waf"
  scope = "REGIONAL"  # Para ALBs (no CloudFront)

  default_action {
    allow {}  # Permitir por defecto, bloquear excepciones
  }

  # Regla de AWS: protección contra ataques comunes
  rule {
    name     = "AWSManagedRulesCommonRuleSet"
    priority = 1

    override_action {
      none {}  # Usar la acción de la regla original
    }

    statement {
      managed_rule_group_statement {
        name        = "AWSManagedRulesCommonRuleSet"
        vendor_name = "AWS"
      }
    }

    visibility_config {
      cloudwatch_metrics_enabled = true
      metric_name                = "CommonRuleSet"
      sampled_requests_enabled   = true
    }
  }

  visibility_config {
    cloudwatch_metrics_enabled = true
    metric_name                = "${var.project_name}-waf"
    sampled_requests_enabled   = true
  }
}

# Asociar WAF con el ALB
resource "aws_wafv2_web_acl_association" "main" {
  resource_arn = aws_lb.main.arn
  web_acl_arn  = aws_wafv2_web_acl.main.arn
}

Ejercicio 3: Auto-Scaling Basado en CPU

# Política de escalado programado (más instancias en horas punta)
resource "aws_autoscaling_schedule" "scale_up_morning" {
  scheduled_action_name  = "scale-up-morning"
  autoscaling_group_name = aws_autoscaling_group.app.name
  min_size               = 2
  max_size               = 6
  desired_capacity       = 4
  recurrence             = "0 8 * * MON-FRI"  # 8:00 AM L-V (cron)
  time_zone              = "Europe/Madrid"
}

resource "aws_autoscaling_schedule" "scale_down_night" {
  scheduled_action_name  = "scale-down-night"
  autoscaling_group_name = aws_autoscaling_group.app.name
  min_size               = 1
  max_size               = 4
  desired_capacity       = 1
  recurrence             = "0 22 * * *"  # 10:00 PM todos los días
  time_zone              = "Europe/Madrid"
}

Resumen y Lecciones Aprendidas

En este proyecto hemos construido una infraestructura web de nivel de producción en AWS completamente con Terraform. Las lecciones clave son:

  • Separación de archivos por función: Dividir el código en vpc.tf, ec2.tf, rds.tf, etc. mejora enormemente la mantenibilidad y la lectura del código.
  • Variables con validaciones: Usar validation en las variables evita errores de configuración antes de aplicar.
  • Data Sources: Los data blocks hacen el código más portable (AMIs por región, AZs disponibles).
  • Security Groups encadenados: Referenciar SGs entre sí es más robusto que usar IPs.
  • Alta disponibilidad con múltiples AZs: Distribuir recursos en 2 AZs garantiza resiliencia ante fallos de zona.
  • Outputs útiles: Los outputs bien nombrados hacen la infraestructura más fácil de integrar con otros sistemas.

En el siguiente proyecto aplicaremos estos conocimientos en un escenario multi-cloud, desplegando infraestructura simultáneamente en AWS y Azure.

© Copyright 2026. Todos los derechos reservados