Managing AWS Route 53 with CloudFormation
In which I delegate DNS from Gandi to AWS Route 53, and learn how to configure Route 53 with CloudFormation & Sceptre.
In my previous post I outlined my plan for adding SSL to this blog. This post is me exploring the first step in that plan: managing DNS in AWS Route 53 via CloudFormation.
Gandi has been great for the most part but using their DNS configuration is my least favourite part of interacting with their site. Route 53 is a lot less frustrating, but I've got the exact same base DNS setup I want to replicate across at least three domains that I own and I don't want to do it all manually. Also, I want to learn more about CloudFormation.
Table of Contents
List of Listings
I started this journey by reading through the CloudFormation User Guide. It is alright at explaining how the various bits in the templates fit together, but I feel there's a bit missing with regards to application. They are very firm in their recommendation that you should put your templates in revision control, but they don't really show any techniques for how you would go about that. They mostly use the AWS console for interacting with CloudFormation which I do not want to do.
Enter Sceptre, an unopinionated tool to drive CloudFormation. Its Getting Started Guide starts out by explaining the directory layout and config files involved, and is a good companion to the CloudFormation docs in my opinion.
Creating a Route 53 HostedZone with CloudFormation
To manage Route 53 records with CloudFormation you need to use a
AWS::Route53::RecordSet. One of its required properties is a
HostedZoneName) property—so I started there. Turns
out creating a zone is very easy. My complete template (stored in
templates/dns-zone.yaml) looks like this:
AWSTemplateFormatVersion: "2010-09-09" Parameters: DomainName: Type: String Default: example.net Resources: DNS: Type: "AWS::Route53::HostedZone" Properties: Name: !Ref DomainName HostedZoneConfig: Comment: !Join - " " - ["My hosted zone for", !Ref DomainName]
I set up a single required parameter, the domain name we want to
create a zone for, and create a single resource of type. The only
thing that caused me a bit of trouble here was getting the
syntax right for the comment.
With a template in place we need an environment & stack config so we
can create a stack. Normally an "environment" is something like "dev",
"test", "prod" but I won't bother with that for my blog so I'll use
"superloopy" for the environment name for this blog here at
superloopy.io. I create a file called
that looks like this:
template_path: templates/dns-zone.yaml parameters: DomainName: superloopy.io
Now we're ready to run Sceptre! Note: I don't worry about breaking my blog at this point because I haven't yet told Gandi to use AWS' name servers. I execute sceptre from the top-level directory like so:
sceptre create-stack superloopy dns-zone
Here's the result of running that command, with timestamps & common prefixes removed:
Creating stack sb-superloopy-dns-zone AWS::CloudFormation::Stack CREATE_IN_PROGRESS User Initiated DNS AWS::Route53::HostedZone CREATE_IN_PROGRESS DNS AWS::Route53::HostedZone CREATE_IN_PROGRESS Resource creation Initiated DNS AWS::Route53::HostedZone CREATE_COMPLETE sb-superloopy-dns-zone AWS::CloudFormation::Stack CREATE_COMPLETE
Success! Next I thought I'd tackle my MX setup.
Setting up Route 53 MX records with CloudFormation
I use Gandi's MXs and see no need to end that now. There's a primary
and a secondary, with different priorities and domain names. After a
bit of experimentation I ended up with
templates/mx.yaml looking like
AWSTemplateFormatVersion: "2010-09-09" Parameters: DomainName: Type: String Default: example.net PrimaryMx: Type: String Default: spool.mail.gandi.net SecondaryMx: Type: String Default: fb.mail.gandi.net TTL: Type: Number Default: 600 Resources: MxRecordSet: Type: AWS::Route53::RecordSet Properties: Name: !Ref DomainName HostedZoneName: !Join - "" - [!Ref DomainName, "."] Type: MX TTL: !Ref TTL ResourceRecords: - !Join - "" - [10, " ", !Ref PrimaryMx, "."] - !Join - "" - [50, " ", !Ref SecondaryMx, "."]
I'm not terribly happy with the hardcoded priorities for the MX servers, nor with the limitation that the template only supports two MXs. (Nor, indeed, that it requires two MXs.) But—it will suffice for now.
When it comes to the stack config the default MXs are fine for my
domain, so all we need to set in the stack config is the
template_path: templates/mx.yaml parameters: DomainName: superloopy.io
Standing up that stack looks like this (minus the timestamps etc):
superloopy/mx - Creating stack sb-superloopy-mx AWS::CloudFormation::Stack CREATE_IN_PROGRESS User Initiated MxRecordSet AWS::Route53::RecordSet CREATE_IN_PROGRESS MxRecordSet AWS::Route53::RecordSet CREATE_IN_PROGRESS Resource creation Initiated MxRecordSet AWS::Route53::RecordSet CREATE_COMPLETE sb-superloopy-mx AWS::CloudFormation::Stack CREATE_COMPLETE
If I ask one of the AWS nameservers listed in my zone, I can see that the MX record looks alright. I have to add the address of the NS to query part to explicitly ask one of the AWS nameserves as I have not yet delegated the zone to AWS.
dig @ns-1681.awsdns-18.co.uk -t mx superloopy.io +short
10 spool.mail.gandi.net. 50 fb.mail.gandi.net.
At this point I started having second thoughts about my approach. I originally had in mind setting up just the zone in one stack, and creating the mx entries as another stack, and the Apex forwarding with its own DNS Setup in a separate stack, and finally the www bucket with the content with its own DNS setup in yet another stack.
However, I think I got it the wrong way around. I now feel that all
the DNS setup should be in one stack. I combined my
templates into a single template and added handling of the Apex and
WWW records to it. The resulting template is in
its contents is:
1: AWSTemplateFormatVersion: "2010-09-09" 2: Parameters: 3: DomainName: 4: Type: String 5: Default: example.net 6: TTL: 7: Type: Number 8: Default: 600 9: MxRecords: 10: Type: CommaDelimitedList 11: Description: >- 12: A comma-separated list of entries for MX servers. Each entry 13: should have a priority and domain name, separated by a space. 14: Default: 10 spool.mail.gandi.net,50 fb.mail.gandi.net 15: ApexRecords: 16: Type: CommaDelimitedList 17: Description: >- 18: The default here is for GitHub Pages, cf 19: https://help.github.com/articles/setting-up-an-apex-domain/ 20: Default: 18.104.22.168,22.214.171.124 21: WwwRecord: 22: Type: String 23: Description: >- 24: Set up www.example.net as CNAME for this address 25: Default: stig.github.io 26: Resources: 27: Zone: 28: Type: 'AWS::Route53::HostedZone' 29: Properties: 30: Name: !Ref DomainName 31: HostedZoneConfig: 32: Comment: !Join 33: - " " 34: - ["My hosted zone for", !Ref DomainName] 35: MxRecordSet: 36: Type: 'AWS::Route53::RecordSet' 37: Properties: 38: Name: !Ref DomainName 39: HostedZoneId: !Ref Zone 40: Type: MX 41: TTL: !Ref TTL 42: ResourceRecords: !Ref MxRecords 43: ApexRecordSet: 44: Type: 'AWS::Route53::RecordSet' 45: Properties: 46: Name: !Ref DomainName 47: HostedZoneId: !Ref Zone 48: Type: A 49: TTL: !Ref TTL 50: ResourceRecords: !Ref ApexRecords 51: WwwRecordSet: 52: Type: 'AWS::Route53::RecordSet' 53: Properties: 54: Name: !Join 55: - "" 56: - ['www.', !Ref DomainName, '.'] 57: HostedZoneId: !Ref Zone 58: Type: CNAME 59: TTL: !Ref TTL 60: ResourceRecords: 61: - !Ref WwwRecord
As you can see on lines 10 and 42 I also switched to using a
CommaDelimitedList for the MX records1. The result is no
more hard-code priorities in the template, and I can support any
number of MX records.
Furthermore, now that the zone resource is in the same template I also
switched to using
HostedZoneId on line 39 rather than looking
up the zone by name. It just seems more robust, somehow.
Finally we just need a new
config/superloopy/dns.yaml file to drive
it, and since the defaults are mostly OK it just needs to set the
template path and DomainName:
template_path: templates/dns.yaml parameters: DomainName: superloopy.io
So, that's it for this post. I've learnt how to set up a Route 53 zone with Sceptre/CloudFormation and I'm pretty happy with it. I haven't actually delegated DNS to this zone yet, as I want to give myself a chance to experiment a bit more figuring out how to change this DNS zone to refer to CloudFront distributions while I learn how to set up the redirections for the Apex domain and hosting www from an S3 bucket behind CloudFront.