App Runner Custom Domain Association With CDK
July 7, 2025
2 minutes
As of today, the AWS Cloud Development Kit (CDK) does not natively support associating a custom domain with an AWS App Runner service. This has been a long-standing request from the community.
The AWS CDK team is actively working on a solution, and progress can be tracked on the official App Runner roadmap.
In the meantime, I’ve created a custom CDK construct that acts as a workaround to programmatically associate a custom domain with an App Runner service using a combination of Route 53, IAM, and a custom resource.
import * as apprunner from '@aws-cdk/aws-apprunner-alpha';
import { Duration } from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as customResources from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs';
export interface AppRunnerCustomDomainProps {
service: apprunner.Service;
domainName: string;
hostedZone: route53.IHostedZone;
ttl?: Duration;
}
export class AppRunnerCustomDomain extends Construct {
public customResource: customResources.AwsCustomResource;
public cnameRecord: route53.CnameRecord;
constructor(scope: Construct, id: string, props: AppRunnerCustomDomainProps) {
super(scope, id);
this.cnameRecord = new route53.CnameRecord(this, `CnameRecord`, {
zone: props.hostedZone,
recordName: props.domainName,
domainName: props.service.serviceUrl.replace('https://', ''),
ttl: props.ttl ?? Duration.minutes(5),
});
this.customResource = new customResources.AwsCustomResource(
this,
`CustomDomainAssociation`,
{
onCreate: {
service: 'AppRunner',
action: 'associateCustomDomain',
parameters: {
ServiceArn: props.service.serviceArn,
DomainName: props.domainName,
EnableWwwSubdomain: false,
},
physicalResourceId: customResources.PhysicalResourceId.of(
`custom-domain-${props.domainName}`
),
},
onDelete: {
service: 'AppRunner',
action: 'disassociateCustomDomain',
parameters: {
ServiceArn: props.service.serviceArn,
DomainName: props.domainName,
},
},
policy: customResources.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: [
'apprunner:AssociateCustomDomain',
'apprunner:DisassociateCustomDomain',
],
resources: [props.service.serviceArn],
}),
]),
timeout: Duration.minutes(5),
}
);
const getValidationRecords = new customResources.AwsCustomResource(
this,
`GetValidationRecords`,
{
onCreate: {
service: 'AppRunner',
action: 'describeCustomDomains',
parameters: {
ServiceArn: props.service.serviceArn,
},
physicalResourceId: customResources.PhysicalResourceId.of(
`validation-records-${props.domainName}`
),
},
policy: customResources.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: ['apprunner:DescribeCustomDomains'],
resources: [props.service.serviceArn],
}),
]),
}
);
getValidationRecords.node.addDependency(this.customResource);
for (let i = 0; i < 3; i++) {
const recordName = getValidationRecords.getResponseField(
`CustomDomains.0.CertificateValidationRecords.${i}.Name`
);
const recordValue = getValidationRecords.getResponseField(
`CustomDomains.0.CertificateValidationRecords.${i}.Value`
);
new customResources.AwsCustomResource(this, `ValidationRecord${i}`, {
onCreate: {
service: 'Route53',
action: 'changeResourceRecordSets',
parameters: {
HostedZoneId: props.hostedZone.hostedZoneId,
ChangeBatch: {
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet: {
Name: recordName,
Type: 'CNAME',
TTL: (props.ttl ?? Duration.minutes(5)).toSeconds(),
ResourceRecords: [{ Value: recordValue }],
},
},
],
},
},
physicalResourceId: customResources.PhysicalResourceId.of(
`validation-record-${i}-${props.domainName}`
),
ignoreErrorCodesMatching: 'InvalidChangeBatch',
},
onDelete: {
service: 'Route53',
action: 'changeResourceRecordSets',
parameters: {
HostedZoneId: props.hostedZone.hostedZoneId,
ChangeBatch: {
Changes: [
{
Action: 'DELETE',
ResourceRecordSet: {
Name: recordName,
Type: 'CNAME',
TTL: (props.ttl ?? Duration.minutes(5)).toSeconds(),
ResourceRecords: [{ Value: recordValue }],
},
},
],
},
},
ignoreErrorCodesMatching: 'InvalidChangeBatch|NoSuchHostedZone',
},
policy: customResources.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: ['route53:ChangeResourceRecordSets'],
resources: [props.hostedZone.hostedZoneArn],
}),
]),
});
}
}
}
Usage Example
const service = new apprunner.Service(this, 'Api', {
source: apprunner.Source.fromEcr({
repository: ecr.Repository.fromRepositoryName(...),
tag: 'latest',
}),
});
new AppRunnerCustomDomain(this, 'CustomDomain', {
service,
domainName: 'api.example.com',
hostedZone: zone,
ttl: Duration.minutes(5),
});