VPC Peering: Connecting Application and Data VPCs Privately
Scenario
Your organisation uses two VPCs: an application VPC (10.0.0.0/16) running ECS workloads, and a data VPC (10.1.0.0/16) running RDS databases. Currently, all traffic between them transits the internet — a major security risk. You need to connect both VPCs privately using VPC Peering so that the ECS tasks can talk to RDS over private IPs with no public internet exposure.
- Create two VPCs with non-overlapping CIDRs and enable_dns_hostnames = true on both (required for private DNS resolution across the peering).
- Create a aws_vpc_peering_connection from the application VPC to the data VPC and an aws_vpc_peering_connection_accepter to accept it (in the same account, this is auto-accepted with auto_accept = true).
- Add routes in both VPCs' route tables: app VPC private route table → data VPC CIDR via peering; data VPC route table → app VPC CIDR via peering.
- Enable allow_remote_vpc_dns_resolution = true on the peering options.
- Create a security group in the data VPC allowing MySQL (3306) inbound only from the app VPC CIDR.
- Output the peering connection ID and both VPC IDs.
Additional Context
VPC peering is not transitive. If VPC A peers with VPC B and VPC B peers with VPC C, traffic from VPC A cannot reach VPC C through VPC B. Each pair needs its own peering connection. For hub-and-spoke with transitive routing, use AWS Transit Gateway instead.
DNS resolution across VPC peering requires both enableDnsHostnames and enableDnsSupport to be true on both VPCs, AND DNS resolution must be enabled on the peering connection options. Without this, your instances can reach each other by IP but not by hostname — which breaks most connection strings that use the RDS endpoint DNS name.
variables.tf
variable "aws_region" { type = string; default = "us-east-1" }
variable "environment" { type = string; default = "prod" }main.tf
provider "aws" { region = var.aws_region }
locals { tags = { Environment = var.environment, ManagedBy = "terraform" } }
# Application VPC — runs ECS workloads.
resource "aws_vpc" "app" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true # required for cross-VPC DNS resolution
tags = merge(local.tags, { Name = "app-vpc" })
}
# Data VPC — runs RDS databases. CIDRs must NOT overlap with app VPC.
resource "aws_vpc" "data" {
cidr_block = "10.1.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(local.tags, { Name = "data-vpc" })
}
# Subnets in the application VPC.
resource "aws_subnet" "app_private" {
vpc_id = aws_vpc.app.id
cidr_block = "10.0.10.0/24"
availability_zone = "${var.aws_region}a"
tags = merge(local.tags, { Name = "app-private-subnet" })
}
# Subnets in the data VPC.
resource "aws_subnet" "data_private" {
vpc_id = aws_vpc.data.id
cidr_block = "10.1.10.0/24"
availability_zone = "${var.aws_region}a"
tags = merge(local.tags, { Name = "data-private-subnet" })
}
# VPC Peering Connection — initiated from app VPC.
resource "aws_vpc_peering_connection" "app_to_data" {
vpc_id = aws_vpc.app.id # requester
peer_vpc_id = aws_vpc.data.id # accepter
auto_accept = true # same account same region — auto-accept is valid
# Enable DNS resolution through the peering so RDS hostnames resolve to private IPs.
accepter { allow_remote_vpc_dns_resolution = true }
requester { allow_remote_vpc_dns_resolution = true }
tags = merge(local.tags, { Name = "app-to-data-peering" })
}
# Route table for the app VPC private subnets.
resource "aws_route_table" "app_private" {
vpc_id = aws_vpc.app.id
# Route traffic destined for the data VPC through the peering connection.
route {
cidr_block = aws_vpc.data.cidr_block
vpc_peering_connection_id = aws_vpc_peering_connection.app_to_data.id
}
tags = merge(local.tags, { Name = "app-private-rt" })
}
resource "aws_route_table_association" "app" {
subnet_id = aws_subnet.app_private.id
route_table_id = aws_route_table.app_private.id
}
# Route table for the data VPC — routes back to the app VPC.
resource "aws_route_table" "data_private" {
vpc_id = aws_vpc.data.id
# Route traffic destined for the app VPC back through the peering.
route {
cidr_block = aws_vpc.app.cidr_block
vpc_peering_connection_id = aws_vpc_peering_connection.app_to_data.id
}
tags = merge(local.tags, { Name = "data-private-rt" })
}
resource "aws_route_table_association" "data" {
subnet_id = aws_subnet.data_private.id
route_table_id = aws_route_table.data_private.id
}
# Security group in the data VPC — allows MySQL only from app VPC CIDR.
resource "aws_security_group" "rds" {
name = "rds-sg-${var.environment}"
vpc_id = aws_vpc.data.id
ingress {
description = "MySQL from App VPC only"
from_port = 3306
to_port = 3306
protocol = "tcp"
# Use the app VPC CIDR — not 0.0.0.0/0. Principle of least privilege.
cidr_blocks = [aws_vpc.app.cidr_block]
}
egress { from_port = 0; to_port = 0; protocol = "-1"; cidr_blocks = ["0.0.0.0/0"] }
tags = merge(local.tags, { Name = "rds-sg" })
}outputs.tf
output "peering_connection_id" { value = aws_vpc_peering_connection.app_to_data.id }
output "app_vpc_id" { value = aws_vpc.app.id }
output "data_vpc_id" { value = aws_vpc.data.id }
output "app_private_subnet_id" { value = aws_subnet.app_private.id }
output "data_private_subnet_id"{ value = aws_subnet.data_private.id }Workflow Commands
terraform init && terraform apply
# Verify peering status
aws ec2 describe-vpc-peering-connections \
--filters "Name=status-code,Values=active" \
--query 'VpcPeeringConnections[*].{ID:VpcPeeringConnectionId,Status:Status.Code}'
# Verify routes exist in both route tables
aws ec2 describe-route-tables \
--route-table-ids $(terraform output -raw && terraform state show aws_route_table.app_private | grep id)
# Test connectivity: launch an EC2 in each VPC and ping across the peering
# (from app subnet instance): nc -zv 3306
terraform destroy Apply complete! Resources: 9 added, 0 changed, 0 destroyed. Outputs: app_vpc_id = "vpc-0aaa..." data_vpc_id = "vpc-0bbb..." peering_connection_id = "pcx-0ccc..." app_private_subnet_id = "subnet-0ddd..." data_private_subnet_id = "subnet-0eee..."

