Connecting Amazon VPCs via VPC peering
A practical example of pinging Amazon VPCs connected via VPC peering, built and deployed using the AWS Cloud Development Kit.
Amazon Virtual Private Cloud
AWS Cloud Development Kit
TypeScript
VPC Peering is a networking connection that you can establish between two VPCs to allow instances on either end to communicate with each other, using their private IPs (both IPv4 and IPv6 are supported), in exactly the same way as if they were inside one VPC. Traffic never leaves the AWS backbone network, thus avoiding the dirty pipes of the Internet. The connection itself is free of charge. However, you have to pay for the data transferred between the VPCs.
Intra-region (between VPCs within the same region), inter-region (between VPCs in different regions) and cross-account (between VPCs belonging to different AWS accounts) peering are all possible. Of course, in either case, the CIDR ranges of the peered VPCs mustn’t overlap with each other, e.g. peering VPC A with the CIDR range of 10.0.0.0/16 and VPC B with the CIDR range of 10.0.1.0/24 would not be possible as IP addresses from 10.0.1.0 to 10.0.1.255 exist in both VPCs.
One more gotcha is that transitive peering is also disallowed. Hence, if you got VPC A peered to VPC B and VPC B peered to VPC C, you wouldn’t be able to reach VPC C from VPC A through VPC B. Instead, you’d need to peer VPC A directly with VPC C. With three VPCs it shouldn’t be such a hard thing to accomplish (and then to maintain), but imagine having hundreds of VPCs… To achieve full mesh topology in that scenario, you’d need a Transit Gateway. But I’m getting a little ahead of myself.
Implementation

We’ll need three stacks: one for the two VPCs, another one for the two EC2 instances and the third one for the actual peering connection and appropriate routes.
Let’s begin by creating our VpcStack
class:
import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2'; // <- this module is not available from the start; remember to import it: `npm install @aws-cdk/aws-ec2`
interface VpcProps extends cdk.StackProps {
vpcSetup: {
cidrs: string[], // <- each VPC will need a list of CIDRs
maxAzs?: number, // <- optionally the number of Availability Zones can be provided; defaults to 2 in our particular case
vpnConnections?: { // <- if dealing with Site-to-Site VPN, the VPN connection details can be provided
[id: string]: ec2.VpnConnectionOptions;
},
};
}
export class VpcStack extends cdk.Stack {
readonly createdVpcs: ec2.Vpc[]; // <- create a class property for exposing the list of VPC objects
constructor(scope: cdk.Construct, id: string, props: VpcProps) {
super(scope, id, props);
const createdVpcs: ec2.Vpc[] = [];
// for each of the provided CIDR ranges, create a VPC with two /27 subnets (one public and one private) per AZ
props.vpcSetup.cidrs.forEach((cidr, index) => {
createdVpcs.push(new ec2.Vpc(this, 'Vpc' + index, {
cidr,
maxAzs: props.vpcSetup.maxAzs,
subnetConfiguration: [
{
cidrMask: 27,
name: 'public',
subnetType: ec2.SubnetType.PUBLIC,
},
{
cidrMask: 27,
name: 'private',
subnetType: ec2.SubnetType.PRIVATE,
},
],
vpnConnections: props.vpcSetup.vpnConnections,
}));
});
// For each VPC's default security group, allow inbound ICMP (ping) requests from anywhere
createdVpcs.forEach((vpc, index) => {
ec2.SecurityGroup.fromSecurityGroupId(this, 'DefaultSecurityGroup' + index, vpc.vpcDefaultSecurityGroup)
.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.icmpPing(), 'Allow ping from anywhere');
});
this.createdVpcs = createdVpcs; // <- expose the list of created VPC objects so that they can be used by different stacks
}
}
Since the @aws-cdk/aws-ec2
module was not imported during the cdk initialization, let’s install it now:
➜ ping-me-cdk-example$ npm install @aws-cdk/aws-ec2@1.74.0
+ @aws-cdk/aws-ec2@1.74.0
added 190 packages from 9 contributors and audited 932 packages in 10.624s
27 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
We chose the 1.74.0
version on purpose (the exact same one we used for our @aws-cdk/core
module) to avoid the possibility of seeing the Argument of type 'this' is not assignable to parameter of type 'Construct'
error.
Next, we’ll initialize an instance of our VpcStack
class:
import * as cdk from '@aws-cdk/core';
import { VpcStack } from '../lib/vpc';
const app = new cdk.App(); // <- you can read more about the App construct here: https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_core.App.html
const vpcPeers = new VpcStack(app, 'VpcPeersStack', {
vpcSetup: {
cidrs: ['10.0.0.0/24', '10.0.1.0/24'], // <- two non-overlapping CIDR ranges for our two VPCs
maxAzs: 1, // <- to keep the costs down, we'll stick to 1 availability zone per VPC (obviously, not something you'd want to do in production)
},
});
TypeScript should be compiled to JavaScript after each modification to our source code. To avoid manually executing the npm run build
command every time that happens, we’ll run the below:
➜ ping-me-cdk-example$ npm run watch
[19:50:46] Starting compilation in watch mode...
[19:50:51] Found 0 errors. Watching for file changes.
# KEEP THIS RUNNING!
We’re ready to synthesize our code into a CloudFormation template. As this is an optional step, we shall do it now for the sake of demonstration, but refrain from doing it later on:
➜ ping-me-cdk-example$ cdk synth
Resources:
Vpc07C831B30:
Type: AWS::EC2::VPC
Properties:
CidrBlock: 10.0.0.0/24
EnableDnsHostnames: true
EnableDnsSupport: true
InstanceTenancy: default
Tags:
- Key: Name
Value: VpcPeersStack/Vpc0
Metadata:
aws:cdk:path: VpcPeersStack/Vpc0/Resource
Vpc0publicSubnet1SubnetB977A71E:
Type: AWS::EC2::Subnet
Properties:
CidrBlock: 10.0.0.0/27
VpcId:
Ref: Vpc07C831B30
AvailabilityZone:
Fn::Select:
- 0
- Fn::GetAZs: ""
MapPublicIpOnLaunch: true
Tags:
- Key: aws-cdk:subnet-name
Value: public
- Key: aws-cdk:subnet-type
Value: Public
- Key: Name
Value: VpcPeersStack/Vpc0/publicSubnet1
Metadata:
aws:cdk:path: VpcPeersStack/Vpc0/publicSubnet1/Subnet
Vpc0publicSubnet1RouteTable2012E33A:
Type: AWS::EC2::RouteTable
Properties:
VpcId:
Ref: Vpc07C831B30
Tags:
- Key: Name
Value: VpcPeersStack/Vpc0/publicSubnet1
Metadata:
aws:cdk:path: VpcPeersStack/Vpc0/publicSubnet1/RouteTable
(... skipping ~400 lines)
Yep, yep, Ladies and gentlemen, without the CDK we would be forced to write all of the above lines ourselves if we wanted to deploy our infrastructure with CloudFormation (that’s one giant leap right there).
Instead of looking at the CloudFormation template, you can run the cdk diff
command to see what changes can be applied:
➜ ping-me-cdk-example$ cdk diff
Stack VpcPeersStack
Security Group Changes
┌───┬──────────────────────────────┬─────┬───────────┬─────────────────┐
│ │ Group │ Dir │ Protocol │ Peer │
├───┼──────────────────────────────┼─────┼───────────┼─────────────────┤
│ + │ ${Vpc0.DefaultSecurityGroup} │ In │ ICMP 8--1 │ Everyone (IPv4) │
├───┼──────────────────────────────┼─────┼───────────┼─────────────────┤
│ + │ ${Vpc1.DefaultSecurityGroup} │ In │ ICMP 8--1 │ Everyone (IPv4) │
└───┴──────────────────────────────┴─────┴───────────┴─────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Conditions
[+] Condition CDKMetadata/Condition CDKMetadataAvailable: {"Fn::Or":[{"Fn::Or":[{"Fn::Equals":[{"Ref":"AWS::Region"},"ap-east-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"ap-northeast-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"ap-northeast-2"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"ap-south-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"ap-southeast-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"ap-southeast-2"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"ca-central-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"cn-north-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"cn-northwest-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"eu-central-1"]}]},{"Fn::Or":[{"Fn::Equals":[{"Ref":"AWS::Region"},"eu-north-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"eu-west-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"eu-west-2"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"eu-west-3"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"me-south-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"sa-east-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"us-east-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"us-east-2"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"us-west-1"]},{"Fn::Equals":[{"Ref":"AWS::Region"},"us-west-2"]}]}]}
Resources
[+] AWS::EC2::VPC Vpc0 Vpc07C831B30
[+] AWS::EC2::Subnet Vpc0/publicSubnet1/Subnet Vpc0publicSubnet1SubnetB977A71E
[+] AWS::EC2::RouteTable Vpc0/publicSubnet1/RouteTable Vpc0publicSubnet1RouteTable2012E33A
[+] AWS::EC2::SubnetRouteTableAssociation Vpc0/publicSubnet1/RouteTableAssociation Vpc0publicSubnet1RouteTableAssociation0E1C3D4B
[+] AWS::EC2::Route Vpc0/publicSubnet1/DefaultRoute Vpc0publicSubnet1DefaultRouteC03283FF
[+] AWS::EC2::EIP Vpc0/publicSubnet1/EIP Vpc0publicSubnet1EIP16FED7DC
[+] AWS::EC2::NatGateway Vpc0/publicSubnet1/NATGateway Vpc0publicSubnet1NATGateway40294DF4
(... skipping ~20 resources)
All looks good. Hence, without further ado, let’s deploy these changes:
➜ ping-me-cdk-example$ cdk deploy --require-approval never
VpcPeersStack: deploying...
VpcPeersStack: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (30/30)
✅ VpcPeersStack
Stack ARN:
arn:aws:cloudformation:eu-west-1:REDACTED:stack/VpcPeersStack/1057bae0-2cc0-11eb-8cd5-0a517997c0b3
We’ve set the --require-approval
flag to never
to avoid manually confirming the creation of the Allow ping from anywhere
rules, which were deemed as potentially insecure by the CDK.
We got the VPCs. Now, on to the EC2s and the peering connection itself:
import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
interface InstanceProps extends cdk.StackProps {
vpcs: ec2.Vpc[]; // <- a list of VPC objects required for the creation of the EC2 instance(s)
}
export class InstanceStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props: InstanceProps) {
super(scope, id, props);
// For each supplied VPC, create a Linux-based EC2 instance in the private subnet and attach the VPC's default security group to it
props.vpcs.forEach((vpc, index) => {
const instanceName = `Instance${index}`;
const instanceResource = new ec2.BastionHostLinux(this, instanceName, {
vpc,
instanceName,
securityGroup: ec2.SecurityGroup.fromSecurityGroupId(this, instanceName + 'SecurityGroup', vpc.vpcDefaultSecurityGroup),
});
// Output the instance's private IP
new cdk.CfnOutput(this, instanceName + 'PrivateIp', {
value: instanceResource.instancePrivateIp,
});
});
}
}
import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
interface PeeringProps extends cdk.StackProps {
vpcs: [ec2.Vpc, ec2.Vpc]; // <- a fixed-length array (a tuple type in TypeScript parlance) consisting of two VPC objects between which the peering connection will be made
}
export class PeeringStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props: PeeringProps) {
super(scope, id, props);
// Create the peering connection
const peer = new ec2.CfnVPCPeeringConnection(this, 'Peer', {
vpcId: props.vpcs[0].vpcId,
peerVpcId: props.vpcs[1].vpcId
});
// Add route from the private subnet of the first VPC to the second VPC over the peering connection
// NB the below was taken from: https://stackoverflow.com/questions/62525195/adding-entry-to-route-table-with-cdk-typescript-when-its-private-subnet-alread
props.vpcs[0].privateSubnets.forEach(({ routeTable: { routeTableId } }, index) => {
new ec2.CfnRoute(this, 'RouteFromPrivateSubnetOfVpc1ToVpc2' + index, {
destinationCidrBlock: props.vpcs[1].vpcCidrBlock,
routeTableId,
vpcPeeringConnectionId: peer.ref,
});
});
// Add route from the private subnet of the second VPC to the first VPC over the peering connection
props.vpcs[1].privateSubnets.forEach(({ routeTable: { routeTableId } }, index) => {
new ec2.CfnRoute(this, 'RouteFromPrivateSubnetOfVpc2ToVpc1' + index, {
destinationCidrBlock: props.vpcs[0].vpcCidrBlock,
routeTableId,
vpcPeeringConnectionId: peer.ref,
});
});
}
}
Back to ping-me-cdk-example/bin/ping-me-cdk-example.ts
to initialize our newly created classes:
import * as cdk from '@aws-cdk/core';
import { VpcStack } from '../lib/vpc';
import { InstanceStack } from '../lib/instance';
import { PeeringStack } from '../lib/peering';
const app = new cdk.App(); // <- you can read more about the App construct here: https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_core.App.html
const vpcPeers = new VpcStack(app, 'VpcPeersStack', {
vpcSetup: {
cidrs: ['10.0.0.0/24', '10.0.1.0/24'], // <- two non-overlapping CIDR ranges for our two VPCs
maxAzs: 1, // <- to keep the costs down, we'll stick to 1 availability zone per VPC (obviously, not something you'd want to do in production)
},
});
// Create two EC2 instances, one in each VPC
new InstanceStack(app, 'InstancePeersStack', {
vpcs: vpcPeers.createdVpcs,
});
// Establish a VPC Peering connection between the two VPCs
new PeeringStack(app, 'PeeringStack', {
vpcs: [vpcPeers.createdVpcs[0], vpcPeers.createdVpcs[1]],
});
Finally, we can deploy the two EC2 instances (one in each of the earlier created VPCs) and the VPC Peering connection itself:
➜ ping-me-cdk-example$ cdk deploy --all --require-approval never
VpcPeersStack
VpcPeersStack: deploying...
✅ VpcPeersStack (no changes)
Outputs:
VpcPeersStack.ExportsOutputFnGetAttVpc07C831B30CidrBlockB8164F9E = 10.0.0.0/24
VpcPeersStack.ExportsOutputFnGetAttVpc07C831B30DefaultSecurityGroup52C351BF = sg-0dd8a9cd265dc8acb
VpcPeersStack.ExportsOutputFnGetAttVpc1C211860BCidrBlock933A5AA8 = 10.0.1.0/24
VpcPeersStack.ExportsOutputFnGetAttVpc1C211860BDefaultSecurityGroup87C47BC2 = sg-0496c16092cdd8311
VpcPeersStack.ExportsOutputRefVpc07C831B304FE08623 = vpc-07277da5218b90290
VpcPeersStack.ExportsOutputRefVpc0privateSubnet1RouteTableB5C6777D52F53FE8 = rtb-005f39777bccd74f4
VpcPeersStack.ExportsOutputRefVpc0privateSubnet1SubnetD6383522ACB05B9B = subnet-0a018df57060948a4
VpcPeersStack.ExportsOutputRefVpc1C211860B64169B74 = vpc-0c5433d68b3f2f67c
VpcPeersStack.ExportsOutputRefVpc1privateSubnet1RouteTable339A93B3DFC75FCA = rtb-02ca74736f4f0ea17
VpcPeersStack.ExportsOutputRefVpc1privateSubnet1Subnet41967AFDFF883DAB = subnet-048b1e861592d392c
Stack ARN:
arn:aws:cloudformation:eu-west-1:REDACTED:stack/VpcPeersStack/d91b71b0-2dbf-11eb-8c69-06b222f0b0a4
InstancePeersStack
InstancePeersStack: deploying...
InstancePeersStack: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (10/10)
✅ InstancePeersStack
Outputs:
InstancePeersStack.Instance0BastionHostId1959CA92 = i-0ca24549d1646cccd # <- COPY THE ID OF YOUR SOURCE EC2 INSTANCE!
InstancePeersStack.Instance0PrivateIp = 10.0.0.36
InstancePeersStack.Instance1BastionHostIdEF2AA144 = i-0fec2bdd51392974d
InstancePeersStack.Instance1PrivateIp = 10.0.1.59 # <- COPY THE PRIVATE IP OF YOUR DESTINATION EC2 INSTANCE!
Stack ARN:
arn:aws:cloudformation:eu-west-1:REDACTED:stack/InstancePeersStack/9e40f500-2dc0-11eb-aab0-0a253e5a178e
PeeringStack
PeeringStack: deploying...
PeeringStack: creating CloudFormation changeset...
[██████████████████████████████████████████████████████████] (5/5)
✅ PeeringStack
Stack ARN:
arn:aws:cloudformation:eu-west-1:REDACTED:stack/PeeringStack/15e96f10-2dc1-11eb-ae91-0643678755c5
Validation
To test if the VPC Peering has been properly set up, we’re gonna send 3 pings from one of the EC2 instances to the other using the AWS CLI and its aws ssm send-command
command.
If you’re following along, be sure to swap the ID of the source EC2 instance (i-0ca24549d1646cccd
) and the private IP of the destination EC2 instance (10.0.1.59
) for appropriate values before running the below:
➜ ping-me-cdk-example$ aws ssm send-command \
--document-name "AWS-RunShellScript" \
--document-version "1" \
--targets '[{"Key":"InstanceIds","Values":["i-0ca24549d1646cccd"]}]' \
--parameters '{"workingDirectory":[""],"executionTimeout":["3600"],"commands":["ping 10.0.1.59 -c 3"]}' \
--timeout-seconds 600 \
--max-concurrency "50" \
--max-errors "0"
{
"Command": {
"CommandId": "e2171883-d9d1-478c-9ad2-2c7c51ca6c2e",
"DocumentName": "AWS-RunShellScript",
"DocumentVersion": "1",
"Comment": "",
"ExpiresAfter": "2020-11-23T22:04:17.410000+01:00",
"Parameters": {
"commands": [
"ping 10.0.1.59 -c 3"
],
"executionTimeout": [
"3600"
],
"workingDirectory": [
""
]
},
"InstanceIds": [],
"Targets": [
{
"Key": "InstanceIds",
"Values": [
"i-0ca24549d1646cccd"
]
}
],
"RequestedDateTime": "2020-11-23T20:54:17.410000+01:00",
"Status": "Pending",
"StatusDetails": "Pending",
"OutputS3BucketName": "",
"OutputS3KeyPrefix": "",
"MaxConcurrency": "50",
"MaxErrors": "0",
"TargetCount": 0,
"CompletedCount": 0,
"ErrorCount": 0,
"DeliveryTimedOutCount": 0,
"ServiceRole": "",
"NotificationConfig": {
"NotificationArn": "",
"NotificationEvents": [],
"NotificationType": ""
},
"CloudWatchOutputConfig": {
"CloudWatchLogGroupName": "",
"CloudWatchOutputEnabled": false
},
"TimeoutSeconds": 600
}
}
Now, let’s check whether that succeeded using AWS CLI’s aws ssm get-command-invocation
command.
Again, if you’re following along, be sure to swap the command ID (e2171883-d9d1-478c-9ad2-2c7c51ca6c2e
) and the ID of the source EC2 instance (i-0ca24549d1646cccd
) for appropriate values before running the below:
➜ ping-me-cdk-example$ aws ssm get-command-invocation --command-id e2171883-d9d1-478c-9ad2-2c7c51ca6c2e --instance-id i-0ca24549d1646cccd
{
"CommandId": "e2171883-d9d1-478c-9ad2-2c7c51ca6c2e",
"InstanceId": "i-0ca24549d1646cccd",
"Comment": "",
"DocumentName": "AWS-RunShellScript",
"DocumentVersion": "1",
"PluginName": "aws:runShellScript",
"ResponseCode": 0,
"ExecutionStartDateTime": "2020-11-23T19:54:17.876Z",
"ExecutionElapsedTime": "PT2.032S",
"ExecutionEndDateTime": "2020-11-23T19:54:19.876Z",
"Status": "Success",
"StatusDetails": "Success",
"StandardOutputContent": "PING 10.0.1.59 (10.0.1.59) 56(84) bytes of data.\n64 bytes from 10.0.1.59: icmp_seq=1 ttl=255 time=0.140 ms\n64 bytes from 10.0.1.59: icmp_seq=2 ttl=255 time=0.152 ms\n64 bytes from 10.0.1.59: icmp_seq=3 ttl=255 time=0.138 ms\n\n--- 10.0.1.59 ping statistics ---\n3 packets transmitted, 3 received, 0% packet loss, time 2025ms\nrtt min/avg/max/mdev = 0.138/0.143/0.152/0.011 ms\n",
"StandardOutputUrl": "",
"StandardErrorContent": "",
"StandardErrorUrl": "",
"CloudWatchOutputConfig": {
"CloudWatchLogGroupName": "",
"CloudWatchOutputEnabled": false
}
}
3 packets transmitted, 3 received, 0% packet loss
, woop woop!
Cleanup
For the sake of our wallets, let’s promptly destroy the current infrastructure before moving on. When prompted, type y
for yes:
➜ ping-me-cdk-example$ cdk destroy --all
Are you sure you want to delete: PeeringStack, InstancePeersStack, VpcPeersStack (y/n)? y
PeeringStack: destroying...
21:15:12 | DELETE_IN_PROGRESS | AWS::CloudFormation::Stack | PeeringStack
✅ PeeringStack: destroyed
InstancePeersStack: destroying...
21:16:03 | DELETE_IN_PROGRESS | AWS::CloudFormation::Stack | InstancePeersStack
✅ InstancePeersStack: destroyed
VpcPeersStack: destroying...
21:17:01 | DELETE_IN_PROGRESS | AWS::CloudFormation::Stack | VpcPeersStack
21:18:55 | DELETE_IN_PROGRESS | AWS::EC2::InternetGateway | Vpc1/IGW
21:18:55 | DELETE_IN_PROGRESS | AWS::EC2::VPC | Vpc1
✅ VpcPeersStack: destroyed
Alright, it’s time to step it up and connect Amazon VPS over Site-to-Site VPN, reusing some of the code we’ve already written here.
Please remember that all the code is available on GitHub.