Skip to content

Commit 29027bd

Browse files
authored
update cloud templates to properly provision and remove certificates for aws route 53 hosted zones (#2)
1 parent 632305c commit 29027bd

File tree

3 files changed

+94
-57
lines changed

3 files changed

+94
-57
lines changed

README.md

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,42 @@ You can deploy to any AWS region by changing `region=us-east-1` in the URL to yo
4040

4141
The template is automatically updated via GitHub Actions when changes are merged to main for `infrastructure/cloudfront.yaml`
4242

43+
## Prerequisites for Custom Domain
44+
45+
When using a custom domain (`UseCustomDomain=true`), ensure the following prerequisites are met:
46+
47+
### Route 53 Hosted Zone
48+
49+
The custom domain must have a **public** Route 53 hosted zone in the same AWS account, and **Route 53 must be authoritative** for the domain (the domain's NS records at the registrar must point to Route 53 nameservers).
50+
51+
**What happens automatically:**
52+
1. The template looks up the hosted zone for your domain (e.g., `example.com` for `flags.example.com`)
53+
2. ACM creates an SSL certificate with DNS validation
54+
3. ACM automatically creates DNS validation CNAME records in Route 53
55+
4. CloudFormation waits for certificate validation to complete
56+
5. CloudFront distribution is created with the validated certificate
57+
6. DNS A record is created pointing to the CloudFront distribution
58+
59+
**Why DNS delegation matters:**
60+
- ACM creates validation CNAME records in your Route 53 hosted zone
61+
- For validation to succeed, public DNS queries must resolve to Route 53 (not CloudFlare, GoDaddy, etc.)
62+
- If the domain is delegated elsewhere, ACM cannot see its own validation records and the certificate remains in `PENDING_VALIDATION` indefinitely
63+
64+
**Verify Route 53 is authoritative for your domain:**
65+
```bash
66+
# Check public DNS nameservers for your domain
67+
dig +short NS yourdomain.com
68+
69+
# Get your Route 53 nameservers
70+
aws route53 get-hosted-zone --id YOUR_HOSTED_ZONE_ID \
71+
--query "DelegationSet.NameServers" --output table
72+
73+
# These should match!
74+
```
75+
76+
**If they don't match: this will not work!**
77+
Alternatively, you may update your domain registrar's NS records to point to the Route 53 nameservers shown in the above command. WARNING! This is a much larger change and should not be performed without understanding the full impact.
78+
4379
## Configuration Options
4480

4581
| Parameter | Default | Options | Description |
@@ -68,13 +104,14 @@ The template is automatically updated via GitHub Actions when changes are merged
68104

69105
```bash
70106
aws cloudformation deploy \
71-
--template-file templates/cloudfront.yaml \
107+
--template-file infrastructure/templates/cloudfront.yaml \
72108
--stack-name ld-cloudfront-proxy \
73109
--parameter-overrides \
74110
UseCustomDomain=true \
75111
DomainName=flags.my-company-domain.com \
76112
PriceClass=PriceClass_100 \
77-
--capabilities CAPABILITY_IAM
113+
--capabilities CAPABILITY_IAM \
114+
--region us-east-1
78115
```
79116

80117
Your reverse proxy URL will be the DomainName specified in the above command, but you can also run the below command to get it:
@@ -98,12 +135,13 @@ This will return your CloudFront domain (e.g., `flags.my-company-domain.com`)
98135
cd infrastructure
99136

100137
aws cloudformation deploy \
101-
--template-file templates/cloudfront.yaml \
138+
--template-file infrastructure/templates/cloudfront.yaml \
102139
--stack-name ld-cloudfront-proxy \
103140
--parameter-overrides \
104141
UseCustomDomain=false \
105142
PriceClass=PriceClass_100 \
106-
EnableLogging=false
143+
EnableLogging=false \
144+
--region us-east-1
107145
```
108146

109147
### Get Your Proxy URL
@@ -160,16 +198,18 @@ NOTE: You may need to restart your application.
160198
### Automated Cleanup (Recommended)
161199
```bash
162200
aws cloudformation deploy \
163-
--template-file templates/remove-cloudfront.yaml \
201+
--template-file infrastructure/templates/remove-cloudfront.yaml \
164202
--stack-name cleanup-ld-cloudfront \
165203
--capabilities CAPABILITY_NAMED_IAM \
166-
--parameter-overrides StackNameToDelete=ld-cloudfront-proxy
204+
--parameter-overrides \
205+
StackNameToDelete=ld-cloudfront-proxy \
206+
DomainName=flags.my-company-domain.com \
207+
CleanupDNS=true \
208+
CleanupCertificate=true \
209+
--region us-east-1
167210
```
168211
169-
### Manual Cleanup
170-
```bash
171-
aws cloudformation delete-stack --stack-name ld-cloudfront-proxy
172-
```
212+
173213
174214
**Deletion time:** ~15-20 minutes (CloudFront global propagation)
175215

infrastructure/templates/cloudfront.yaml

Lines changed: 26 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ Conditions:
3434
UseLogging: !Equals [!Ref EnableLogging, 'true']
3535

3636
Resources:
37-
# Lambda function to look up Route 53 hosted zone ID
3837
HostedZoneLookupRole:
3938
Type: AWS::IAM::Role
4039
Condition: UseCustomDomain
@@ -72,43 +71,43 @@ Resources:
7271
ZipFile: |
7372
import boto3
7473
import cfnresponse
75-
import json
7674
7775
def handler(event, context):
7876
try:
7977
if event['RequestType'] == 'Delete':
8078
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
8179
return
8280
83-
domain_name = event['ResourceProperties']['DomainName']
81+
domain_name = event['ResourceProperties']['DomainName'].rstrip('.')
82+
r53 = boto3.client('route53')
8483
85-
# Extract the root domain from subdomain
86-
# e.g., flags.example.com -> example.com
87-
domain_parts = domain_name.split('.')
88-
if len(domain_parts) >= 2:
89-
root_domain = '.'.join(domain_parts[-2:]) + '.'
90-
else:
91-
root_domain = domain_name + '.'
84+
labels = domain_name.split('.')
85+
candidates = ['.'.join(labels[i:]) + '.' for i in range(len(labels)-1)]
9286
93-
route53 = boto3.client('route53')
87+
chosen = None
88+
paginator = r53.get_paginator('list_hosted_zones')
89+
zones = []
90+
for page in paginator.paginate():
91+
zones.extend(page['HostedZones'])
9492
95-
# List all hosted zones
96-
paginator = route53.get_paginator('list_hosted_zones')
93+
public_zones = [z for z in zones if not z.get('Config', {}).get('PrivateZone', False)]
9794
98-
for page in paginator.paginate():
99-
for zone in page['HostedZones']:
100-
if zone['Name'] == root_domain:
101-
zone_id = zone['Id'].replace('/hostedzone/', '')
102-
cfnresponse.send(event, context, cfnresponse.SUCCESS, {
103-
'HostedZoneId': zone_id,
104-
'RootDomain': root_domain
105-
})
106-
return
95+
for cand in candidates:
96+
for z in public_zones:
97+
if z['Name'] == cand:
98+
chosen = z
99+
break
100+
if chosen:
101+
break
107102
108-
# If no hosted zone found
109-
cfnresponse.send(event, context, cfnresponse.FAILED, {},
110-
f"No Route 53 hosted zone found for domain: {root_domain}")
103+
if not chosen:
104+
raise Exception(f"No matching PUBLIC hosted zone found for {domain_name} (candidates: {candidates})")
111105
106+
zone_id = chosen['Id'].split('/')[-1]
107+
cfnresponse.send(event, context, cfnresponse.SUCCESS, {
108+
'HostedZoneId': zone_id,
109+
'MatchedZoneName': chosen['Name']
110+
})
112111
except Exception as e:
113112
print(f"Error: {str(e)}")
114113
cfnresponse.send(event, context, cfnresponse.FAILED, {}, str(e))
@@ -133,6 +132,7 @@ Resources:
133132
Tags:
134133
- Key: Name
135134
Value: !Sub '${AWS::StackName}-certificate'
135+
DependsOn: HostedZoneLookup
136136

137137
# CloudFront Response Headers Policy
138138
CfResponseHeadersPolicy:
@@ -149,7 +149,6 @@ Resources:
149149
AccessControlExposeHeaders: { Items: ['ETag','Cache-Control','Content-Type'] }
150150
OriginOverride: true
151151

152-
# CloudFront Origin Request Policy
153152
CfOriginRequestPolicy:
154153
Type: AWS::CloudFront::OriginRequestPolicy
155154
Properties:
@@ -187,7 +186,6 @@ Resources:
187186
QueryStringsConfig:
188187
QueryStringBehavior: "all"
189188

190-
# CloudFront Cache Policy - No Cache for Streaming
191189
CfNoCachePolicy:
192190
Type: AWS::CloudFront::CachePolicy
193191
Properties:
@@ -204,7 +202,6 @@ Resources:
204202
HeadersConfig: { HeaderBehavior: "none" }
205203
QueryStringsConfig: { QueryStringBehavior: "all" }
206204

207-
# CloudFront Distribution
208205
CfDistribution:
209206
Type: AWS::CloudFront::Distribution
210207
Properties:
@@ -347,7 +344,6 @@ Resources:
347344
Prefix: cloudfront-logs/
348345
- !Ref AWS::NoValue
349346

350-
# Automatic DNS Record Creation
351347
DNSRecord:
352348
Type: AWS::Route53::RecordSet
353349
Condition: UseCustomDomain
@@ -357,7 +353,7 @@ Resources:
357353
Type: A
358354
AliasTarget:
359355
DNSName: !GetAtt CfDistribution.DomainName
360-
HostedZoneId: Z2FDTNDATAQYW2 # CloudFront hosted zone ID
356+
HostedZoneId: Z2FDTNDATAQYW2 # AWS's hardcoded global Hosted Zone ID for ALL CloudFront distributions
361357
EvaluateTargetHealth: false
362358

363359
Outputs:

infrastructure/templates/remove-cloudfront.yaml

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ Conditions:
3232
ShouldCleanupCertificate: !And [!Condition HasDomainName, !Equals [!Ref CleanupCertificate, 'true']]
3333

3434
Resources:
35-
# IAM Role for the cleanup Lambda function
3635
CleanupLambdaRole:
3736
Type: AWS::IAM::Role
3837
Properties:
@@ -79,7 +78,6 @@ Resources:
7978
- acm:DeleteCertificate
8079
Resource: '*'
8180

82-
# Lambda function that deletes the CloudFront stack
8381
CleanupLambdaFunction:
8482
Type: AWS::Lambda::Function
8583
Properties:
@@ -99,7 +97,6 @@ Resources:
9997
"""Delete Route 53 A records and ACM validation CNAME records for the domain"""
10098
route53 = boto3.client('route53')
10199
102-
# Find hosted zone for domain
103100
root_domain = '.'.join(domain_name.split('.')[-2:]) + '.'
104101
print(f"Looking for hosted zone: {root_domain}")
105102
@@ -118,7 +115,6 @@ Resources:
118115
print(f"No hosted zone found for {root_domain}")
119116
return
120117
121-
# List ALL records in hosted zone (handle pagination)
122118
paginator = route53.get_paginator('list_resource_record_sets')
123119
all_records = []
124120
@@ -129,24 +125,32 @@ Resources:
129125
130126
records_to_delete = []
131127
132-
# Find records to delete
133128
for record in all_records:
134129
record_name = record['Name'].rstrip('.')
135-
print(f"Checking record: {record_name} (type: {record['Type']})")
130+
record_type = record['Type']
131+
print(f"Checking record: {record_name} (type: {record_type})")
136132
137-
# Delete A record for our domain
138-
if record_name == domain_name and record['Type'] == 'A':
133+
if record_name == domain_name and record_type == 'A':
139134
records_to_delete.append(('A', record))
140135
print(f"Found A record to delete: {domain_name}")
141136
142-
# Delete ACM validation CNAME records (start with _ and contain our domain)
143-
elif record['Type'] == 'CNAME' and record_name.startswith('_') and domain_name in record_name:
144-
records_to_delete.append(('CNAME', record))
145-
print(f"Found ACM validation CNAME to delete: {record_name}")
137+
elif record_type == 'CNAME' and record_name.startswith('_'):
138+
is_acm_validation = False
139+
140+
if domain_name in record_name:
141+
is_acm_validation = True
142+
elif record.get('ResourceRecords'):
143+
for rr in record['ResourceRecords']:
144+
if '.acm-validations.aws.' in rr.get('Value', ''):
145+
is_acm_validation = True
146+
break
147+
148+
if is_acm_validation:
149+
records_to_delete.append(('CNAME', record))
150+
print(f"Found ACM validation CNAME to delete: {record_name}")
146151
147152
print(f"Total records to delete: {len(records_to_delete)}")
148153
149-
# Delete records in batch if any found
150154
if records_to_delete:
151155
changes = []
152156
for record_type, record in records_to_delete:
@@ -170,9 +174,8 @@ Resources:
170174
171175
def cleanup_acm_certificates(domain_name):
172176
"""Delete ACM certificates for the domain"""
173-
acm = boto3.client('acm', region_name='us-east-1') # Certificates for CloudFront must be in us-east-1
177+
acm = boto3.client('acm', region_name='us-east-1') # CloudFront requires certificates in us-east-1
174178
175-
# List all certificates
176179
paginator = acm.get_paginator('list_certificates')
177180
178181
for page in paginator.paginate():
@@ -248,14 +251,12 @@ Resources:
248251
cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "Cleanup completed"})
249252
250253
else:
251-
# Update case
252254
cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "No action required"})
253255
254256
except Exception as e:
255257
print(f"Error: {str(e)}")
256258
cfnresponse.send(event, context, cfnresponse.FAILED, {"Message": str(e)})
257259
258-
# Custom resource that triggers the Lambda function
259260
TriggerCleanup:
260261
Type: AWS::CloudFormation::CustomResource
261262
Properties:

0 commit comments

Comments
 (0)