In part one, we loaded the AWS software development kit and created reusable service clients. Parts two and three used those clients to upload and moderate images without routing the file data through ColdFusion.
For the final article, we’re going to edit DNS records. This deserves slightly more caution than uploading a profile photograph. A broken image inconveniences one user. A broken domain record can make an entire application disappear while every server continues operating perfectly. It’s a remarkably efficient way to ruin an afternoon.
Our example will manage canonical-name records beneath an application-owned suffix:
customer-name.example.comEvery record will point to one configured target:
application.example.netThe visitor may choose customer-name. They won’t choose the hosted zone, record type, complete hostname, or target. User input and unrestricted domain management should never be introduced. I’ve made some poor architectural decisions, but I prefer not to automate the consequences.
Restricting the Amazon Permissions
The application needs permission to:
- Change records in one hosted zone
- List records in that hosted zone before deletion
- Check whether a submitted change has propagated
Route 53 supports permission conditions that restrict record names, record types, and permitted actions. We should use all three.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "route53:ChangeResourceRecordSets",
"Resource": "arn:aws:route53:::hostedzone/Z11111112222222333333",
"Condition": {
"ForAllValues:StringLike": {
"route53:ChangeResourceRecordSetsNormalizedRecordNames": [
"*.sites.example.com"
]
},
"ForAllValues:StringEquals": {
"route53:ChangeResourceRecordSetsRecordTypes": [
"CNAME"
],
"route53:ChangeResourceRecordSetsActions": [
"UPSERT",
"DELETE"
]
}
}
},
{
"Effect": "Allow",
"Action": "route53:ListResourceRecordSets",
"Resource": "arn:aws:route53:::hostedzone/Z11111112222222333333"
},
{
"Effect": "Allow",
"Action": "route53:GetChange",
"Resource": "arn:aws:route53:::change/*"
}
]
}Replace the hosted-zone identifier and domain names with your own values. These conditions are a second boundary around the code. Even if the application attempts to modify another record type or another part of the hosted zone, Amazon rejects the request.
Amazon documents the normalization rules and available restrictions in its fine-grained Route 53 permission guide.
The application should manage a dedicated suffix such as sites.example.com. Don’t give routine application code permission to alter mail records, name servers, verification records, or the root domain. That’s too much trust for code I probably wrote while eating lunch.
Creating the Record Service
Create models/route53_record_service.cfc:
component output="false" {
public any function init(
required any route53_client,
required string hosted_zone_id,
required string managed_suffix,
required string canonical_name_target,
numeric time_to_live_seconds = 300
) {
variables.route53_client = arguments.route53_client;
variables.hosted_zone_id = trim( arguments.hosted_zone_id );
variables.managed_suffix = normalizeName( arguments.managed_suffix );
variables.canonical_name_target = normalizeName( arguments.canonical_name_target );
variables.time_to_live_seconds = arguments.time_to_live_seconds;
if ( !len( variables.hosted_zone_id ) ) {
throw(
type = "Route53RecordService.InvalidConfiguration",
message = "A hosted-zone identifier is required."
);
}
if ( !len( variables.managed_suffix ) || !len( variables.canonical_name_target ) ) {
throw(
type = "Route53RecordService.InvalidConfiguration",
message = "A managed suffix and canonical-name target are required."
);
}
if ( variables.time_to_live_seconds < 1 ) {
throw(
type = "Route53RecordService.InvalidConfiguration",
message = "Time to live must be greater than zero."
);
}
return this;
}
public struct function upsertHostname( required string label ) {
var fully_qualified_name = buildManagedName( arguments.label );
var resource_record = createObject(
"java",
"software.amazon.awssdk.services.route53.model.ResourceRecord"
)
.builder()
.value( ensureTrailingDot( variables.canonical_name_target ) )
.build();
var record_set = createObject(
"java",
"software.amazon.awssdk.services.route53.model.ResourceRecordSet"
)
.builder()
.name( fully_qualified_name )
.type(
createObject( "java", "software.amazon.awssdk.services.route53.model.RRType" ).CNAME
)
.ttl( javacast( "long", variables.time_to_live_seconds ) )
.resourceRecords( [ resource_record ] )
.build();
var result = submitChange(
action = "UPSERT",
record_set = record_set,
comment = "Provision application hostname " & fully_qualified_name
);
result.changed = true;
result.fully_qualified_name = fully_qualified_name;
return result;
}
public struct function deleteHostname( required string label ) {
var fully_qualified_name = buildManagedName( arguments.label );
var existing = findCanonicalNameRecord( fully_qualified_name );
if ( !existing.found ) {
return {
changed: false,
fully_qualified_name: fully_qualified_name,
status: "ABSENT",
change_id: ""
};
}
var values = existing.record_set.resourceRecords();
if (
values.size() != 1
|| normalizeName( values.get( 0 ).value() ) != variables.canonical_name_target
) {
throw(
type = "Route53RecordService.RecordConflict",
message = "The existing record isn't owned by this application."
);
}
var result = submitChange(
action = "DELETE",
record_set = existing.record_set,
comment = "Remove application hostname " & fully_qualified_name
);
result.changed = true;
result.fully_qualified_name = fully_qualified_name;
return result;
}
public struct function getChangeStatus( required string change_id ) {
var request = createObject( "java", "software.amazon.awssdk.services.route53.model.GetChangeRequest" )
.builder()
.id( trim( arguments.change_id ) )
.build();
var response = variables.route53_client.getChange( request );
var change_information = response.changeInfo();
return {
change_id: toString( change_information.id() ),
status: toString( change_information.statusAsString() ),
submitted_at: toString( change_information.submittedAt() )
};
}
public void function close() { variables.route53_client.close(); }
private struct function submitChange(
required string action,
required any record_set,
required string comment
) {
var change_action = createObject(
"java",
"software.amazon.awssdk.services.route53.model.ChangeAction"
).fromValue( uCase( arguments.action ) );
var change = createObject( "java", "software.amazon.awssdk.services.route53.model.Change" )
.builder()
.action( change_action )
.resourceRecordSet( arguments.record_set )
.build();
var change_batch = createObject( "java", "software.amazon.awssdk.services.route53.model.ChangeBatch" )
.builder()
.comment( arguments.comment )
.changes( [ change ] )
.build();
var request = createObject( "java", "software.amazon.awssdk.services.route53.model.ChangeResourceRecordSetsRequest" )
.builder()
.hostedZoneId( variables.hosted_zone_id )
.changeBatch( change_batch )
.build();
var response = variables.route53_client.changeResourceRecordSets( request );
var change_information = response.changeInfo();
return {
change_id: toString( change_information.id() ),
status: toString( change_information.statusAsString() ),
submitted_at: toString( change_information.submittedAt() )
};
}
private struct function findCanonicalNameRecord( required string fully_qualified_name ) {
var record_type = createObject( "java", "software.amazon.awssdk.services.route53.model.RRType" ).CNAME;
var request = createObject( "java", "software.amazon.awssdk.services.route53.model.ListResourceRecordSetsRequest" )
.builder()
.hostedZoneId( variables.hosted_zone_id )
.startRecordName( arguments.fully_qualified_name )
.startRecordType( record_type )
.maxItems( "1" )
.build();
var response = variables.route53_client.listResourceRecordSets( request );
var record_sets = response.resourceRecordSets();
if ( record_sets.isEmpty() ) {
return { found: false };
}
var candidate = record_sets.get( 0 );
if (
normalizeName( candidate.name() ) != normalizeName( arguments.fully_qualified_name )
|| candidate.typeAsString() != "CNAME"
) {
return { found: false };
}
return {
found: true,
record_set: candidate
};
}
private string function buildManagedName( required string label ) {
var selected_label = lCase( trim( arguments.label ) );
if (
len( selected_label ) > 63
|| !reFind( "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", selected_label )
) {
throw(
type = "Route53RecordService.InvalidLabel",
message = "The hostname label is invalid."
);
}
return ensureTrailingDot( selected_label & "." & variables.managed_suffix );
}
private string function normalizeName( required string value ) {
return lCase(
reReplace(
trim( arguments.value ),
"\.$",
"",
"one"
)
);
}
private string function ensureTrailingDot( required string value ) {
var selected_value = trim( arguments.value );
if ( right( selected_value, 1 ) != "." ) {
selected_value &= ".";
}
return selected_value;
}
}The public methods accept a single hostname label. Calling code can request blue-team, but it can’t request mail.example.com, change a name-server record, or point the hostname at a cryptocurrency referral site. The canonical-name target comes from protected application configuration. The hosted-zone identifier and managed suffix do too.
The service uses UPSERT, which creates the record when it doesn’t exist and updates it when it does. That makes provisioning safe to retry, but it also means the application can replace an existing canonical-name record within its managed suffix. Use a dedicated suffix whose records belong exclusively to the application.
Amazon describes the exact CREATE, DELETE, and UPSERT behaviour in the ChangeResourceRecordSets reference.
Creating the Global Route 53 Client
Route 53 is a global service. When we create its Java client, we should use the software development kit’s special global region:
<cfscript>
route53_client = application.aws_client_factory.createClient(
service_name = "route53",
region_name = "aws-global"
);
application.route53_record_service = new models.route53_record_service(
route53_client = route53_client,
hosted_zone_id = application.configuration.route53_hosted_zone_id,
managed_suffix = "sites.example.com",
canonical_name_target = "application.example.net",
time_to_live_seconds = 300
);
</cfscript>The global region is the one correction we need to make to the generic factory usage from part one. Amazon’s Java version 2 migration documentation specifies Region.AWS_GLOBAL for services without regional endpoints. Our factory creates regions by name, so aws-global supplies the corresponding value.
Close the client when the application ends:
public void function onApplicationEnd( required struct applicationScope ) {
if ( structKeyExists( arguments.applicationScope, "route53_record_service" ) ) {
arguments.applicationScope.route53_record_service.close();
}
}As with the other service clients, create it once and reuse it. Repeatedly constructing clients won’t improve the domain records. It will merely make the application busier while achieving nothing, a pattern with which I’m personally familiar.
Creating or Updating a Hostname
Provisioning a hostname requires one method call:
<cfscript>
result = application.route53_record_service.upsertHostname( label = "blue-team" );
writeDump( result );
</cfscript>The result will resemble this:
{
"changed": true,
"fully_qualified_name": "blue-team.sites.example.com.",
"change_id": "/change/C0123456789EXAMPLE",
"status": "PENDING",
"submitted_at": "2026-09-15T14:32:17Z"
}PENDING doesn’t mean the request failed. It means Route 53 accepted the change and is propagating it to the authoritative name servers. Store the change identifier with the provisioning record. A scheduled job or background worker can check it later:
<cfscript>
change = application.route53_record_service.getChangeStatus( change_id = pending_domain_change.change_id );
if ( change.status == "INSYNC" ) {
domain_repository.markActive(
domain_id = pending_domain_change.domain_id
);
}
</cfscript>Don’t keep a public request open while repeatedly asking Route 53 whether it has finished. Return a pending state and let a background process continue the work. Holding a ColdFusion request open won’t make DNS feel encouraged.
According to Amazon’s GetChange documentation, INSYNC means the change has reached all Route 53 authoritative name servers for the hosted zone. It doesn’t mean every recursive resolver on the internet has forgotten the previous answer. Cached records remain cached according to their time-to-live value. DNS remembers our mistakes for exactly as long as we instructed it to.
Removing a Hostname
Removing a record is slightly less obvious than creating one. Route 53 requires a deletion request to contain the same record values used by the existing record. We shouldn’t rebuild the record from assumptions. The service retrieves the live record set and submits that exact object in the deletion request.
<cfscript>
result = application.route53_record_service.deleteHostname( label = "blue-team" );
writeDump( result );
</cfscript>Before deleting, the service verifies that the current target still matches the application’s configured target. If somebody changed the record manually, the method throws Route53RecordService.RecordConflict instead of deleting something it may no longer own. That check is intentionally suspicious. Suspicion is an underrated quality in infrastructure code.
Route 53 treats a change batch transactionally: either every valid change in the batch is applied or none of them are. Our example submits one change at a time, but the same structure can safely replace several related records together.
Handling Failures
The component allows AWS exceptions to reach the calling service. Production code should catch them at the workflow boundary, log the request identifier when available, and leave the domain operation pending or failed. Don’t report a domain as active until GetChange returns INSYNC. Don’t delete the application’s database record before Route 53 accepts the deletion. Don’t let a failure silently become success because the interface looked happier that way.
At this point, the same ColdFusion application can:
- Authorize direct browser uploads to AWS S3
- Moderate uploaded images with Amazon Rekognition
- Create and remove application-owned domain records with Route 53
ColdFusion didn’t need a service-specific extension for any of it. It loaded the Java libraries, created typed request objects, and called the same interfaces available to a Java application. The difficult part wasn’t calling Amazon. The difficult part was deciding exactly what the application should be permitted to do.
That’s usually the difficult part. The method call merely gives our mistakes somewhere expensive to happen.