Amazon VPC peering enables a network connection between two private VPCs so traffic can be routed from one VPC to the other using private IP addresses. You can peer your VPC with another VPC in the same region, in a different region, or in a different AWS account.
AWS creates the peering connection using the existing infrastructure of the VPC. A VPC peering connection is not a gateway or a VPN connection, and there is no single point of failure or bandwidth bottleneck. It makes it easy to transfer data from VPC to VPC.
This guide covers the simplest case: both VPCs are in the same AWS account and the same region, so Terraform can create and accept the request in one step. It assumes the VPCs already exist. If you need help creating one, check out this guide: Terraform AWS VPC with Public and Private subnets with NAT.
We will:
- Look up both VPCs by their
Nametag. - Create the peering connection and accept it automatically.
- Enable DNS resolution across the peering.
- Add a route to the other VPC’s CIDR in every route table on both sides.
- Allow ICMP in the security groups and test with
ping.
Prerequisites
- Terraform 1.6 or later, or OpenTofu. Every command below works with
tofuin place ofterraform. - AWS credentials for the account that owns both VPCs, with permission to manage VPC peering, routes and security groups.
- Two VPCs in the same region with non-overlapping CIDR blocks, for example
10.10.0.0/16and10.20.0.0/16. AWS refuses to peer VPCs whose CIDRs overlap. - For the DNS part:
enableDnsSupportandenableDnsHostnamesturned on in both VPCs (the defaults for VPCs created by the console wizard and most Terraform modules).
Note that peering is not transitive: if VPC A is peered with B and B with C, A still cannot reach C. Each pair needs its own peering connection, or use a Transit Gateway once you have more than a handful of VPCs.
Provider and variables
Pin the AWS provider. This guide uses the current major version, 6.x. Save this as versions.tf:
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.region
}
Let us define the variables we will use. Save the following as vars.tf:
variable "region" {
type = string
description = "AWS region both VPCs live in"
default = "eu-west-1"
}
variable "name" {
type = string
description = "Name of the peering connection"
}
variable "requester_vpc_name" {
type = string
description = "Name tag of the requester VPC"
}
variable "accepter_vpc_name" {
type = string
description = "Name tag of the accepter VPC"
}
And give them values in terraform.tfvars:
name = "dev-to-shared"
requester_vpc_name = "dev-vpc"
accepter_vpc_name = "shared-vpc"
Look up the VPCs
Next, using the given VPC names, query both VPCs. Start main.tf with:
data "aws_vpc" "requester" {
filter {
name = "tag:Name"
values = [var.requester_vpc_name]
}
}
data "aws_vpc" "accepter" {
filter {
name = "tag:Name"
values = [var.accepter_vpc_name]
}
}
The data sources give us each VPC’s id and cidr_block, so there is no need to pass the VPC IDs in as separate variables.
Create the peering connection
Add the peering connection to main.tf:
resource "aws_vpc_peering_connection" "this" {
vpc_id = data.aws_vpc.requester.id
peer_vpc_id = data.aws_vpc.accepter.id
auto_accept = true
requester {
allow_remote_vpc_dns_resolution = true
}
accepter {
allow_remote_vpc_dns_resolution = true
}
tags = {
Name = var.name
}
}
auto_accept = trueonly works because both VPCs are in the same account and region. For cross-account or cross-region peering, you create the request here and accept it with anaws_vpc_peering_connection_accepterresource using a provider for the other account or region.allow_remote_vpc_dns_resolutionlets instances resolve the public DNS hostnames of instances in the other VPC to their private IPs. Without it,ec2-203-0-113-10.eu-west-1.compute.amazonaws.comresolves to the public IP and the traffic does not go over the peering.
Update the route tables on both sides
Now that the peering connection is created, we have to update the route table entries on both sides to send traffic via the peering connection. A single VPC can have multiple route tables (typically one public and one per private subnet tier or AZ), and I wanted the code to work irrespective of how many route tables each VPC has. So we look up all the route tables and loop over them with for_each, creating a route to the other VPC’s CIDR block via the peering connection.
data "aws_route_tables" "requester" {
vpc_id = data.aws_vpc.requester.id
}
data "aws_route_tables" "accepter" {
vpc_id = data.aws_vpc.accepter.id
}
# Requester route tables -> accepter CIDR
resource "aws_route" "requester_to_accepter" {
for_each = toset(data.aws_route_tables.requester.ids)
route_table_id = each.value
destination_cidr_block = data.aws_vpc.accepter.cidr_block
vpc_peering_connection_id = aws_vpc_peering_connection.this.id
}
# Accepter route tables -> requester CIDR
resource "aws_route" "accepter_to_requester" {
for_each = toset(data.aws_route_tables.accepter.ids)
route_table_id = each.value
destination_cidr_block = data.aws_vpc.requester.cidr_block
vpc_peering_connection_id = aws_vpc_peering_connection.this.id
}
for_each keys each route by its route table ID, so adding or removing a route table later only touches that one route. With count, removing a table from the middle of the list would shift the indexes and make Terraform recreate unrelated routes.
The routes send the whole peer VPC CIDR. It is also possible to route only specific subnets over the peering connection by using subnet CIDRs as destination_cidr_block.
After the apply, every route table on the requester side has an entry like this (and the mirror image on the accepter side):
| Destination | Target |
|---|---|
10.10.0.0/16 | local |
10.20.0.0/16 | pcx-0123456789abcdef0 |
Allow traffic in the security groups
Routes only make the path exist. Security groups still decide what is allowed in. This example allows ICMP (ping) from each VPC into the other VPC’s default security group. Save it as security.tf, and add rules for the ports your workloads actually use (5432 for PostgreSQL, 443 for an internal API, and so on).
data "aws_security_group" "requester_default" {
vpc_id = data.aws_vpc.requester.id
name = "default"
}
data "aws_security_group" "accepter_default" {
vpc_id = data.aws_vpc.accepter.id
name = "default"
}
# Allow ping from the requester VPC into the accepter VPC
resource "aws_vpc_security_group_ingress_rule" "accepter_icmp_from_requester" {
security_group_id = data.aws_security_group.accepter_default.id
cidr_ipv4 = data.aws_vpc.requester.cidr_block
ip_protocol = "icmp"
from_port = -1
to_port = -1
description = "ICMP from peered VPC ${var.requester_vpc_name}"
}
# Allow ping from the accepter VPC into the requester VPC
resource "aws_vpc_security_group_ingress_rule" "requester_icmp_from_accepter" {
security_group_id = data.aws_security_group.requester_default.id
cidr_ipv4 = data.aws_vpc.accepter.cidr_block
ip_protocol = "icmp"
from_port = -1
to_port = -1
description = "ICMP from peered VPC ${var.accepter_vpc_name}"
}
aws_vpc_security_group_ingress_rule is the current way to manage individual rules. Avoid mixing it with inline ingress blocks or the older aws_security_group_rule on the same security group: they overwrite each other.
In the same region you can also reference the peer VPC’s security group ID instead of a CIDR (referenced_security_group_id), which is tighter than allowing a whole CIDR block.
Outputs
Save this as outputs.tf:
output "peering_connection_id" {
value = aws_vpc_peering_connection.this.id
}
output "peering_status" {
value = aws_vpc_peering_connection.this.accept_status
}
Plan and apply
terraform init
terraform fmt
terraform validate
terraform plan -out=peering.plan
terraform apply peering.plan
validate should print:
Success! The configuration is valid.
After the apply, peering_status should be active. Delete peering.plan once applied: saved plans can contain sensitive values and do not belong in Git.
Verify the peering
Check the connection status from the AWS CLI:
aws ec2 describe-vpc-peering-connections \
--filters Name=tag:Name,Values=dev-to-shared \
--query 'VpcPeeringConnections[].{Id:VpcPeeringConnectionId,Status:Status.Code}' \
--output table
The status code should be active. pending-acceptance means the accept step did not happen; failed usually means overlapping CIDRs.
Check the routes landed in a requester route table:
aws ec2 describe-route-tables \
--filters Name=vpc-id,Values=<requester-vpc-id> \
--query 'RouteTables[].Routes[?VpcPeeringConnectionId!=`null`].[DestinationCidrBlock,VpcPeeringConnectionId,State]' \
--output text
Then test from an instance. From an instance in the requester VPC, ping the private IP of an instance in the accepter VPC (both using the default security group from above):
ping -c 3 10.20.1.15
You should get three replies. With DNS resolution enabled, the other instance’s public DNS hostname resolves to its private IP from inside the peered VPC, so this should print 10.20.1.15 rather than the public address:
dig +short ec2-203-0-113-25.eu-west-1.compute.amazonaws.com
Troubleshooting
- Ping times out: check, in order, the route tables on both sides (the reply needs a route back), the security group on the target, and any network ACLs on the subnets. ICMP is a common one to forget in NACLs.
- Peering stuck in
pending-acceptance:auto_acceptonly works within one account and region. Useaws_vpc_peering_connection_accepterotherwise. Error: ... no matching EC2 VPC found: theNametag in your variables does not match the VPC, or the provider is pointed at the wrong region.- Routes keep being recreated: you are probably mixing these
aws_routeresources with inlinerouteblocks in anaws_route_tableresource elsewhere. Pick one style per route table.
Related content
- Terraform AWS VPC with Public and Private subnets with NAT
- How to use terraform to Launch an AWS EC2 Instance
- How to use Terraform AWS EC2 user_data – aws_instance
- Terraform -target: Plan, Apply or Destroy Specific Resources
Conclusion
In this guide, we learnt how to create a VPC peering connection between two VPCs in the same AWS account and region, with routes in every route table on both sides, DNS resolution across the peering, security group rules, and a ping test to prove it works.