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 alarmasTabla 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.logrds.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-infrastructurePaso 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.shPaso 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 -recursivePaso 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ánPaso 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 crearsePaso 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 tableVerificar 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_ARNProbar 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:"
doneVerificar 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 minutosQué 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 destroyErrores 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_certificatepara solicitar el certificado - Recurso
aws_acm_certificate_validationpara 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
validationen las variables evita errores de configuración antes de aplicar. - Data Sources: Los
datablocks 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.
Curso de Terraform
Módulo 1: Introducción a Terraform
- ¿Qué es Terraform?
- Instalando Terraform
- Conceptos Básicos de Terraform
- Primera Configuración de Terraform
Módulo 2: Lenguaje de Configuración de Terraform
Módulo 3: Gestión del Estado
Módulo 4: Módulos de Terraform
Módulo 5: Aprovisionamiento de Recursos
- Conceptos Básicos de Aprovisionamiento
- Aprovisionamiento de Recursos AWS
- Aprovisionamiento de Recursos Azure
- Aprovisionamiento de Recursos GCP
Módulo 6: Funcionalidades Avanzadas de Terraform
Módulo 7: Mejores Prácticas de Terraform
- Organización del Código
- Control de Versiones
- Pruebas del Código de Terraform
- Mejores Prácticas de Seguridad
Módulo 8: Terraform en CI/CD
- Integración de Terraform con CI/CD
- Automatización de Terraform con Jenkins
- Uso de Terraform con GitHub Actions
- Terraform Cloud y Enterprise
