Problema
Muchos proyectos de aprendizaje o pruebas de concepto terminan con una VPC “todo en uno” donde las instancias privadas reciben IP pública o el tráfico sale sin control. El patrón que suele romperse es la separación clara entre subredes públicas (bastión, NAT) y subredes privadas (cargas de trabajo), junto con una configuración de Terraform que mezcla recursos sin módulos reutilizables. Cuando el diseño no sigue las mejores prácticas, aparecen problemas de conectividad, exposición accidental y dificultad para escalar o migrar a producción.
Causa
- Rutas y tablas mal alineadas – Si la tabla de rutas de la subred privada apunta directamente al IGW, la instancia privada sale sin pasar por NAT y pierde la máscara de IP privada.
- Security groups demasiado amplios – Permitir
0.0.0.0/0en puertos SSH o RDP para la instancia bastión facilita el acceso, pero también abre la puerta a escaneos automatizados. - NAT basado en instancia sin ajustes de
source_dest_check– Olvidar desactivar esta opción impide que la NAT haga masquerading y genera “timeout” en la salida. - Terraform sin módulos – Definir VPC, subredes y recursos de red en un único archivo dificulta la reutilización y el versionado.
- Falta de etiquetas y de políticas de IAM – Sin etiquetas consistentes, la auditoría y el coste se vuelven opacos; sin roles limitados, cualquier persona con acceso a Terraform puede crear recursos críticos.
Solución
Adoptar una arquitectura modular y explícita que separe claramente los componentes de red, aplique el principio de “least privilege” y utilice recursos nativos de AWS para NAT cuando sea posible. Cuando se prefiere una NAT basada en instancia (por ejemplo, para practicar iptables), seguir estos pasos:
- Módulo VPC – Define VPC, subredes públicas y privadas, IGW y tablas de rutas en su propio módulo. Usa variables para CIDR y número de zonas de disponibilidad.
- Bastión como recurso independiente – Crea una instancia
t3.microen la subred pública, asigna un security group que solo permita SSH desde rangos de IP de la oficina o del home office. Habilitaagent forwardingen el cliente y desactiva el acceso directo a la subred privada. - NAT Instance – Lanza una instancia ligera (por ejemplo, Amazon Linux 2) en la subred pública, desactiva
source_dest_check, y configuraiptablespara masquerade. Asocia un security group que solo permita tráfico saliente a0.0.0.0/0y tráfico entrante desde la subred privada en puertos necesarios (HTTP/HTTPS). - Route tables – Asigna una tabla de rutas a la subred privada que tenga un único
0.0.0.0/0apuntando al ENI de la NAT Instance. La subred pública usa la tabla con ruta al IGW. - Etiquetado y IAM – Aplica etiquetas estándar (
Name,Environment,Owner) a cada recurso. Usa un role de IAM limitado aec2:Describe*,ec2:CreateTagsyec2:RunInstancespara la ejecución de Terraform. - Validación automática – Integra
terraform validateyterraform planen un pipeline CI/CD para evitar drift antes de aplicar cambios.
Cuándo aplicar esta solución
- Entornos de aprendizaje o pruebas donde se necesita experimentar con NAT basada en instancia y no se justifica el coste de un NAT Gateway.
- Arquitecturas que requieren inspección de tráfico (por ejemplo, proxy o firewall de capa 3) antes de salir a Internet.
- Proyectos que deben cumplir con normas de auditoría que exigen separación de subredes y control estricto de security groups.
No es recomendable en cargas de producción de alto tráfico, ya que una NAT Instance se convierte en cuello de botella y su escalado es manual. En esos casos, sustituirla por un NAT Gateway o un Transit Gateway es la mejor práctica.
Código
# vpc/main.tf
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.0.0"
name = var.vpc_name
cidr = var.vpc_cidr
azs = data.aws_availability_zones.available.names
public_subnets = [cidrsubnet(var.vpc_cidr, 8, 1)]
private_subnets = [cidrsubnet(var.vpc_cidr, 8, 2)]
enable_nat_gateway = false # usamos NAT instance
enable_dns_hostnames = true
tags = {
Environment = var.environment
Owner = var.owner
}
}
# bastion/main.tf
resource "aws_instance" "bastion" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnets[0]
associate_public_ip_address = true
vpc_security_group_ids = [aws_security_group.bastion.id]
tags = {
Name = "${var.environment}-bastion"
Environment = var.environment
}
}
resource "aws_security_group" "bastion" {
name = "${var.environment}-sg-bastion"
description = "SSH access from office IPs"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.allowed_ssh_cidrs
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# nat-instance/main.tf
resource "aws_instance" "nat" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnets[0]
source_dest_check = false
vpc_security_group_ids = [aws_security_group.nat.id]
user_data = <<-EOF
#!/bin/bash
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
EOF
tags = {
Name = "${var.environment}-nat"
Environment = var.environment
}
}
resource "aws_security_group" "nat" {
name = "${var.environment}-sg-nat"
description = "Allow traffic from private subnets"
vpc_id = module.vpc.vpc_id
ingress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [module.vpc.private_subnets_cidr_blocks[0]]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# route-table/main.tf
resource "aws_route_table" "private" {
vpc_id = module.vpc.vpc_id
route {
cidr_block = "0.0.0.0/0"
instance_id = aws_instance.nat.id
}
tags = {
Name = "${var.environment}-private-rt"
}
}
resource "aws_route_table_association" "private" {
subnet_id = module.vpc.private_subnets[0]
route_table_id = aws_route_table.private.id
}
Verificación
- Conectividad SSH – Desde tu laptop, ejecuta
ssh -A ec2-user@<bastion-public-ip>y luegossh ec2-user@<private-instance-private-ip>. La sesión debe pasar por el bastión sin solicitar clave adicional. - Salida a Internet – Dentro de la instancia privada, corre
curl -s https://checkip.amazonaws.com. La IP retornada debe coincidir con la IP pública del NAT Instance, no con la del bastión. - Reglas de SG – Usa
aws ec2 describe-security-groupspara confirmar que los grupos bastión y NAT solo permiten los CIDR esperados. - Route tables – Verifica que la tabla de la subred privada tenga una ruta
0.0.0.0/0apuntando al ID de la NAT Instance y que la subred pública apunte al IGW.
Notas adicionales
- Escalado de NAT – Si el tráfico crece, considera reemplazar la instancia por un NAT Gateway; el módulo
terraform-aws-modules/vpc/awspermite habilitarlo conenable_nat_gateway = true. - Auditoría de logs – Habilita VPC Flow Logs en la VPC y envíalos a CloudWatch o S3 para detectar accesos inesperados.
- Rotación de claves – Usa
aws_key_pairgestionado por Terraform y rota las claves cada 90 días; evita almacenar claves privadas en el repositorio. - Mantenimiento de iptables – Al actualizar la AMI del NAT, verifica que el script de
user_datasiga aplicando el masquerade; una versión de Amazon Linux 2023 cambia la interfaz de red aeth0porens5. - Coste – Una NAT Instance cuesta menos que un NAT Gateway, pero el precio del tráfico saliente se mantiene. Monitorea el uso con Cost Explorer para decidir el punto de corte