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

  1. 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.
  2. Security groups demasiado amplios – Permitir 0.0.0.0/0 en puertos SSH o RDP para la instancia bastión facilita el acceso, pero también abre la puerta a escaneos automatizados.
  3. 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.
  4. Terraform sin módulos – Definir VPC, subredes y recursos de red en un único archivo dificulta la reutilización y el versionado.
  5. 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:

  1. 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.
  2. Bastión como recurso independiente – Crea una instancia t3.micro en la subred pública, asigna un security group que solo permita SSH desde rangos de IP de la oficina o del home office. Habilita agent forwarding en el cliente y desactiva el acceso directo a la subred privada.
  3. NAT Instance – Lanza una instancia ligera (por ejemplo, Amazon Linux 2) en la subred pública, desactiva source_dest_check, y configura iptables para masquerade. Asocia un security group que solo permita tráfico saliente a 0.0.0.0/0 y tráfico entrante desde la subred privada en puertos necesarios (HTTP/HTTPS).
  4. Route tables – Asigna una tabla de rutas a la subred privada que tenga un único 0.0.0.0/0 apuntando al ENI de la NAT Instance. La subred pública usa la tabla con ruta al IGW.
  5. Etiquetado y IAM – Aplica etiquetas estándar (Name, Environment, Owner) a cada recurso. Usa un role de IAM limitado a ec2:Describe*, ec2:CreateTags y ec2:RunInstances para la ejecución de Terraform.
  6. Validación automática – Integra terraform validate y terraform plan en 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

  1. Conectividad SSH – Desde tu laptop, ejecuta ssh -A ec2-user@<bastion-public-ip> y luego ssh ec2-user@<private-instance-private-ip>. La sesión debe pasar por el bastión sin solicitar clave adicional.
  2. 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.
  3. Reglas de SG – Usa aws ec2 describe-security-groups para confirmar que los grupos bastión y NAT solo permiten los CIDR esperados.
  4. Route tables – Verifica que la tabla de la subred privada tenga una ruta 0.0.0.0/0 apuntando 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/aws permite habilitarlo con enable_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_pair gestionado 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_data siga aplicando el masquerade; una versión de Amazon Linux 2023 cambia la interfaz de red a eth0 por ens5.
  • 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