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
- You own a public domain (or subdomain) and want the A records under version control — apex,
www,api,*— rather than living only in the portal. - You are standing up a static site, an App Gateway, a public load balancer, or a VM and need to point a friendly hostname at its public IP as part of the same Terraform run.
- You run multiple environments or brands (
dev.kloudvin.com,kloudvin.com,kloudvin.in) and want one module instantiated per zone with identical guardrails. - You need the name-server list as an output to feed a registrar-delegation step, a runbook, or a parent zone’s
NSdelegation record.
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 config — live/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 config — live/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
- Delegate, then verify. After the first apply, copy the
name_serversoutput to your registrar’s NS records and confirm withdig NS kloudvin.com @8.8.8.8. The zone resolves nothing publicly until delegation is in place — Azure does not touch your registrar. - Keep apex TTL low during cutovers, raise it after. Use a short TTL (e.g. 300s) on the
@andwwwrecords while a migration is in flight so rollbacks propagate fast, then bump to 3600s+ once stable to cut query volume (and the per-million-query cost). - Prefer alias A records over hard-coded IPs for Azure targets. When the front end is an Azure Public IP, Traffic Manager, or Front Door, set
target_resource_idinstead ofrecords— the record follows the resource if its IP changes and survives recreation, eliminating a class of stale-record incidents. - Lock the zone with management locks and RBAC. Apply a
CanNotDeletelock to the zone resource group and scope DNS Zone Contributor narrowly; an accidental zone deletion takes the whole domain offline and the name-server values are reassigned on recreation, forcing a fresh registrar update. - Treat wildcards as deliberate, not convenient. A
*record answers for every undefined host, which can mask typos and widen phishing surface — scope wildcards to a dedicated subdomain like*.preview.kloudvin.comrather than the apex zone. - Tag for ownership and cost. Azure bills per hosted zone per month; tag every zone with
ownerandenvironmentso the per-zone line items in Cost Management map back to a team and unused zones get cleaned up.