# VPC & CIDR Associations ===================================================== # Creates the VPC with a primary CIDR and associates any secondary CIDRs. # All subnets depend on this block being complete before provisioning. resource "aws_vpc" "this" { cidr_block = var.cidr_block enable_dns_hostnames = var.enable_dns_hostnames enable_dns_support = var.enable_dns_support tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.region_abbr}-vpc" resource-type = "vpc" }) } resource "aws_vpc_ipv4_cidr_block_association" "this" { for_each = toset(var.secondary_cidr_blocks) vpc_id = aws_vpc.this.id cidr_block = each.value } # Subnets ====================================================================== # 3 tiers: public (IGW-routed), private (NAT-routed), isolated (no egress) # All tiers depend on CIDR associations being complete before provisioning. resource "aws_subnet" "public" { for_each = var.public_subnets vpc_id = aws_vpc.this.id cidr_block = each.value.cidr_block availability_zone = each.value.availability_zone map_public_ip_on_launch = each.value.map_public_ip tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-subnet-public-${each.key}" resource-type = "subnet" reachability = "public" }) depends_on = [aws_vpc_ipv4_cidr_block_association.this] } resource "aws_subnet" "private" { for_each = var.private_subnets vpc_id = aws_vpc.this.id cidr_block = each.value.cidr_block availability_zone = each.value.availability_zone tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-subnet-private-${each.key}" resource-type = "subnet" reachability = "private" }) depends_on = [aws_vpc_ipv4_cidr_block_association.this] } resource "aws_subnet" "isolated" { for_each = var.isolated_subnets vpc_id = aws_vpc.this.id cidr_block = each.value.cidr_block availability_zone = each.value.availability_zone tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-subnet-isolated-${each.key}" resource-type = "subnet" reachability = "isolated" }) depends_on = [aws_vpc_ipv4_cidr_block_association.this] } # Internet Gateway =========================================================== # Created only when public subnets are defined, skipped otherwise. # The IGW itself needs no configuration beyond attaching to the VPC. resource "aws_internet_gateway" "this" { for_each = length(var.public_subnets) > 0 ? { igw = true } : {} vpc_id = aws_vpc.this.id tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.region_abbr}-igw" resource-type = "internet-gateway" }) } # NAT Gateways =============================================================== # Skipped entirely when enable_nat_gateway is false. # Each NAT gateway requires a static public IP (EIP) and sits in a public subnet. # Supports single NAT (cost-saving) or one NAT per AZ (high availability). resource "aws_eip" "nat" { for_each = local.nat_gateway_keys domain = "vpc" tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-nat-eip-${each.key}" resource-type = "eip" }) depends_on = [aws_internet_gateway.this] } resource "aws_nat_gateway" "this" { for_each = local.nat_gateway_keys allocation_id = aws_eip.nat[each.key].id subnet_id = var.single_nat_gateway ? ( values(aws_subnet.public)[0].id ) : aws_subnet.public[each.key].id tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-nat-${each.key}" resource-type = "nat-gateway" }) depends_on = [aws_internet_gateway.this] } # Route Tables =============================================================== # Public subnets share a single RT with a default route to the IGW. # Private and isolated subnets each get a dedicated RT for per-subnet flexibility. # Default egress for private subnets is NAT when enable_nat_gateway is true. # --- Public ----------------------------------------------------------------- resource "aws_route_table" "public" { for_each = length(var.public_subnets) > 0 ? { public = true } : {} vpc_id = aws_vpc.this.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.this["igw"].id } tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.region_abbr}-rt-public" resource-type = "route-table" reachability = "public" }) } resource "aws_route_table_association" "public" { for_each = var.public_subnets subnet_id = aws_subnet.public[each.key].id route_table_id = aws_route_table.public["public"].id } # --- Private ---------------------------------------------------------------- resource "aws_route_table" "private" { for_each = var.private_subnets vpc_id = aws_vpc.this.id tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-rt-private-${each.key}" resource-type = "route-table" reachability = "private" }) } resource "aws_route" "private_nat" { for_each = var.enable_nat_gateway ? var.private_subnets : {} route_table_id = aws_route_table.private[each.key].id destination_cidr_block = "0.0.0.0/0" nat_gateway_id = var.single_nat_gateway ? ( aws_nat_gateway.this["single"].id ) : ( [ for k, v in aws_nat_gateway.this : v.id if aws_subnet.public[k].availability_zone == aws_subnet.private[each.key].availability_zone ][0] ) } resource "aws_route_table_association" "private" { for_each = var.private_subnets subnet_id = aws_subnet.private[each.key].id route_table_id = aws_route_table.private[each.key].id } # --- Isolated --------------------------------------------------------------- resource "aws_route_table" "isolated" { for_each = var.isolated_subnets vpc_id = aws_vpc.this.id tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.az_name_to_zone_id[each.value.availability_zone]}-rt-isolated-${each.key}" resource-type = "route-table" reachability = "isolated" }) } resource "aws_route_table_association" "isolated" { for_each = var.isolated_subnets subnet_id = aws_subnet.isolated[each.key].id route_table_id = aws_route_table.isolated[each.key].id } # TGW VPC Attachment ========================================================= # Created only when transit_gateway_id is set. # Attachment subnets are derived from tgw_subnet_keys within isolated_subnets. # RT association and propagation are created when transit_gateway_route_table_id is set, # otherwise the TGW default route table is used. resource "aws_ec2_transit_gateway_vpc_attachment" "this" { count = var.transit_gateway_id != null ? 1 : 0 transit_gateway_id = var.transit_gateway_id vpc_id = aws_vpc.this.id subnet_ids = [ for k, v in aws_subnet.isolated : v.id if contains(var.tgw_subnet_keys, k) ] dns_support = "enable" ipv6_support = "disable" transit_gateway_default_route_table_association = var.tgw_default_route_table_association transit_gateway_default_route_table_propagation = var.tgw_default_route_table_propagation tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.region_abbr}-tgw-attach" resource-type = "tgw-attachment" }) } resource "aws_ec2_transit_gateway_route_table_association" "this" { count = var.transit_gateway_id != null && var.transit_gateway_route_table_id != null ? 1 : 0 transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.this[0].id transit_gateway_route_table_id = var.transit_gateway_route_table_id } resource "aws_ec2_transit_gateway_route_table_propagation" "this" { count = var.transit_gateway_id != null && var.transit_gateway_route_table_id != null ? 1 : 0 transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.this[0].id transit_gateway_route_table_id = var.transit_gateway_route_table_id } # TGW Routes ================================================================= # Default routes to TGW for all isolated subnets except attachment subnets. # depends_on ensures the attachment is fully available before routes are created, # avoiding the InvalidTransitGatewayID.NotFound error on fresh TGW deployments. resource "aws_route" "isolated_tgw" { for_each = var.transit_gateway_id != null ? { for k, v in var.isolated_subnets : k => v if !contains(var.tgw_subnet_keys, k) } : {} route_table_id = aws_route_table.isolated[each.key].id destination_cidr_block = "0.0.0.0/0" transit_gateway_id = var.transit_gateway_id depends_on = [aws_ec2_transit_gateway_vpc_attachment.this] } # VPC Flow Logs ============================================================== # Skipped entirely when enable_flow_logs is false. # Supports both CloudWatch and S3 destinations, auto-detected from the ARN. # IAM role and inline policy are created for CloudWatch destinations only. # The S3 bucket and CloudWatch log group are expected to exist before this module runs. resource "aws_iam_role" "flow_log" { for_each = var.enable_flow_logs ? { role = true } : {} name = "${var.name_prefix}-${local.region_abbr}-flow-log-role" permissions_boundary = var.flow_log_role_permissions_boundary_arn assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "sts:AssumeRole" Principal = { Service = "vpc-flow-logs.amazonaws.com" } }] }) tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.region_abbr}-flow-log-role" resource-type = "iam-role" }) lifecycle { precondition { condition = length("${var.name_prefix}-${local.region_abbr}-flow-log-role") <= 64 error_message = "Assembled IAM role name exceeds AWS's 64-character limit" } } } resource "aws_iam_role_policy" "flow_log" { for_each = var.enable_flow_logs ? { role = true } : {} name = "${var.name_prefix}-${local.region_abbr}-flow-log-policy" role = aws_iam_role.flow_log["role"].id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams" ] Resource = "*" }] }) } resource "aws_flow_log" "this" { for_each = var.enable_flow_logs ? { flow_log = true } : {} vpc_id = aws_vpc.this.id traffic_type = var.flow_log_traffic_type log_destination_type = local.flow_log_destination_type log_destination = var.flow_log_destination_arn iam_role_arn = local.flow_log_destination_type == "cloud-watch-logs" ? aws_iam_role.flow_log["role"].arn : null max_aggregation_interval = 60 tags = merge(var.tags, local.module_tags, { Name = "${var.name_prefix}-${local.region_abbr}-flow-log" resource-type = "flow-log" }) }