Lab 1: Sitio Web Primario
Lab 1: Sitio Web Primario (EC2 + ALB)
Estructura del módulo
01_primary/
├── main.tf VPC (nueva u default), EC2, ALB
├── variables.tf Inputs del módulo
├── outputs.tf Exports para módulos siguientes
└── userdata.sh Script de bootstrap nginxPaso 1: Variables de entorno y Verificación de VPC
source ~/route53-arc-tf/global.env
cd ~/route53-arc-tf/01_primaryAntes de continuar, podés comprobar qué modo de VPC está activo y verificar si tu cuenta posee una VPC Default:
aws ec2 describe-vpcs --filters "Name=is-default,Values=true" --query "Vpcs[0].VpcId" --output text
echo "Modo VPC configurado: $VPC_MODE"- Si el comando devuelve un VPC ID (ej:
vpc-0a1b2c3d), podés utilizarVPC_MODE="default". - Si devuelve
None, asegurate de queVPC_MODE="new"para que Terraform cree una VPC completa con sus subredes.
Paso 2: Crear los archivos del módulo
1. variables.tf
¿Para qué sirve este archivo?
Define los parámetros de entrada del módulo (región AWS, tipo de VPC, tipo de instancia EC2 y etiquetas). Esto permite modificar el comportamiento del despliegue sin alterar la lógica de infraestructura.
cat > variables.tf << 'EOF'
variable "aws_region" {
description = "Región AWS"
type = string
default = "us-east-1"
}
variable "vpc_mode" {
description = "default = usar VPC existente | new = crear VPC nueva"
type = string
default = "default"
validation {
condition = contains(["default", "new"], var.vpc_mode)
error_message = "vpc_mode debe ser 'default' o 'new'."
}
}
variable "instance_type" {
description = "Tipo de instancia EC2 (t4g.micro recomendado, t3.micro alternativa)"
type = string
default = "t4g.micro"
validation {
condition = contains(["t4g.micro", "t3.micro"], var.instance_type)
error_message = "Solo se permiten t4g.micro o t3.micro."
}
}
variable "use_domain" {
description = "true = habilitar HTTPS con certificado ACM y dominio real | false = modo HTTP demo"
type = bool
default = false
}
variable "domain_name" {
description = "Dominio base (ej: tudominio.com)"
type = string
default = ""
}
variable "app_subdomain" {
description = "Subdominio del workshop (ej: app)"
type = string
default = "app"
}
variable "hosted_zone_id" {
description = "ID de la Hosted Zone en Route 53 (requerido si use_domain = true)"
type = string
default = ""
}
variable "project_tag" {
description = "Tag de identificación del workshop"
type = string
default = "route53-arc"
}
EOF2. userdata.sh
¿Para qué sirve este archivo?
Es el script de inicialización (EC2 User Data) que se ejecuta al arrancar la instancia. Instala el servidor Nginx, consulta la API de metadatos de la instancia (http://169.254.169.254), genera una página HTML de estado y habilita el endpoint/healthnecesario para los Health Checks de Route 53.
cat > userdata.sh << 'EOF'
#!/bin/bash
set -e
dnf install -y nginx
systemctl enable nginx
systemctl start nginx
# IMDSv2 (recomendado por AWS Security — requiere token antes de consultar metadatos)
INSTANCE_TOKEN=$(curl -s -S -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600" 2>/dev/null || true)
INSTANCE_TYPE=$(curl -s -H "X-aws-ec2-metadata-token: $INSTANCE_TOKEN" http://169.254.169.254/latest/meta-data/instance-type)
REGION=$(curl -s -H "X-aws-ec2-metadata-token: $INSTANCE_TOKEN" http://169.254.169.254/latest/meta-data/placement/region)
cat > /usr/share/nginx/html/index.html << HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Sitio Primario – AWS Workshop</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Segoe UI',Arial,sans-serif; background:#0f172a;
color:#e2e8f0; display:flex; align-items:center;
justify-content:center; min-height:100vh; }
.card { background:#1e293b; border-radius:12px; padding:48px 64px;
text-align:center; box-shadow:0 4px 24px rgba(0,0,0,.4); }
h1 { color:#22c55e; font-size:2.5em; margin-bottom:8px; }
h2 { color:#94a3b8; font-size:1.1em; font-weight:normal; margin-bottom:24px; }
.badge { display:inline-block; background:#22c55e; color:#0f172a;
padding:6px 20px; border-radius:20px; font-weight:bold; }
.meta { color:#475569; margin-top:24px; font-size:.85em; line-height:1.8; }
</style>
</head>
<body>
<div class="card">
<h1>✅ Sitio Primario</h1>
<h2>AWS Route 53 ARC Workshop</h2>
<div class="badge">ONLINE</div>
<p class="meta">
EC2 Amazon Linux 2023 + nginx<br>
Región: ${REGION} | Instancia: ${INSTANCE_TYPE}<br>
Desplegado con <strong>Terraform</strong>
</p>
</div>
</body>
</html>
HTML
echo "OK" > /usr/share/nginx/html/health
systemctl restart nginx
EOF3. main.tf
¿Para qué sirve este archivo?
Es el archivo central del módulo donde se declaran todos los recursos de infraestructura en AWS:
- AMI dinámica: Selecciona automáticamente la imagen de Amazon Linux 2023 adecuada según la arquitectura de la instancia (
arm64parat4g.micro,x86_64parat3.micro).- Red (VPC): Si
vpc_mode = "default", consulta la VPC Default de la región. Sivpc_mode = "new", crea una VPC/16, un Internet Gateway y 2 subredes públicas en distintas Zonas de Disponibilidad.- Application Load Balancer (ALB): Crea el balanceador de carga público, su Target Group en el puerto 80 y el Listener.
- Seguridad e Instancia EC2: Define los Security Groups y aprovisiona la instancia EC2 con el script
userdata.sh.
cat > main.tf << 'EOF'
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Workshop = var.project_tag
ManagedBy = "Terraform"
Environment = "primary"
}
}
}
# ─── Data sources ──────────────────────────────────────────────────────────────
# AMI: Amazon Linux 2023 — ARM si t4g.micro, x86 si t3.micro
locals {
is_arm = startswith(var.instance_type, "t4g")
ami_arch = local.is_arm ? "arm64" : "x86_64"
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-kernel-*-${local.ami_arch}"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# VPC default (si vpc_mode = "default")
data "aws_vpc" "default" {
count = var.vpc_mode == "default" ? 1 : 0
default = true
}
data "aws_subnets" "default_public" {
count = var.vpc_mode == "default" ? 1 : 0
filter {
name = "vpc-id"
values = [data.aws_vpc.default[0].id]
}
}
# ─── VPC nueva (solo si vpc_mode = "new") ─────────────────────────────────────
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "workshop" {
count = var.vpc_mode == "new" ? 1 : 0
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "route53-arc-vpc" }
}
resource "aws_internet_gateway" "workshop" {
count = var.vpc_mode == "new" ? 1 : 0
vpc_id = aws_vpc.workshop[0].id
tags = { Name = "route53-arc-igw" }
}
resource "aws_subnet" "public" {
count = var.vpc_mode == "new" ? 2 : 0
vpc_id = aws_vpc.workshop[0].id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "route53-arc-public-${count.index + 1}" }
}
resource "aws_route_table" "public" {
count = var.vpc_mode == "new" ? 1 : 0
vpc_id = aws_vpc.workshop[0].id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.workshop[0].id
}
tags = { Name = "route53-arc-rt-public" }
}
resource "aws_route_table_association" "public" {
count = var.vpc_mode == "new" ? 2 : 0
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public[0].id
}
# ─── Locals para unificar VPC/subnets ─────────────────────────────────────────
locals {
vpc_id = var.vpc_mode == "new" ? aws_vpc.workshop[0].id : data.aws_vpc.default[0].id
subnet_ids = var.vpc_mode == "new" ? aws_subnet.public[*].id : slice(data.aws_subnets.default_public[0].ids, 0, 2)
}
# ─── Security Groups ──────────────────────────────────────────────────────────
resource "aws_security_group" "alb" {
name = "route53-arc-alb-sg-v2"
description = "SG ALB - permite HTTP/HTTPS publico"
vpc_id = local.vpc_id
lifecycle {
create_before_destroy = true
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
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"]
}
tags = { Name = "route53-arc-alb-sg" }
}
resource "aws_security_group" "ec2" {
name = "route53-arc-ec2-sg-v2"
description = "SG EC2 - solo acepta trafico del ALB"
vpc_id = local.vpc_id
lifecycle {
create_before_destroy = true
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "route53-arc-ec2-sg" }
}
# ─── IAM Role para SSM ────────────────────────────────────────────────────────
resource "aws_iam_role" "ec2_ssm" {
name = "route53-arc-ec2-ssm-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.ec2_ssm.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2" {
name = "route53-arc-ec2-profile"
role = aws_iam_role.ec2_ssm.name
}
# ─── EC2 Instance ─────────────────────────────────────────────────────────────
resource "aws_instance" "primary" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = local.subnet_ids[0]
vpc_security_group_ids = [aws_security_group.ec2.id]
iam_instance_profile = aws_iam_instance_profile.ec2.name
user_data = filebase64("${path.module}/userdata.sh")
tags = { Name = "route53-arc-primary-web" }
}
# ─── Application Load Balancer ────────────────────────────────────────────────
resource "aws_lb" "primary" {
name = "route53-arc-primary-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = local.subnet_ids
}
resource "aws_lb_target_group" "primary" {
name = "route53-arc-primary-tg"
port = 80
protocol = "HTTP"
vpc_id = local.vpc_id
health_check {
path = "/health"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_target_group_attachment" "primary" {
target_group_arn = aws_lb_target_group.primary.arn
target_id = aws_instance.primary.id
port = 80
}
# ─── ACM Certificate (solo si use_domain = true) ──────────────────────────────
resource "aws_acm_certificate" "cert" {
count = var.use_domain ? 1 : 0
domain_name = "${var.app_subdomain}.${var.domain_name}"
validation_method = "DNS"
tags = { Name = "route53-arc-cert" }
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in (var.use_domain ? aws_acm_certificate.cert[0].domain_validation_options : []) : dvo.domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each.value.name
records = [each.value.record]
ttl = 60
type = each.value.type
zone_id = var.hosted_zone_id
}
resource "aws_acm_certificate_validation" "cert" {
count = var.use_domain ? 1 : 0
certificate_arn = aws_acm_certificate.cert[0].arn
validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}
# ─── Listeners del ALB ────────────────────────────────────────────────────────
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.primary.arn
port = 80
protocol = "HTTP"
dynamic "default_action" {
for_each = var.use_domain ? [] : [1]
content {
type = "forward"
target_group_arn = aws_lb_target_group.primary.arn
}
}
dynamic "default_action" {
for_each = var.use_domain ? [1] : []
content {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
}
resource "aws_lb_listener" "https" {
count = var.use_domain ? 1 : 0
load_balancer_arn = aws_lb.primary.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-2016-08"
certificate_arn = aws_acm_certificate_validation.cert[0].certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.primary.arn
}
}
EOF4. outputs.tf
¿Para qué sirve este archivo?
Expone los datos clave generados tras el despliegue (DNS del ALB, Zone ID del ALB, ID de la instancia EC2 e ID de la VPC). Estos valores son exportados para que los laboratorios posteriores puedan consumirlos a través dedata "terraform_remote_state".
cat > outputs.tf << 'EOF'
output "alb_dns_name" {
description = "DNS del ALB primario"
value = aws_lb.primary.dns_name
}
output "alb_zone_id" {
description = "Hosted Zone ID del ALB (para alias record en Route 53)"
value = aws_lb.primary.zone_id
}
output "ec2_instance_id" {
description = "ID de la instancia EC2"
value = aws_instance.primary.id
}
output "vpc_id" {
description = "VPC utilizada"
value = local.vpc_id
}
EOFPaso 3: Inicializar y desplegar
# Inicializar Terraform (descarga provider AWS)
terraform init
# Ver el plan antes de aplicar
terraform plan \
-var="vpc_mode=${VPC_MODE}" \
-var="instance_type=${INSTANCE_TYPE:-t4g.micro}"
# Aplicar
terraform apply \
-var="vpc_mode=${VPC_MODE}" \
-var="instance_type=${INSTANCE_TYPE:-t4g.micro}" \
-auto-approveConsejo
Con terraform plan ves exactamente qué recursos se van a crear o modificar antes de aplicar los cambios en AWS.
Información
⏱️ Tiempo estimado: El apply tarda ~3 minutos. El ALB necesita otro minuto para pasar a estado active y la instancia EC2 unos 2 minutos más para que nginx arranque y pase el health check. Si la verificación da ⚠️ al principio, esperá 2-3 minutos y volvé a ejecutarla — es completamente normal.
Paso 4: Ver outputs
terraform output
export PRIMARY_ALB_DNS=$(terraform output -raw alb_dns_name)
echo "🌐 Abre: http://$PRIMARY_ALB_DNS"✅ Verificación del Lab 1
echo "=== Verificación Lab 1 ==="
# Terraform state limpio
terraform show | grep -q "aws_lb.primary" \
&& echo "✅ ALB en state" || echo "❌ ALB no encontrado en state"
# Sitio responde
sleep 20
HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://$PRIMARY_ALB_DNS/)
[ "$HTTP" = "200" ] \
&& echo "✅ Sitio primario HTTP $HTTP" \
|| echo "⚠️ HTTP $HTTP (espera 1-2 min)"
HEALTH=$(curl -s http://$PRIMARY_ALB_DNS/health | tr -d '[:space:]')
[ "$HEALTH" = "OK" ] \
&& echo "✅ Health check: OK" \
|| echo "⚠️ Health check: '$HEALTH'"
echo "ALB DNS: $PRIMARY_ALB_DNS"Aviso
El sitio no carga en el browser pero curl da 200?
El sitio usa HTTP puro (sin HTTPS). Algunos browsers modernos bloquean automáticamente HTTP o tienen caché de HSTS de visitas anteriores. Si no carga, probá en una ventana de incógnito o en un browser diferente.
Las extensiones tipo “HTTPS Everywhere” o VPNs corporativas también pueden bloquear el acceso HTTP al puerto 80.
Siguiente paso → Lab 2: Sitio Secundario