
How to automatically set up a website on AWS behind a real domain with AWS CloudFront and cross account roles
Have you ever tried to automate the setup of CloudFront in a multi-account-setup? Here’s how to do it!
In this blog post you are going to learn how to automatically set up CloudFront and Route53 using AWS CDK across different AWS accounts.
DNS management for all of my domains
As most of you reading this blog, we all have “our” domains that we use - or maybe not use - and registered years ago!
For me, this is domains like lockhead.info, lockhead.net, lockhead.cloud, lockhead.dev, … and a few others. Since 2005 I have registered all of them at Netbeat. That works perfectly. Until recently, I also used Netbeat to manage the DNS setup for my domains, but a few months ago I decided to move everything into AWS and use Route53 for my DNS management.
AWS account structure
The infrastructure is deployed across multiple AWS accounts for separation of concerns and security:
I made the decision to host the DNS centrally and manage it with an AWS CDK project, that gives me the possibility to - if required - quickly identify the current status by logging into the decicated account.
But it also brings a challenge…: For Workloads, if they need to do DNS changes. e.g. to validate a domain for CloudFront usage (or API Gateway usage), the CI/CD pipeline will need to perform actions with Route53 cross-account.
Using Cloudfront - simple? Not if you’re not in us-east-1
As I already wrote, I am using CloudFront to host all of the domains above…but… my infrastructure is not deployed in us-east-1, where CloudFront expects the ACM certificate for your domain to be registered. This brings an additional challenge when trying to automate the whole process: The registration of the certificate needs to be done in us-east-1 while the rest of the infrastructure is in the target region.
My AWS stack structure (using AWS CDK)
The project is deployed using AWS CDK with multiple stacks:
Deployment Process
I have set up the deployment process to work like this:
Cross-Account Deployment
For resources that span multiple accounts, the deployment process uses cross-account roles:
These cross-account roles are assumed by AWS Lambda functions that are deployed as custom CloudFormation resources.
Some pieces of source code
Custom Resource setup
The custom resources are set up like this in AWS CDK:
// Create a Lambda function that will assume the role for DNS validation
const dnsValidationHandler = new NodejsFunction(this, 'DnsValidationHandler', {
runtime: cdk.aws_lambda.Runtime.NODEJS_22_X,
handler: 'handler',
entry: path.join(__dirname, 'lambda/dns-validation-handler.ts'),
bundling: {
minify: true,
externalModules: [],
sourceMap: true,
target: 'es2020',
},
timeout: cdk.Duration.minutes(5),
});
// Create a custom resource that will trigger the role assumption and DNS validation
const dnsValidationResource = new cr.AwsCustomResource(this, 'DnsValidationResource', {
onCreate: {
service: 'Lambda',
action: 'invoke',
parameters: {
FunctionName: dnsValidationHandler.functionName,
InvocationType: 'RequestResponse',
Payload: JSON.stringify({
RequestType: 'Create',
ResourceProperties: {
RoleArn: route53LookupRoleArn,
DomainName: domainName,
SubjectAlternativeNames: subjectAlternativeNames || [],
HostedZoneId: hostedZone.hostedZoneId
}
})
},
physicalResourceId: cr.PhysicalResourceId.of('dns-validation-records-created'),
outputPaths: ['Payload'], // Extract the response from the Lambda payload
},
onUpdate: {
service: 'Lambda',
action: 'invoke',
parameters: {
FunctionName: dnsValidationHandler.functionName,
Payload: JSON.stringify({
RequestType: 'Update',
ResourceProperties: {
RoleArn: route53LookupRoleArn,
DomainName: domainName,
SubjectAlternativeNames: subjectAlternativeNames || [],
HostedZoneId: hostedZone.hostedZoneId
}
})
},
},
onDelete: {
service: 'Lambda',
action: 'invoke',
parameters: {
FunctionName: dnsValidationHandler.functionName,
Payload: JSON.stringify({
RequestType: 'Delete',
ResourceProperties: {
RoleArn: route53LookupRoleArn,
DomainName: domainName,
SubjectAlternativeNames: subjectAlternativeNames || [],
HostedZoneId: hostedZone.hostedZoneId
}
})
},
},
policy: cr.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['lambda:InvokeFunction'],
resources: [dnsValidationHandler.functionArn],
}),
]),
});
DNS validation lambda - source code
The source code of the DNS validation Custom Resouce is 100% generated by Amazon Q Developer:
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
import {
Route53Client,
ChangeResourceRecordSetsCommand,
ListResourceRecordSetsCommand,
ResourceRecordSet,
ChangeAction,
Change
} from '@aws-sdk/client-route-53';
import { ACMClient, DescribeCertificateCommand, ListCertificatesCommand } from '@aws-sdk/client-acm';
// Initialize STS client
const stsClient = new STSClient({});
const acmClient = new ACMClient({});
export const handler = async (event: any) => {
console.log('Event:', JSON.stringify(event, null, 2));
// Extract the domain name, subject alternative names, and hosted zone ID from the event
const {
RoleArn: roleArn,
DomainName: domainName,
SubjectAlternativeNames: subjectAlternativeNames,
HostedZoneId: hostedZoneId
} = event.ResourceProperties;
if (!domainName && event.RequestType !== 'Delete') {
throw new Error('DomainName is required for Create and Update operations');
}
if (!hostedZoneId && event.RequestType !== 'Delete') {
throw new Error('HostedZoneId is required for Create and Update operations');
}
if (event.RequestType === 'Delete') {
// For delete operations, we would ideally clean up the DNS records
// However, since the certificate will be deleted by CloudFormation,
// the DNS records are no longer needed, so we can just return success
return {
PhysicalResourceId: event.PhysicalResourceId || 'dns-validation-records',
Status: 'SUCCESS',
Data: {}
};
}
try {
// Find the certificate ARN using the domain name
const certificateArn = await findCertificateArn(domainName, subjectAlternativeNames);
if (!certificateArn) {
throw new Error(`Certificate not found for domain: ${domainName}`);
}
console.log(`Found certificate ARN: ${certificateArn}`);
// Assume the Route53 lookup role
const assumeRoleCommand = new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: 'ACMCertificateValidation'
});
const assumeRoleResponse = await stsClient.send(assumeRoleCommand);
const credentials = assumeRoleResponse.Credentials;
if (!credentials) {
throw new Error('Failed to obtain credentials from assumed role');
}
// Create Route53 client with the assumed role credentials
const route53Client = new Route53Client({
credentials: {
accessKeyId: credentials.AccessKeyId!,
secretAccessKey: credentials.SecretAccessKey!,
sessionToken: credentials.SessionToken
}
});
// Get the certificate details to extract the DNS validation records
const describeCertificateCommand = new DescribeCertificateCommand({
CertificateArn: certificateArn
});
const certificateDetails = await acmClient.send(describeCertificateCommand);
if (!certificateDetails.Certificate) {
throw new Error(`Certificate not found: ${certificateArn}`);
}
const domainValidationOptions = certificateDetails.Certificate.DomainValidationOptions || [];
if (domainValidationOptions.length === 0) {
throw new Error('No domain validation options found in the certificate');
}
// Create DNS validation records for each domain
// Create DNS validation records for each domain
const changes: Change[] = [];
for (const option of domainValidationOptions) {
if (!option.ResourceRecord) {
console.warn(`No validation record for domain: ${option.DomainName}`);
continue;
}
const { Name, Type, Value } = option.ResourceRecord;
// Check if the record already exists
const listRecordsCommand = new ListResourceRecordSetsCommand({
HostedZoneId: hostedZoneId,
StartRecordName: Name,
StartRecordType: Type,
MaxItems: 1
});
const existingRecords = await route53Client.send(listRecordsCommand);
const recordExists = existingRecords.ResourceRecordSets?.some(
record => record.Name === Name && record.Type === Type
);
if (!recordExists) {
changes.push({
Action: 'CREATE' as ChangeAction,
ResourceRecordSet: {
Name,
Type,
TTL: 300,
ResourceRecords: [{ Value }]
}
});
}
}
// If there are changes to make, submit them
if (changes.length > 0) {
const changeRecordsCommand = new ChangeResourceRecordSetsCommand({
HostedZoneId: hostedZoneId,
ChangeBatch: {
Changes: changes
}
});
await route53Client.send(changeRecordsCommand);
console.log(`Created ${changes.length} DNS validation records`);
} else {
console.log('No new DNS validation records to create');
}
// Return success
return {
PhysicalResourceId: 'dns-validation-records',
Status: 'SUCCESS',
Data: {
AssumedRoleArn: roleArn,
CertificateArn: certificateArn,
ValidationRecordsCreated: changes.length
}
};
} catch (error) {
console.error('Error in DNS validation handler:', error);
// Return failure response for CloudFormation
return {
PhysicalResourceId: event.PhysicalResourceId || 'dns-validation-records',
Status: 'FAILED',
Reason: error instanceof Error ? error.message : String(error)
};
}
};
/**
* Find the certificate ARN for a given domain name with retry logic
* @param domainName The primary domain name on the certificate
* @param subjectAlternativeNames Optional list of alternative domain names
* @returns The ARN of the matching certificate or undefined if not found
*/
async function findCertificateArn(domainName: string, subjectAlternativeNames?: string[]): Promise<string | undefined> {
let maxRetries = 50;
if (domainName === 'example.com')
maxRetries = 3;
let retryCount = 0;
let lastError: any;
// Get and log identity information for debugging
try {
const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts');
const stsClient = new STSClient({});
const callerIdentity = await stsClient.send(new GetCallerIdentityCommand({}));
console.log('ACM Client Identity:', {
Account: callerIdentity.Account,
Arn: callerIdentity.Arn,
UserId: callerIdentity.UserId
});
console.log('ACM Client Region:', acmClient.config.region);
} catch (error) {
console.error('Error getting caller identity:', error);
}
while (retryCount < maxRetries) {
try {
// Add exponential backoff delay except for the first attempt
if (retryCount > 0) {
const delayMs = Math.pow(2, retryCount) * 1000; // 2s, 4s, 8s
console.log(`Retry attempt ${retryCount + 1}/${maxRetries}, waiting ${delayMs}ms...`);
await new Promise(resolve => setTimeout(resolve, delayMs));
}
// List all certificates
const listCertificatesCommand = new ListCertificatesCommand({
CertificateStatuses: ['PENDING_VALIDATION', 'ISSUED']
});
const listResponse = await acmClient.send(listCertificatesCommand);
if (!listResponse.CertificateSummaryList || listResponse.CertificateSummaryList.length === 0) {
console.log('No certificates found');
retryCount++;
continue; // Try again if no certificates found
}
console.log(`Found ${listResponse.CertificateSummaryList.length} certificates`);
// Find certificates that match our domain name
const potentialMatches = listResponse.CertificateSummaryList.filter(cert =>
cert.DomainName === domainName
);
if (potentialMatches.length === 0) {
console.log(`No certificates found for domain: ${domainName}`);
retryCount++;
continue; // Try again if no matching certificates found
}
// If we have subject alternative names, we need to check those too
if (subjectAlternativeNames && subjectAlternativeNames.length > 0) {
// For each potential match, get the full certificate details to check SANs
for (const cert of potentialMatches) {
if (!cert.CertificateArn) continue;
const describeCertificateCommand = new DescribeCertificateCommand({
CertificateArn: cert.CertificateArn
});
const certDetails = await acmClient.send(describeCertificateCommand);
if (!certDetails.Certificate) continue;
// Check if all subject alternative names are included in the certificate
const certSANs = certDetails.Certificate.SubjectAlternativeNames || [];
const allSANsMatch = subjectAlternativeNames.every(san =>
certSANs.includes(san)
);
if (allSANsMatch) {
return cert.CertificateArn;
}
}
// If we get here, we didn't find a perfect match
console.log('No certificate found with matching subject alternative names');
// Fall back to the first certificate that matches the domain name
return potentialMatches[0].CertificateArn;
} else {
// If no SANs to check, return the first matching certificate
return potentialMatches[0].CertificateArn;
}
} catch (error) {
console.error(`Error finding certificate ARN (attempt ${retryCount + 1}/${maxRetries}):`, error);
lastError = error;
retryCount++;
}
}
// If we've exhausted all retries, throw the last error
if (lastError) {
throw lastError;
}
return undefined;
}
DNS entry setup
The source code of the DNS validation Custom Resouce is 100% generated by Amazon Q Developer and it is used to set up the DNS entries that are required so YOU can access the homepage later (it sets up A-records/CNAME-records in Route53):
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
import {
Route53Client,
ChangeResourceRecordSetsCommand,
ListResourceRecordSetsCommand,
ResourceRecordSet,
ChangeAction,
Change
} from '@aws-sdk/client-route-53';
// Initialize STS client
const stsClient = new STSClient({});
export const handler = async (event: any) => {
console.log('Event:', JSON.stringify(event, null, 2));
// Extract the properties from the event
const {
RoleArn: roleArn,
DomainName: domainName,
WwwDomainName: wwwDomainName,
HostedZoneId: hostedZoneId,
DistributionDomainName: distributionDomainName,
RequestType: requestType
} = event.ResourceProperties;
if (!domainName && requestType !== 'Delete') {
throw new Error('DomainName is required for Create and Update operations');
}
if (!hostedZoneId && requestType !== 'Delete') {
throw new Error('HostedZoneId is required for Create and Update operations');
}
if (!distributionDomainName && requestType !== 'Delete') {
throw new Error('DistributionDomainName is required for Create and Update operations');
}
if (requestType === 'Delete') {
// For delete operations, we would ideally clean up the DNS records
// However, since the stack will be deleted by CloudFormation,
// the DNS records will be cleaned up automatically, so we can just return success
return {
PhysicalResourceId: event.PhysicalResourceId || 'dns-records',
Status: 'SUCCESS',
Data: {}
};
}
try {
// Assume the Route53 lookup role
const assumeRoleCommand = new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: 'DnsRecordsCreation'
});
const assumeRoleResponse = await stsClient.send(assumeRoleCommand);
const credentials = assumeRoleResponse.Credentials;
if (!credentials) {
throw new Error('Failed to obtain credentials from assumed role');
}
// Create Route53 client with the assumed role credentials
const route53Client = new Route53Client({
credentials: {
accessKeyId: credentials.AccessKeyId!,
secretAccessKey: credentials.SecretAccessKey!,
sessionToken: credentials.SessionToken
}
});
// Create DNS records for both apex and www domains
const changes: Change[] = [];
// Check if the apex record already exists
const listApexRecordsCommand = new ListResourceRecordSetsCommand({
HostedZoneId: hostedZoneId,
StartRecordName: domainName,
StartRecordType: 'A',
MaxItems: 1
});
const existingApexRecords = await route53Client.send(listApexRecordsCommand);
const apexRecordExists = existingApexRecords.ResourceRecordSets?.some(
record => record.Name === `${domainName}.` && record.Type === 'A'
);
if (!apexRecordExists) {
// Create the apex record (A record for the root domain)
changes.push({
Action: 'CREATE' as ChangeAction,
ResourceRecordSet: {
Name: domainName,
Type: 'A',
AliasTarget: {
DNSName: distributionDomainName,
HostedZoneId: 'Z2FDTNDATAQYW2', // CloudFront's hosted zone ID is always this value
EvaluateTargetHealth: false
}
}
});
}
// Check if the www record already exists
const listWwwRecordsCommand = new ListResourceRecordSetsCommand({
HostedZoneId: hostedZoneId,
StartRecordName: wwwDomainName,
StartRecordType: 'A',
MaxItems: 1
});
const existingWwwRecords = await route53Client.send(listWwwRecordsCommand);
const wwwRecordExists = existingWwwRecords.ResourceRecordSets?.some(
record => record.Name === `${wwwDomainName}.` && record.Type === 'A'
);
if (!wwwRecordExists) {
// Create the www record (A record for the www subdomain)
changes.push({
Action: 'CREATE' as ChangeAction,
ResourceRecordSet: {
Name: wwwDomainName,
Type: 'A',
AliasTarget: {
DNSName: distributionDomainName,
HostedZoneId: 'Z2FDTNDATAQYW2', // CloudFront's hosted zone ID is always this value
EvaluateTargetHealth: false
}
}
});
}
// If there are changes to make, submit them
if (changes.length > 0) {
const changeRecordsCommand = new ChangeResourceRecordSetsCommand({
HostedZoneId: hostedZoneId,
ChangeBatch: {
Changes: changes
}
});
await route53Client.send(changeRecordsCommand);
console.log(`Created ${changes.length} DNS records`);
} else {
console.log('No new DNS records to create');
}
// Return success
return {
PhysicalResourceId: 'dns-records',
Status: 'SUCCESS',
Data: {
AssumedRoleArn: roleArn,
RecordsCreated: changes.length
}
};
} catch (error) {
console.error('Error in DNS records handler:', error);
// Return failure response for CloudFormation
return {
PhysicalResourceId: event.PhysicalResourceId || 'dns-records',
Status: 'FAILED',
Reason: error instanceof Error ? error.message : String(error)
};
}
};
Summary
If you’ve read this blog you have seen how you can automate your CloudFront and DNS management using AWS CDK if your deployment is spread accross multiple AWS accounts.
Let me know on socials or in the contact form if this helped you or if you have any questions!
Photo by Ajay Gorecha on Unsplash