Before christmas holidays set in we had a feature request in my work life where owners of a landing zone in Azure wanted access to manage records in public dns. After a bit of thinking, discussions with the team posting the request, and understanding their use case better we discovered that what they really wanted was to not deal with the existing process of manually ordering/renewing a publicly trusted certificate, store it in a key vault, and then forget about it until it expired one year later. Instead they wanted to automate the process with Let's Encrypt.
In the end we settled on a design where the subscription vending has a feature flag for enabling a DNS child zone in the new subscription and create the necessary delegations in the parent zone. This essentially gives a team a subdomain with its own DNS zone they have full control over, while also not creating any complicated role assignments to avoid granting too wide credentials and allow someone to modify records outside of their own scope.
To implement this pattern with terraform/opentofu we ran across a few challenges, so here is a short example (with notes) on how you can achieve the same thing
Setup intial resources
The very first thing we need to do is read the parent DNS zone and create a resource group and child dns zone:
data "azurerm_dns_zone" "parent_zone" {
name = "contoso.com"
resource_group_name = "rg-parent-dns-zone"
}
resource "azurerm_resource_group" "example" {
name = "rg-child-dns-zone"
location = "westeurope"
}
resource "azurerm_dns_zone" "child_zone" {
name = "example.contoso.com"
resource_group_name = azurerm_resource_group.example.name
} Configure the NS delegation
To configure our new Azure DNS Zone as a child of the parent we need to configure a nameserver (NS) record in the parent zone. In short, when someone types in gandalf.example.contoso.com in their browser or terminal the client will do something like this to figure out where the request should be sent:
- hey
.com, where can I findcontoso.com?
oh, you should go ask one of these (returns the NS records forcontoso.com) - Hey nameserver for
contoso.com, where can I findexample?
oh, you should go ask one of these (returns the NS records forexample.contoso.com) - Hey nameserver for
example.contoso.com, where can I findgandalf?
And so it goes all the way until you get an IP-address (or more than 1) and the client knows where to send the request
In Terraform we add this new NS record to the parent dns zone like this:
resource "azurerm_dns_ns_record" "dns_delegation" {
name = replace(azurerm_dns_zone.child_zone.name, ".${data.azurerm_dns_zone.parent.name}","")
zone_name = data.azurerm_dns_zone.parent.name
resource_group_name = data.azurerm_dns_zone.parent.resource_group_name
ttl = 3600
records = azurerm_dns_zone.child_zone.name_servers
} One note here is that we want to name our Azure DNS Zone with the full FQDN, but the NS record should only use the relevant subdomain part. We achieve this with a simple replace() to just remove the parent dns zone name. e.g. the result of replace("example.contoso.com", "contoso.com", "") will be example)
Create a DNSSEC configuration delegation
The second part we want to add is a DNSSEC configuration. The very simplified explanation is that we want to add a layer of trust to tell clients that yes, this is a legit DNS server for example.contoso.com and not some random malicious rascal trying to impersonate our domain name.
If you are unfamiliar with the concept I would recommend two sources for learning:
To implement this trust we first need to add a DNSSEC configuration to the child zone, and then use that configuration to add a DS record in the parent zone.
The bad news is that neither of these steps are supported in Terraform's azurerm provider. The good news is that we can use the azapi provider instead:
resource "azapi_resource" "dnssec_config" {
type = "Microsoft.Network/dnsZones/dnssecConfigs@2023-07-01-preview"
name = "default"
parent_id = azurerm_dns_zone.child_zone.id
}
resource "azapi_resource" "dnssec_delegation" {
type = "Microsoft.Network/dnsZones/DS@2023-07-01-preview"
name = replace(azurerm_dns_zone.child_zone.name, ".${data.azurerm_dns_zone.parent.name}","")
parent_id = data.azurerm_dns_zone.parent.id
body = {
properties = {
DSRecords = [
{
algorithm = azapi_resource.dnssec_config.output.properties.signingKeys[1].securityAlgorithmType
digest = {
algorithmType = azapi_resource.dnssec_config.output.properties.signingKeys[1].delegationSignerInfo[0].digestAlgorithmType
value = azapi_resource.dnssec_config.output.properties.signingKeys[1].delegationSignerInfo[0].digestValue
}
keyTag = azapi_resource.dnssec_config.output.properties.signingKeys[1].keyTag
}
]
TTL = 3600
}
}
} It is, as it often is when using azapi_resource a bit hard to read and understand, but when we put these parts together (and wait a bit for changes to propagate properly) we have a working configuration where each landing zone that need a self-managed public dns now has their own child zone under the company's domain. They can use this to automate certificates with dns validation of ownership, create all the records they want for the various services they run etc.
Notes
- As you probably want to do this in an Azure Landing Zones-architecture, it is likely that the parent and child dns zones will be in different Azure subscriptions. To solve this in Terraform you need to create provider aliases and be a bit careful when you navigate which blocks should query which alias
- Because you need both
azurermandazapiproviders in this brief example, you would end up with 4 different provider aliases if you need to work with multiple subscriptions. It would make sense to rewrite the code to only use theazapiprovider to simplify this - If your subscription vending is using the "old" modules, you might have a provider constraints that requires using
azapi ~> 1.0. The biggest difference between v1.x and v2.x is the requirement to usejsonencode()to encapsulate yourbodyin v1.x and using native terraform objects in v2.x.