Skip to content
How to automatically set up a website on AWS behind a real domain with AWS CloudFront and cross account roles
June 12, 2025 • Lockhead

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:

AWS Organization

Manages

Manages

Manages

DNS Services

DNS Services

Management Account

DNS Account

Development Account

Production Account

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:

eu-central-1 Region

us-east-1 Region

Provides Certificates

Provides Certificates

Hosts Website

Hosts API

Certificate Stack

API Certificate Stack

Blog Stack

API Stack

CloudFront + S3

API Gateway + Lambda

Deployment Process

I have set up the deployment process to work like this:

Part 2

Part 1

Push Code

Trigger

Deploy Infrastructure

Create/Update

Deploy Infrastructure

Triggers

Create/Update

Triggers

Developer

GitHub Repository

GitHub Actions

CDK Deployment

us-east-1

AWS Resources (Certificates)

AWS custom resource - DNS validation

CDK Deployment

eu-central-1

AWS Resources (Certificates)

AWS custom resource - Route53 updates

Cross-Account Deployment

For resources that span multiple accounts, the deployment process uses cross-account roles:

Account: DNS MAnagement

Account: Workload

Creates

Assumes Role

Modifies

CDK Deployment

Lambda Function

Route53 Role

Hosted Zone

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