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.

List of Listings

Discussion

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 HostedZone (or 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 !Join 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 config/superloopy/dns-zone.yaml 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 this:

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 DomainName:

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.

Great!

Combining the zone and mx stacks

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 dns-zone and mx templates into a single template and added handling of the Apex and WWW records to it. The resulting template is in templates/dns.yaml and 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: 192.30.252.153,192.30.252.154
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

Conclusion

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.

1
I learnt about them when researching how to best represent the ApexRecords.

Date: 19 July 2017

Author: Stig Brautaset

Validate