This article’s code is available on GitHub: mattvanstone/aws-cdk-cross-account-route53
When you assign an alternate domain name to your CloudFront distribution you can use new route53.ARecord to add a record to a hosted zone. But some organizations opt to centalize their Route 53 management in a central AWS account. Unfortunately, if you want to manage your Route 53 records in CDK there are no high-level CDK constructs that can do that across accounts.
In this example we will create a Route 53 hosted zone and STS role in account A. In account B we will use CDK to create a CloudFront distribution with a custom alternate DNS name. Then using a CDK custom resource we will assume the role in account B to add the domain name to the Route 53 hosted zone.
Prerequisites
The CDK project in this example assumes we already have a hosted zone, role to assume, and ACM certificate.
Account A: Route 53
Account A is a central location for your hosted zones. These resources need to be created before the hosted zone can be managed from account B.
Hosted zone
In account A copy the hosted zone ID for your custom domain. If you don’t already have a hosted zone, create one a new one.
Route 53 Role
Now create a role that you can assume from account B that grants it permissions to manage the hosted zone.
Attach an assume role policy that will allow account B to use it. Replace 999999999999 with the account number from account B.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::999999999999:root",
]
},
"Action": "sts:AssumeRole"
}
]
}Attach the policy below to the role. It will allow the role to manage the hosted zone you created. Replace Z00000000000000000000 with your hosted zone ID.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:GetChange"
],
"Resource": "arn:aws:route53:::change/*"
},
{
"Effect": "Allow",
"Action": [
"route53:GetHostedZone",
"route53:ChangeResourceRecordSets",
"route53:ListResourceRecordSets",
"route53:ListTagsForResource"
],
"Resource": [
"arn:aws:route53:::hostedzone/Z00000000000000000000"
]
}
]
}Account B: CloudFront
Account B is where our CDK stack will be deployed.
Certificate
Before deploying the CDK stack you need to create and validate a certificate for your domain.
The CDK
The CDK stack has three main components:
- An S3 bucket to host the content
- A CloudFront distribution with a custom alternate domain name
- A CDK custom resource to assume the role in account B and execute Route 53 API calls to create, update, and delete records.
The AwsCdkCrossAccountRoute53Stack Stack
// lib/aws-cdk-cross-account-route53-stack.ts
import {
Stack, StackProps, RemovalPolicy, Duration,
} from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
import { Distribution, CachePolicy } from 'aws-cdk-lib/aws-cloudfront';
import { S3Origin } from 'aws-cdk-lib/aws-cloudfront-origins';
import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
import { Construct } from 'constructs';
import { Route53CrossAccountRecord } from './modules/route53';
export class AwsCdkCrossAccountRoute53Stack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// The hosted zone ID of mydomain.com
const HostedZoneId = 'Z04695271O0DO89QHTL9X';
// The name of the record to create ➡️ mysubdomain.mydomain.com
const customDomainName = 'mysubdomain.nutrienddc.io';
// The ARN of a certificate for mydomain.com
const certArn = 'arn:aws:acm:us-east-1:840375332198:certificate/3b387cd3-b001-4fe2-94a6-412169afd160';
// "arn:aws:acm:us-east-1:111111111111:certificate/00000000-0000-0000-0000-000000000000";
// A role in the AWS account where Route 53 is hosting the target hosted zone
const assumeRoleArn = 'arn:aws:iam::068149891515:role/route53-hostedzone-sts-ddc-role';
// "arn:aws:iam::111111111111:role/manage-route53-hostedzone";
// A bucket to use as a CloudFront origin
const myBucket = new Bucket(this, 'OriginBucket', {
// websiteIndexDocument: "index.html",
removalPolicy: RemovalPolicy.DESTROY,
autoDeleteObjects: true,
});
// Copies the html files into the bucket
new BucketDeployment(this, 'StaticWebsiteDeployment', {
sources: [Source.asset('./website')],
destinationBucket: myBucket,
});
// A CloudFront distribution to distribute the S3 content
const distro = new Distribution(this, 'Distribution', {
defaultBehavior: {
origin: new S3Origin(myBucket, {
originPath: '/',
}),
cachePolicy: CachePolicy.CACHING_DISABLED,
},
domainNames: [customDomainName],
certificate: Certificate.fromCertificateArn(this, 'Certificate', certArn),
errorResponses: [{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: '/index.html',
ttl: Duration.seconds(10),
}],
});
/**
* A custom resource to create the custom domain name on a hosted
* zone in another AWS account
*/
new Route53CrossAccountRecord(this, 'CloudfrontAlias', {
RecordName: customDomainName,
TargetDnsName: distro.domainName,
HostedZoneId,
AssumeRoleArn: assumeRoleArn,
});
}
}Route53CrossAccountRecord Custom Resource Class
The key to managing any resource in
To achieve this we will use the aws-cdk-lib/custom_resources module. A custom resource creates a Lambda function that can make any AWS api call. When the CloudFormation stack is created or changed it will send an onCreate, onUpdate, or onDelete event to the handler. The function will then execute different API calls for each event.
This class creates a custom resource that will create, update, and delete an A record. It takes the hosted zone ID, role arn, DNS name, and hostname as parameters.
// lib/modules/route53.ts
import { Construct } from '@aws-cdk/core';
import { CloudFrontTarget } from '@aws-cdk/aws-route53-targets'
import {
AwsCustomResource,
AwsCustomResourcePolicy,
PhysicalResourceId,
} from '@aws-cdk/custom-resources';
interface Route53CrossAccountRecordProps {
/** The Route53 domain name to create */
Name: string,
/** CloudFront URL to create an alias to */
DNSName: string,
/** The arn of the role that has access to update Route53 */
AssumeRoleArn: string,
/** The Route53 hosted zone ID to create the record in */
HostedZoneId: string,
}
class Route53CrossAccountRecord extends Construct {
constructor(scope: Construct, id: string,
props: Route53CrossAccountRecordProps) {
super(scope, id);
const ResourceRecordSet = {
Name: props.Name,
Type: 'A',
AliasTarget: {
DNSName: props.DNSName,
EvaluateTargetHealth: false,
HostedZoneId: CloudFrontTarget.CLOUDFRONT_ZONE_ID,
},
};
AwsCustomResource(this, 'dnsRecord', {
policy: AwsCustomResourcePolicy.fromSdkCalls(
{ resources: AwsCustomResourcePolicy.ANY_RESOURCE }),
onCreate: {
assumedRoleArn: props.AssumeRoleArn,
service: 'Route53',
action: 'changeResourceRecordSets',
parameters: {
HostedZoneId: props.HostedZoneId,
ChangeBatch: {
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet,
},
],
},
},
physicalResourceId: PhysicalResourceId.of(
ResourceRecordSet.Name
),
},
onUpdate: {
assumedRoleArn: props.AssumeRoleArn,
service: 'Route53',
action: 'changeResourceRecordSets',
parameters: {
HostedZoneId: props.HostedZoneId,
ChangeBatch: {
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet,
},
],
},
},
physicalResourceId: PhysicalResourceId.of(
ResourceRecordSet.Name
),
},
onDelete: {
assumedRoleArn: props.AssumeRoleArn,
service: 'Route53',
action: 'changeResourceRecordSets',
parameters: {
HostedZoneId: props.HostedZoneId,
ChangeBatch: {
Changes: [
{
Action: 'DELETE',
ResourceRecordSet,
},
],
},
},
physicalResourceId: PhysicalResourceId.of(
ResourceRecordSet.Name
),
},
});
}
}
export default Route53CrossAccountRecord;