IaC Azure

Terraform Module: Azure Public DNS Zone — apex and host records as code, no portal drift

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for an Azure Public DNS Zone with A records, wildcards, and TTL control — manage apex, www, and host records as code instead of clicking through the portal. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "dns_zone" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-dns-zone?ref=v1.0.0"

  zone_name           = "..."  # Fully-qualified public zone name, e.g. `kloudvin.com`, …
  resource_group_name = "..."  # Resource group that holds the DNS zone.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure Public DNS Zone hosts the authoritative records for an internet-facing domain such as kloudvin.com. Once you delegate the domain to Azure (by pointing your registrar’s NS records at the four ns1-NN.azure-dns.com / .net / .org / .info name servers Azure assigns), every public resolver in the world asks your zone for the answer to www.kloudvin.com, api.kloudvin.com, and the apex (@) itself. The zone is a global, highly-available service billed per zone per month plus a tiny per-million-query charge — there is no region to choose.

The trouble starts the moment a human edits records in the portal. A wildcard gets added during an incident, a TTL gets bumped to debug a cutover, an old A record points at a decommissioned VM — and none of it is written down. This module wraps azurerm_dns_zone together with azurerm_dns_a_record so the zone and its A records (apex, host, and wildcard) are declared once, reviewed in a pull request, and reconciled by terraform plan. If someone hand-edits a record in the portal, the next plan shows the drift in red. You get one input map of record name to IP list, sensible TTL defaults, and outputs (zone ID, name, and the name-server list) that downstream modules and your registrar delegation actually need.

When to use it

Reach for a different tool when you need a Private DNS Zone (use azurerm_private_dns_zone — different resource, VNet-linked, not internet-resolvable), apex aliasing to an Azure resource (use azurerm_dns_a_record’s target_resource_id against a Public IP / Traffic Manager / Front Door), or record types beyond A such as CNAME, TXT, or MX (those are separate azurerm_dns_*_record resources you would add to a fuller module).

Module structure

terraform-module-azure-dns-zone/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

main.tf

# The authoritative public zone. There is no location — Azure DNS is a global service.
resource "azurerm_dns_zone" "this" {
  name                = var.zone_name
  resource_group_name = var.resource_group_name
  tags                = var.tags
}

# A records: apex (@), hosts (www, api), and wildcards (*).
# Driven by a single map so consumers add records without touching the module.
resource "azurerm_dns_a_record" "this" {
  for_each = var.a_records

  name                = each.key
  zone_name           = azurerm_dns_zone.this.name
  resource_group_name = var.resource_group_name

  # Per-record TTL falls back to the zone-wide default when omitted.
  ttl = coalesce(each.value.ttl, var.default_ttl)

  # Exactly one of records / target_resource_id is set (enforced by validation).
  records            = each.value.target_resource_id == null ? each.value.records : null
  target_resource_id = each.value.target_resource_id

  tags = var.tags
}

variables.tf

variable "zone_name" {
  description = "Fully-qualified public DNS zone name, e.g. kloudvin.com (no trailing dot)."
  type        = string

  validation {
    condition     = can(regex("^([a-zA-Z0-9_]([a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?\\.)+[a-zA-Z]{2,}$", var.zone_name))
    error_message = "zone_name must be a valid FQDN such as 'kloudvin.com' with no trailing dot."
  }
}

variable "resource_group_name" {
  description = "Resource group that holds the DNS zone."
  type        = string
}

variable "default_ttl" {
  description = "Default TTL (seconds) applied to any A record that does not set its own."
  type        = number
  default     = 3600

  validation {
    condition     = var.default_ttl >= 1 && var.default_ttl <= 2147483647
    error_message = "default_ttl must be between 1 and 2147483647 seconds."
  }
}

variable "a_records" {
  description = <<-EOT
    Map of A records keyed by record name. Use "@" for the zone apex and "*" for a wildcard.
    Set EITHER `records` (list of IPv4 addresses) OR `target_resource_id` (an Azure Public IP /
    Traffic Manager / Front Door resource ID for an alias record), never both. `ttl` is optional
    and falls back to default_ttl.
  EOT
  type = map(object({
    records            = optional(list(string))
    target_resource_id = optional(string)
    ttl                = optional(number)
  }))
  default = {}

  validation {
    condition = alltrue([
      for r in values(var.a_records) :
      (r.records != null) != (r.target_resource_id != null)
    ])
    error_message = "Each A record must set exactly one of `records` or `target_resource_id`."
  }

  validation {
    condition = alltrue([
      for r in values(var.a_records) : (
        r.records == null ? true : alltrue([
          for ip in r.records :
          can(regex("^((25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]?[0-9])$", ip))
        ])
      )
    ])
    error_message = "Every value in `records` must be a valid IPv4 address."
  }

  validation {
    condition = alltrue([
      for r in values(var.a_records) :
      r.ttl == null ? true : (r.ttl >= 1 && r.ttl <= 2147483647)
    ])
    error_message = "Per-record ttl, when set, must be between 1 and 2147483647 seconds."
  }
}

variable "tags" {
  description = "Tags applied to the zone and all A records."
  type        = map(string)
  default     = {}
}

outputs.tf

output "zone_id" {
  description = "Resource ID of the public DNS zone."
  value       = azurerm_dns_zone.this.id
}

output "zone_name" {
  description = "Name of the public DNS zone."
  value       = azurerm_dns_zone.this.name
}

output "name_servers" {
  description = "Azure-assigned authoritative name servers. Set these as NS records at your registrar to delegate the domain."
  value       = azurerm_dns_zone.this.name_servers
}

output "number_of_record_sets" {
  description = "Total record sets in the zone (includes the auto-created SOA/NS sets)."
  value       = azurerm_dns_zone.this.number_of_record_sets
}

output "a_record_fqdns" {
  description = "Map of A record name to its fully-qualified domain name."
  value       = { for k, r in azurerm_dns_a_record.this : k => r.fqdn }
}

How to use it

module "public_dns_zone" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-dns-zone?ref=v1.0.0"

  zone_name           = "kloudvin.com"
  resource_group_name = azurerm_resource_group.dns.name
  default_ttl         = 3600

  a_records = {
    # Apex pointing at a public load balancer IP.
    "@" = {
      records = ["20.50.120.10"]
      ttl     = 300
    }
    # www and api share the same front-end IP.
    "www" = { records = ["20.50.120.10"] }
    "api" = { records = ["20.50.120.11"] }
    # Wildcard for preview environments, longer TTL.
    "*" = {
      records = ["20.50.120.12"]
      ttl     = 7200
    }
  }

  tags = {
    environment = "prod"
    managed_by  = "terraform"
    owner       = "platform"
  }
}

# Downstream: feed the Azure name servers into a registrar-delegation module
# (or surface them so the domain owner can update NS records at the registrar).
module "registrar_delegation" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-registrar-delegation?ref=v1.2.0"

  domain       = module.public_dns_zone.zone_name
  name_servers = module.public_dns_zone.name_servers
}

# Or simply expose them at the root so they can be copied to the registrar once.
output "delegate_these_name_servers" {
  value = module.public_dns_zone.name_servers
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/dns_zone/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-dns-zone?ref=v1.0.0"
}

inputs = {
  zone_name = "..."
  resource_group_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/dns_zone && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
zone_name string Yes Fully-qualified public zone name, e.g. kloudvin.com, no trailing dot. Validated as an FQDN.
resource_group_name string Yes Resource group that holds the DNS zone.
default_ttl number 3600 No TTL (seconds) for any A record that omits its own ttl. Must be 1–2147483647.
a_records map(object({ records = optional(list(string)), target_resource_id = optional(string), ttl = optional(number) })) {} No A records keyed by name (@ = apex, * = wildcard). Set exactly one of records (IPv4 list) or target_resource_id (alias to an Azure resource).
tags map(string) {} No Tags applied to the zone and every A record.

Outputs

Name Description
zone_id Resource ID of the public DNS zone.
zone_name Name of the public DNS zone.
name_servers Azure-assigned authoritative name servers; set these as NS records at your registrar to delegate the domain.
number_of_record_sets Total record sets in the zone, including the auto-created SOA and NS sets.
a_record_fqdns Map of each A record name to its fully-qualified domain name.

Enterprise scenario

A retail platform team runs its public estate across shop.contoso.com (production), staging.shop.contoso.com, and a wildcard *.preview.shop.contoso.com for per-PR preview deployments. They instantiate this module once per zone in a delegated subscription, feed each environment’s App Gateway public IP into the a_records map, and pipe the name_servers output into their registrar-automation step so a new brand domain is delegated end-to-end from a single terraform apply. Because every A record is in Git, an auditor can answer “what does shop.contoso.com resolve to and who changed it last” from the pull-request history instead of the Azure activity log.

Best practices

TerraformAzurePublic DNS ZoneModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading