Provisionar una VPC de AWS con Terraform
Cómo usar Terraform para provisionar una VPC de AWS lista para producción con subredes públicas y privadas, NAT gateways y security groups
Visión General
Toda carga de trabajo que corras en AWS termina en una VPC, y la que AWS crea por defecto rara vez sirve para producción: todo queda en subredes públicas, los flow logs están apagados y las reglas de salida son demasiado permisivas. Esta receta construye una VPC lista para producción con Terraform desde cero: subredes públicas y privadas en tres zonas de disponibilidad, NAT gateways para el tráfico saliente, security groups restrictivos, flow logs y VPC endpoints para que el tráfico interno nunca toque internet.
Cuándo Usar Esto
- Necesitas infraestructura de red repetible y versionada en dev, staging y prod; recrear la misma topología a mano en cada cuenta no escala. Consulta Git Workflow para mantener ese código bajo control de versiones.
- Los únicos recursos que deberían hablar con internet son tus load balancers — todo lo que está detrás (servidores de aplicación, bases de datos) pertenece a subredes sin ruta pública. Consulta AWS ECS Fargate para correr contenedores en esas subredes.
- Quieres segmentación real entre tiers: un balanceador que solo llega a los puertos de la app, un tier que solo llega a la base de datos. Consulta Load Balancing HAProxy para la capa superior.
- Tu auditoría exige flow logs y fronteras de red documentadas, no un diagrama dibujado a posteriori.
Requisitos Previos
- AWS CLI configurado con credenciales apropiadas
- Terraform 1.5+ instalado
Solución
Los snippets de abajo están listos para copiar. Una versión completa y ejecutable (variables, outputs y el toggle de NAT único cableado a las tablas de ruteo) está en el repositorio complementario.
1. VPC y Subredes
# vpc.tf
locals {
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "production-vpc"
}
}
resource "aws_subnet" "public" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = {
Name = "public-subnet-${count.index + 1}"
Type = "public"
}
}
resource "aws_subnet" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 100)
availability_zone = local.azs[count.index]
tags = {
Name = "private-subnet-${count.index + 1}"
Type = "private"
}
}
2. Gateways de Internet y NAT
# gateways.tf
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "main-igw" }
}
resource "aws_eip" "nat" {
count = length(local.azs)
domain = "vpc"
tags = { Name = "nat-eip-${count.index + 1}" }
}
resource "aws_nat_gateway" "main" {
count = length(local.azs)
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = { Name = "nat-gateway-${count.index + 1}" }
}
3. Tablas de Ruteo
# routing.tf
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = { Name = "public-rt" }
}
resource "aws_route_table_association" "public" {
count = length(local.azs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
count = length(local.azs)
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[count.index].id
}
tags = { Name = "private-rt-${count.index + 1}" }
}
resource "aws_route_table_association" "private" {
count = length(local.azs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
4. Security Groups
# security.tf
resource "aws_security_group" "web" {
name_prefix = "web-"
vpc_id = aws_vpc.main.id
description = "Permitir tráfico HTTP y HTTPS"
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 = "web-sg" }
}
resource "aws_security_group" "database" {
name_prefix = "db-"
vpc_id = aws_vpc.main.id
description = "Permitir acceso a base de datos solo desde capa web"
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id]
}
tags = { Name = "database-sg" }
}
5. VPC Flow Logs
# flow_logs.tf
resource "aws_cloudwatch_log_group" "vpc_flow" {
name = "/aws/vpc/flow-logs"
retention_in_days = 30
}
resource "aws_iam_role" "vpc_flow" {
name = "vpc-flow-logs-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "vpc-flow-logs.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "vpc_flow" {
name = "vpc-flow-logs-policy"
role = aws_iam_role.vpc_flow.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups"
]
Resource = "*"
}]
})
}
resource "aws_flow_log" "main" {
vpc_id = aws_vpc.main.id
iam_role_arn = aws_iam_role.vpc_flow.arn
log_destination = aws_cloudwatch_log_group.vpc_flow.arn
log_destination_type = "cloud-watch-logs"
traffic_type = "ALL"
}
6. VPC Endpoints para Servicios AWS
# endpoints.tf
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = [for rt in aws_route_table.private : rt.id]
}
resource "aws_vpc_endpoint" "secrets_manager" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.us-east-1.secretsmanager"
vpc_endpoint_type = "Interface"
subnet_ids = [for s in aws_subnet.private : s.id]
security_group_ids = [aws_security_group.database.id]
private_dns_enabled = true
}
7. Network ACLs para Defensa en Profundidad
# nacls.tf
resource "aws_network_acl" "public" {
vpc_id = aws_vpc.main.id
subnet_ids = [for s in aws_subnet.public : s.id]
ingress {
protocol = "tcp"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 443
to_port = 443
}
ingress {
protocol = "tcp"
rule_no = 110
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 1024
to_port = 65535
}
egress {
protocol = "-1"
rule_no = 100
action = "allow"
cidr_block = "0.0.0.0/0"
from_port = 0
to_port = 0
}
}
8. Variables para Reutilización
Estas variables convierten los snippets anteriores en un módulo reutilizable. Ojo: el bloque NAT condicional de abajo reemplaza al recurso aws_nat_gateway.main de gateways.tf — usa uno u otro, no ambos, o terraform plan falla con un error de recurso duplicado.
# variables.tf
variable "environment" {
type = string
default = "production"
}
variable "cidr_block" {
type = string
default = "10.0.0.0/16"
}
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "single_nat_gateway" {
type = bool
default = false
}
# NAT condicional: uno por AZ para prod, uno solo para dev
resource "aws_nat_gateway" "main" {
count = var.single_nat_gateway ? 1 : length(var.availability_zones)
allocation_id = var.single_nat_gateway ? aws_eip.nat[0].id : aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
}
Cómo Funciona
- VPC define el espacio de direcciones IP privadas para todos los recursos
- Subredes Públicas enrutan el tráfico a través del Internet Gateway para acceso externo
- Subredes Privadas enrutan el tráfico saliente a través de los NAT Gateways por seguridad
- Tablas de Ruteo controlan la dirección del tráfico por subred
- Security Groups actúan como firewalls con estado a nivel de instancia
Una petición desde una subred privada hacia internet cruza tres capas: subred → tabla de ruteo → NAT gateway → IGW.
Consideraciones de Producción
- Usa Terraform workspaces o archivos de estado separados para cada entorno
- Habilita VPC Flow Logs a CloudWatch para la auditoría del tráfico de red
- Coloca las bases de datos y servicios internos solo en subredes privadas
- Usa AWS Network Firewall o Security Groups para defensa en profundidad
Errores Comunes
- Olvidar habilitar
map_public_ip_on_launchpara instancias en subredes públicas - Colocar los NAT Gateways en subredes privadas en lugar de públicas
- Usar bloques CIDR demasiado permisivos como
0.0.0.0/0en el ingress de los security groups
Tips de Rendimiento
- Usa endpoints de tipo Gateway para S3 y DynamoDB. Sin cargo por GB, a diferencia de los endpoints de tipo Interface:
# Gateway endpoint: gratis, sin costo por GB
vpc_endpoint_type = "Gateway"
# Interface endpoint: $7/mes + por GB
vpc_endpoint_type = "Interface"
- Coloca los NAT Gateways en cada AZ. El tráfico NAT entre zonas incurre en costos dobles de transferencia:
# Un NAT por AZ evita cargos entre zonas
count = length(var.availability_zones)
- Usa
aws_ec2_transit_gatewaypara multi-VPC. El peering escala como O(n²) conexiones; Transit Gateway es O(n):
resource "aws_ec2_transit_gateway" "main" {
description = "Platform transit gateway"
tags = local.common_tags
}
Lecturas Adicionales
- Terraform Registry — aws_vpc
- Terraform Registry — aws_nat_gateway
- AWS docs — VPC endpoints
- AWS docs — VPC Flow Logs
- terraform-aws-modules/vpc — el módulo de la comunidad que conviene evaluar antes de escribir el tuyo
Preguntas frecuentes
¿Conviene un NAT Gateway por AZ o uno solo?
Uno por AZ. Un único NAT gateway es un punto único de fallo — si su zona cae, las subredes privadas de las demás zonas pierden salida a internet — y además fuerza transferencia de datos entre zonas, que AWS cobra doble. Para un entorno dev descartable alcanza con uno; en producción, uno por AZ.
¿Puedo usar el módulo terraform-aws-modules en lugar de escribirlo a mano?
En la mayoría de los casos, sí. El módulo de la comunidad es el camino más rápido porque casos borde como IPv6 y políticas de endpoints ya vienen resueltos. Escribirlo a mano rinde cuando quieres control total sobre cada recurso o estás aprendiendo cómo encajan las piezas; leer el código del módulo te enseña lo mismo.
¿Cómo conecto esta VPC con otra?
Un aws_vpc_peering_connection más una ruta en la tabla de ruteo de cada VPC apuntando al CIDR de la otra es todo lo que hace falta. La trampa está en el espacio de direcciones solapado: dos VPCs que comparten 10.0.0.0/16 jamás podrán hacer peering, algo a recordar antes de estampar el mismo CIDR en todas partes.
¿Puedo importar una VPC existente a esta configuración?
Se puede. Ejecuta terraform import aws_vpc.main vpc-abc123 para traer la VPC bajo gestión, y después escribe la configuración que coincida con lo que ya existe. Importar subredes, tablas de ruteo y gateways uno por uno es tedioso pero funciona; para redes grandes ya existentes, herramientas como terraformer generan el HCL inicial.
Recursos Relacionados
Buenas Prácticas de Terraform: Módulos, State y Workspaces
Guía práctica de Terraform: módulos, estado remoto, workspaces y seguridad para IaC de producción.
GuideInfrastructure as Code — Terraform y Pulumi
Guía práctica para gestionar infraestructura como código: beneficios de enfoques declarativo vs imperativo, manejo de estado, módulos y testing de cambios de infraestructura.
RecipeDesplegar Contenedores en ECS con Fargate
Cómo desplegar contenedores Docker en AWS ECS usando cómputo serverless de Fargate con Terraform y GitHub Actions
RecipePlaybook de Ansible para Configuración de Servidores
Cómo escribir y ejecutar playbooks de Ansible para aprovisionar, configurar y administrar servidores con tareas idempotentes, roles e inventarios.
RecipeDespliegues Canary con Istio Service Mesh
Como usar el splitting de trafico de Istio para realizar despliegues canary seguros desplazando gradualmente usuarios entre versiones de aplicaciones
PatternPatrón Builder: construcción de objetos paso a paso
Construye objetos complejos paso a paso. Patrón de diseño creacional para construcción de objetos legible y configurable.