Managing Route53 DNS with Terraform and CDK in 100 LOC or less


Many organizations have a DNS strategy that spans across accounts. This can be difficult to represent in Terraform as there are circular dependencies between zones. Particularly when ACM and other DNS validation is involved. In this post, I will demonstrate an easy way to manage DNS using Terraform and CDK.

Terraform + CDK

Terraform is a tool for managing infrastructure. CDK is useful for dynamically generating infrastructure templates using programming languages. Typically, CDK uses CloudFormation under the hood. This means the code you write with CDK generates CloudFormation templates and deploys the resources using the CloudFormation API.

cdktf is a project started by Hashicorp that utilizes the CDK underpinnings but generates Terraform manifests instead. While Terraform has a lot of useful features that make it more flexible than CloudFormation. It is possible to run into some use cases that are difficult to map out in a decent way in Terraform. cdktf may step in to help these situations.

DNS with Route53

Many organizations utilize Route53 across a multitude of accounts. This is the case because most organizations have separated out their AWS accounts per environment.

A typical scenario is diagrammed below. The domain apex resides in the production account, while environment specific zones go in the environment account. Many times each of these accounts reside in their own Terraform plan. Since a zone in a dev account, needs NS records in the zone apex, a dependency is introduced between those two accounts. The problem is even further exacerbated when there is a need for DNS validation on some of the resources in the dev account. Now there is a circular dependency between the dev, qa, and prod plans.

Enter cdktf

This post proposes we take another path to building out DNS with IAC. Instead, we separate DNS into it’s own separate plan utilizing multiple providers for cross account access. This can be difficult to model out with traditional Terraform modules. It involves a lot of passing around of providers.

In order to make this easier, we use cdktf to build out and deploy our Terraform stacks.

First, we’ll start with a simple configuration file that contains only what we need to manage the Route53 zones:

  - alias: dev
    region: us-east-1
      - roleArn: 'arn:aws:iam::<account id>:role/<role name>'
  - alias: prod
      - roleArn: 'arn:aws:iam::<account id>:role/<role name>'
  - name:
    account: prod
    records: # example of adding records from here
      - name:
        type: A
        ttl: 300
    zones: # subdomains declared under root domain, nest indefinitely
      - name:
        account: dev
          - name:
            type: CNAME
            ttl: 300

Defining Accounts

As you can see, we create a list of accounts with the corresponding roles that Terraform will use to access them. Each of these creates an AWS Terraform provider. So, anything that can be used as an option for AWS Terraform Provider may be used in the nested structure.

The example is using an assume role block to switch to a role in the account. As such, the execution of cdktf will need to be authenticated with a user or role that has the ability to assume that role. You may also use profiles or any other method available in the provider to access the account.

Zone Management

We define our zones as a list. Again, any argument that is present on the Terraform Route53 resource may be used in this context. account is used to lookup which provider to use, which corresponds to the account that it will be located in.


Though many times, you may want records to be more dynamic in nature, there is the option to add records to the zone in the yaml file. This accepts a list of records and can contain any arguments provided by the Terraform Route53 record resource.


The zones attribute provides the ability to extend the zone into separate sub-zones. This means that the zone will be created in the account specified. In addition, NS records will be put into the parent zone that point to the hosted zone of the subdomain. Subdomains may be nested indefinitely.

Enter cdktf

Now that we have a clear representation of the mechanics of our DNS. We need to write some code to wire it up. First, I have defined a type that represents the configuration file in Typescript. This makes it a little easier to work with in Typescript.

import {
} from './.gen/providers/aws'

type ZoneConfiguration = Route53ZoneConfig & {
  account: string
  zones: ZoneConfiguration[]
  records: Route53RecordConfig[]

type Configuration = {
  accounts: AwsProviderConfig[]
  zones: ZoneConfiguration[]

Notice that I have reused the generated config stubs from cdktf. This is a very powerful feature that adds a ton of flexibility into our repository. The ZoneConfiguration object combines the cdktf Route53ZoneConfig with a custom type that gives us the additional properties: account, zones, and records.

Now we can get to the fun stuff.

config.accounts.forEach( providerConfig => {
  const id = providerConfig.alias || "default"
  this.awsProviders[id] = new AwsProvider(this, id, providerConfig)

// Configure hosted zones
config.zones.forEach((zoneConfig) => {
  this.createZone(null, zoneConfig)

This code, loops through the accounts structure and adds a provider for each account. In addition, we capture that provider and save it so we can look it up by id later.

Now that the providers are created, we loop through the zones and create a Route53 hosted zone for each zone definition. We pass null as the parent since we assume that the zone is an apex zone and NS will be managed for it some other way (at a different registrar, possibly).

  createZone(parent: Route53Zone | null, zoneConfig: ZoneConfiguration) {
  const id ='.', '_')

  // add defaults and provider to zone config
  zoneConfig = {
      provider: this.awsProviders[zoneConfig.account],
      zones: [],
      records: [],
  const zone = new Route53Zone(this, id, zoneConfig)

  // if there is a parent zone, add NS records to it that point to the current zone
  if(parent != null) {
    const recordId = `${}_${id}`
    new Route53Record(this, recordId, {
      provider: parent.provider,
      zoneId: parent.zoneId,
      type: "NS",
      ttl: 300,
      records: zone.nameServers,
  zoneConfig.records.forEach((r) => {
    const recordId = `${}_${'.', '_')}_${r.type}`
    const record = {
        provider: zone.provider,
        zoneId: zone.zoneId,
    new Route53Record(this, recordId, record)
  zoneConfig.zones.forEach((z) => this.createZone(zone, z))

The createZone method is a recursive function for creating nested zones. For each zone, we create the hosted zone resource. If a parent zone has been passed, we will add NS records pointing to the new zone to that parent. Then, we loop through and create the records that have been specified in the YAML definition. Lastly, we call create zone for each of the subdomains (if any).

Hopefully, this has been a helpful example of how to manage DNS with CDK and Terraform. CDK and Terraform is a powerful combination that can be used to really boost your ability to manage infrastructure at scale.

The full source code is available on github. I may extend this project to include ACM and SES setup as these are the two services that have caused me headaches in the past when dealing with organizations with a lot of accounts.

Kerry Wilson
AWS Certified IQ Expert | Cloud Architect

Coming from a development background, Kerry’s focus is on application development, infrastructure and security automation, and applying agile software development practices to IT operations in the cloud.