In our team we use benefits of using multiple AWS accounts hence we have to deal with cross-account operations such as RDS instances visibility, events subscriptions etc.

One of this area is also networking - how to make resources visible to each other even if we have deployments in different AWS accounts? The answer is VPC peering connection.

You can use it between two different accounts and regions as one-to-one connection thus you need to create separate peering connection for every VPC network in pair.

CDK setup

As we want to have all setup automated and we use Typescript in all our backend and frontend apps, AWS CDK is the obvious choice.

Before we start with an example, we suppose that you know it and have it installed locally and bootstraped every AWS account/region we will use.

Accepter stack

This is the primary network we need to connect.

VPC

First of all we define VPC network. Subnet could be 172.51.0.0/16. We have max 3 availability zones, so we have enough backup for unexpected outages. As subnet type we choose isolated, because our applications don't need to connect to the internet and turn on DNS support with enableDnsHostnames and enableDnsSupport.

import * as ec2 from 'aws-cdk-lib/aws-ec2'

const vpc = new ec2.Vpc(this, 'AccepterVpcStack', {
    ipAddresses: ec2.IpAddresses.cidr('172.50.0.0/16'),
    maxAzs: 3,
    subnetConfiguration: [
        {
            name: 'isolated',
            subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
            cidrMask: 20
        }
    ],
    enableDnsHostnames: true,
    enableDnsSupport: true
})
TIP - Alternatively there are two other subnet types - private (if you need to connect to the internet) and public (if you need connect to the internet and have assigned some public IP address, eg. through NAT gateway).

Peering role

As the next step wee need to allow connections from requester account. This can be achieved with IAM Role.

import * as iam from 'aws-cdk-lib/aws-iam'

const peeringRole = new iam.Role(this, "AcceptVpcPeeringFromRequesterAccountRole", {
    roleName: "AcceptVpcPeeringFromSecondaryAccountRole",
    assumedBy: new iam.AccountPrincipal("<requester_account_id>")
})
peeringRole.addToPolicy(new iam.PolicyStatement({
    actions: [
        "ec2:AcceptVpcPeeringConnection"
    ],
    resources: ["*"]
}))

Requester stack

This is the secondary network we need to connect from.

VPC

Just like in the previous stack we need to define VPC network first, but with the difference that CIDR IP range should be unique for every VPC, so we choose 172.51.0.0/16.

import * as ec2 from 'aws-cdk-lib/aws-ec2'

const vpc = new ec2.Vpc(this, 'RequesterVpc', {
    ipAddresses: ec2.IpAddresses.cidr('172.51.0.0/16'),
    maxAzs: 3,
    subnetConfiguration: [
        {
            name: 'isolated',
            subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
            cidrMask: 20
        }
    ],
    enableDnsHostnames: true,
    enableDnsSupport: true
})

Peering connection

Then we define VPC peering itself.

const peeringConnection = new ec2.CfnVPCPeeringConnection(
    this,
    'RequesterToAccepterPeering',
    {
        vpcId: vpc.vpcId,
        peerVpcId: ..., // Accepter VPC ID
        peerOwnerId: ..., // Accepter AWS Account ID,
        peerRegion: ..., // Accepter Region,
        peerRoleArn: ..., // Accepter Role Arn,
        tags: [
            {
                key: 'Name',
                value: 'requester->accepter'
            }
        ]
    }
)

This will also create incoming peering connection on accepter account side.

Routing

But it's not all. We still need to tell our network how to route requests to accepter network. This should be done for all our subnets.

vpc.privateSubnets.forEach(({ routeTable: { routeTableId } }, index) => {
    const route = new ec2.CfnRoute(
        this,
        'IsolatedSubnetPeeringConnectionRoute' + index,
        {
            destinationCidrBlock: '172.50.0.0/16',
            routeTableId,
            vpcPeeringConnectionId: peeringConnection.ref
        }
    )
    route.addDependency(peeringConnection)
})

DNS

As the last step we have to set up DNS resolution. This can be a little bit tricky, because this option is disabled by default on accepter peering connection side and there is no option to enabled it with CDK or even with CloudFormation.

So we have to enable it manually with AWS console:

or with the AWS CLI on the accepter peering connection side.

That's all!

Now we have two VPC network's in two different AWS accounts and we are able to connect to RDS instanced located in accepter account network from lambdas located in requester account network for example and almost everything is automated with CDK.

Bonus idea

I also find some ideas how to solve manual step with allowing DNS options but it was base on Custom Resources in CDK. People try to solve this with AWS SDK Call inside custom resource Lambda, but the problem is that cross-account calls are buggy and I was not managed to make it run. The implementation with Constructs could look like this:

import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as logs from 'aws-cdk-lib/aws-logs'
import { AwsCustomResource, AwsCustomResourcePolicy, AwsSdkCall, PhysicalResourceId } from 'aws-cdk-lib/custom-resources'
import { Construct } from 'constructs'

export interface AllowVPCPeeringDNSResolutionProps {
  vpcPeering: ec2.CfnVPCPeeringConnection,
	accepterRoleArn: string
}

export class AllowVPCPeeringDNSResolution extends Construct {
  constructor(scope: Construct, id: string, props: AllowVPCPeeringDNSResolutionProps) {
    super(scope, id)

		const { vpcPeering, accepterRoleArn } = props

		const assumedRoleArn = accepterRoleArn

    const onCreate: AwsSdkCall = {
        service: "EC2",
        action: "modifyVpcPeeringConnectionOptions",
		assumedRoleArn,
        parameters: {
            VpcPeeringConnectionId: vpcPeering.ref,
            AccepterPeeringConnectionOptions: {
                AllowDnsResolutionFromRemoteVpc: true,
            },
            RequesterPeeringConnectionOptions: {
                AllowDnsResolutionFromRemoteVpc: true
            }
        },
        physicalResourceId: PhysicalResourceId.of(`allowVPCPeeringDNSResolution:${vpcPeering.ref}`)
    }
    const onUpdate = onCreate
    const onDelete: AwsSdkCall = {
        service: "EC2",
        action: "modifyVpcPeeringConnectionOptions",
		assumedRoleArn,
        parameters: {
            VpcPeeringConnectionId: vpcPeering.ref,
            AccepterPeeringConnectionOptions: {
                AllowDnsResolutionFromRemoteVpc: false,
            },
            RequesterPeeringConnectionOptions: {
                AllowDnsResolutionFromRemoteVpc: false
            }
        },
    }

    const customResource = new AwsCustomResource(this, "allow-peering-dns-resolution-t", {
        policy: AwsCustomResourcePolicy.fromStatements([
            new iam.PolicyStatement({
                effect: iam.Effect.ALLOW,
                resources: ["*"],
                actions: [
                    "ec2:ModifyVpcPeeringConnectionOptions",
                ]
            }),
        ]),
        logRetention: logs.RetentionDays.ONE_DAY,
        onCreate,
        onUpdate,
        onDelete,
    })

    customResource.node.addDependency(vpcPeering)
  }
}

and usage like this:

// import { AllowVPCPeeringDNSResolution } from './constructs/AllowVPCPeeringDNSResolution'

new AllowVPCPeeringDNSResolution(this, "PeerConnectionDnsResolution", {
    vpcPeering: peeringConnection,
    accepterRoleArn
})

But we all hope, that AWS make it more use-friendly as soon as possible and we won't need these kind of hacks :-).