Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,42 @@ You can deploy to any AWS region by changing `region=us-east-1` in the URL to yo

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

## Prerequisites for Custom Domain

When using a custom domain (`UseCustomDomain=true`), ensure the following prerequisites are met:

### Route 53 Hosted Zone

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).

**What happens automatically:**
1. The template looks up the hosted zone for your domain (e.g., `example.com` for `flags.example.com`)
2. ACM creates an SSL certificate with DNS validation
3. ACM automatically creates DNS validation CNAME records in Route 53
4. CloudFormation waits for certificate validation to complete
5. CloudFront distribution is created with the validated certificate
6. DNS A record is created pointing to the CloudFront distribution

**Why DNS delegation matters:**
- ACM creates validation CNAME records in your Route 53 hosted zone
- For validation to succeed, public DNS queries must resolve to Route 53 (not CloudFlare, GoDaddy, etc.)
- If the domain is delegated elsewhere, ACM cannot see its own validation records and the certificate remains in `PENDING_VALIDATION` indefinitely

**Verify Route 53 is authoritative for your domain:**
```bash
# Check public DNS nameservers for your domain
dig +short NS yourdomain.com

# Get your Route 53 nameservers
aws route53 get-hosted-zone --id YOUR_HOSTED_ZONE_ID \
--query "DelegationSet.NameServers" --output table

# These should match!
```

**If they don't match: this will not work!**
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.

## Configuration Options

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

```bash
aws cloudformation deploy \
--template-file templates/cloudfront.yaml \
--template-file infrastructure/templates/cloudfront.yaml \
--stack-name ld-cloudfront-proxy \
--parameter-overrides \
UseCustomDomain=true \
DomainName=flags.my-company-domain.com \
PriceClass=PriceClass_100 \
--capabilities CAPABILITY_IAM
--capabilities CAPABILITY_IAM \
--region us-east-1
```

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

aws cloudformation deploy \
--template-file templates/cloudfront.yaml \
--template-file infrastructure/templates/cloudfront.yaml \
--stack-name ld-cloudfront-proxy \
--parameter-overrides \
UseCustomDomain=false \
PriceClass=PriceClass_100 \
EnableLogging=false
EnableLogging=false \
--region us-east-1
```

### Get Your Proxy URL
Expand Down Expand Up @@ -160,16 +198,18 @@ NOTE: You may need to restart your application.
### Automated Cleanup (Recommended)
```bash
aws cloudformation deploy \
--template-file templates/remove-cloudfront.yaml \
--template-file infrastructure/templates/remove-cloudfront.yaml \
--stack-name cleanup-ld-cloudfront \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides StackNameToDelete=ld-cloudfront-proxy
--parameter-overrides \
StackNameToDelete=ld-cloudfront-proxy \
DomainName=flags.my-company-domain.com \
CleanupDNS=true \
CleanupCertificate=true \
--region us-east-1
```

### Manual Cleanup
```bash
aws cloudformation delete-stack --stack-name ld-cloudfront-proxy
```


**Deletion time:** ~15-20 minutes (CloudFront global propagation)

Expand Down
56 changes: 26 additions & 30 deletions infrastructure/templates/cloudfront.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ Conditions:
UseLogging: !Equals [!Ref EnableLogging, 'true']

Resources:
# Lambda function to look up Route 53 hosted zone ID
HostedZoneLookupRole:
Type: AWS::IAM::Role
Condition: UseCustomDomain
Expand Down Expand Up @@ -72,43 +71,43 @@ Resources:
ZipFile: |
import boto3
import cfnresponse
import json

def handler(event, context):
try:
if event['RequestType'] == 'Delete':
cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
return

domain_name = event['ResourceProperties']['DomainName']
domain_name = event['ResourceProperties']['DomainName'].rstrip('.')
r53 = boto3.client('route53')

# Extract the root domain from subdomain
# e.g., flags.example.com -> example.com
domain_parts = domain_name.split('.')
if len(domain_parts) >= 2:
root_domain = '.'.join(domain_parts[-2:]) + '.'
else:
root_domain = domain_name + '.'
labels = domain_name.split('.')
candidates = ['.'.join(labels[i:]) + '.' for i in range(len(labels)-1)]

route53 = boto3.client('route53')
chosen = None
paginator = r53.get_paginator('list_hosted_zones')
zones = []
for page in paginator.paginate():
zones.extend(page['HostedZones'])

# List all hosted zones
paginator = route53.get_paginator('list_hosted_zones')
public_zones = [z for z in zones if not z.get('Config', {}).get('PrivateZone', False)]

for page in paginator.paginate():
for zone in page['HostedZones']:
if zone['Name'] == root_domain:
zone_id = zone['Id'].replace('/hostedzone/', '')
cfnresponse.send(event, context, cfnresponse.SUCCESS, {
'HostedZoneId': zone_id,
'RootDomain': root_domain
})
return
for cand in candidates:
for z in public_zones:
if z['Name'] == cand:
chosen = z
break
if chosen:
break

# If no hosted zone found
cfnresponse.send(event, context, cfnresponse.FAILED, {},
f"No Route 53 hosted zone found for domain: {root_domain}")
if not chosen:
raise Exception(f"No matching PUBLIC hosted zone found for {domain_name} (candidates: {candidates})")

zone_id = chosen['Id'].split('/')[-1]
cfnresponse.send(event, context, cfnresponse.SUCCESS, {
'HostedZoneId': zone_id,
'MatchedZoneName': chosen['Name']
})
except Exception as e:
print(f"Error: {str(e)}")
cfnresponse.send(event, context, cfnresponse.FAILED, {}, str(e))
Expand All @@ -133,6 +132,7 @@ Resources:
Tags:
- Key: Name
Value: !Sub '${AWS::StackName}-certificate'
DependsOn: HostedZoneLookup

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

# CloudFront Origin Request Policy
CfOriginRequestPolicy:
Type: AWS::CloudFront::OriginRequestPolicy
Properties:
Expand Down Expand Up @@ -187,7 +186,6 @@ Resources:
QueryStringsConfig:
QueryStringBehavior: "all"

# CloudFront Cache Policy - No Cache for Streaming
CfNoCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
Expand All @@ -204,7 +202,6 @@ Resources:
HeadersConfig: { HeaderBehavior: "none" }
QueryStringsConfig: { QueryStringBehavior: "all" }

# CloudFront Distribution
CfDistribution:
Type: AWS::CloudFront::Distribution
Properties:
Expand Down Expand Up @@ -347,7 +344,6 @@ Resources:
Prefix: cloudfront-logs/
- !Ref AWS::NoValue

# Automatic DNS Record Creation
DNSRecord:
Type: AWS::Route53::RecordSet
Condition: UseCustomDomain
Expand All @@ -357,7 +353,7 @@ Resources:
Type: A
AliasTarget:
DNSName: !GetAtt CfDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # CloudFront hosted zone ID
HostedZoneId: Z2FDTNDATAQYW2 # AWS's hardcoded global Hosted Zone ID for ALL CloudFront distributions
EvaluateTargetHealth: false

Outputs:
Expand Down
35 changes: 18 additions & 17 deletions infrastructure/templates/remove-cloudfront.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ Conditions:
ShouldCleanupCertificate: !And [!Condition HasDomainName, !Equals [!Ref CleanupCertificate, 'true']]

Resources:
# IAM Role for the cleanup Lambda function
CleanupLambdaRole:
Type: AWS::IAM::Role
Properties:
Expand Down Expand Up @@ -79,7 +78,6 @@ Resources:
- acm:DeleteCertificate
Resource: '*'

# Lambda function that deletes the CloudFront stack
CleanupLambdaFunction:
Type: AWS::Lambda::Function
Properties:
Expand All @@ -99,7 +97,6 @@ Resources:
"""Delete Route 53 A records and ACM validation CNAME records for the domain"""
route53 = boto3.client('route53')

# Find hosted zone for domain
root_domain = '.'.join(domain_name.split('.')[-2:]) + '.'
print(f"Looking for hosted zone: {root_domain}")

Expand All @@ -118,7 +115,6 @@ Resources:
print(f"No hosted zone found for {root_domain}")
return

# List ALL records in hosted zone (handle pagination)
paginator = route53.get_paginator('list_resource_record_sets')
all_records = []

Expand All @@ -129,24 +125,32 @@ Resources:

records_to_delete = []

# Find records to delete
for record in all_records:
record_name = record['Name'].rstrip('.')
print(f"Checking record: {record_name} (type: {record['Type']})")
record_type = record['Type']
print(f"Checking record: {record_name} (type: {record_type})")

# Delete A record for our domain
if record_name == domain_name and record['Type'] == 'A':
if record_name == domain_name and record_type == 'A':
records_to_delete.append(('A', record))
print(f"Found A record to delete: {domain_name}")

# Delete ACM validation CNAME records (start with _ and contain our domain)
elif record['Type'] == 'CNAME' and record_name.startswith('_') and domain_name in record_name:
records_to_delete.append(('CNAME', record))
print(f"Found ACM validation CNAME to delete: {record_name}")
elif record_type == 'CNAME' and record_name.startswith('_'):
is_acm_validation = False

if domain_name in record_name:
is_acm_validation = True
elif record.get('ResourceRecords'):
for rr in record['ResourceRecords']:
if '.acm-validations.aws.' in rr.get('Value', ''):
is_acm_validation = True
break

if is_acm_validation:
records_to_delete.append(('CNAME', record))
print(f"Found ACM validation CNAME to delete: {record_name}")

print(f"Total records to delete: {len(records_to_delete)}")

# Delete records in batch if any found
if records_to_delete:
changes = []
for record_type, record in records_to_delete:
Expand All @@ -170,9 +174,8 @@ Resources:

def cleanup_acm_certificates(domain_name):
"""Delete ACM certificates for the domain"""
acm = boto3.client('acm', region_name='us-east-1') # Certificates for CloudFront must be in us-east-1
acm = boto3.client('acm', region_name='us-east-1') # CloudFront requires certificates in us-east-1

# List all certificates
paginator = acm.get_paginator('list_certificates')

for page in paginator.paginate():
Expand Down Expand Up @@ -248,14 +251,12 @@ Resources:
cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "Cleanup completed"})

else:
# Update case
cfnresponse.send(event, context, cfnresponse.SUCCESS, {"Message": "No action required"})

except Exception as e:
print(f"Error: {str(e)}")
cfnresponse.send(event, context, cfnresponse.FAILED, {"Message": str(e)})

# Custom resource that triggers the Lambda function
TriggerCleanup:
Type: AWS::CloudFormation::CustomResource
Properties:
Expand Down