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),
});

Comments

Join the conversation by mentioning this post on Bluesky.

Loading comments...