Quick take — A production-ready Terraform module for hashicorp/azurerm ~> 4.0 that provisions an Azure Private DNS Zone, virtual network links with optional auto-registration, and seed A records — with validation and clean outputs. 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 "private_dns_zone" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-dns-zone?ref=v1.0.0"
zone_name = "..." # FQDN of the Private DNS Zone (e.g. `privatelink.blob.co…
resource_group_name = "..." # Resource group that holds the zone, links, and records.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure Private DNS Zone is a managed DNS zone whose records are only resolvable from inside the virtual networks you link to it — never from the public internet. It lets you run a private namespace such as internal.kloudvin.com or one of the Microsoft Private Link zones (privatelink.blob.core.windows.net, privatelink.database.windows.net, and friends) so that Private Endpoints resolve to their private IPs instead of public ones. Unlike public DNS, there are no name servers to delegate; resolution happens through Azure’s platform resolver (168.63.129.16) for every VNet that has a virtual network link.
The reason to wrap it in a module is that a zone on its own does nothing. To be useful it needs at least one azurerm_private_dns_zone_virtual_network_link, and in production you almost always need a consistent decision about auto-registration (Azure writes an A record for every VM NIC in linked VNets) plus a handful of seed records. Hand-rolling those three resources per zone, per environment, leads to drift — one VNet linked with registration on where it should be off, a missing link that silently breaks Private Link resolution. This module makes the zone, its links, and optional A records a single var-driven unit with sane defaults and guardrails.
When to use it
- You are deploying Private Endpoints and need the matching
privatelink.*zone linked to the hub (and spoke) VNets so connections resolve privately. - You want a custom internal namespace (e.g.
corp.internal) shared across several spoke VNets in a hub-and-spoke topology. - You need auto-registration for IaaS VMs so their hostnames resolve without manually maintaining records.
- You are centralizing DNS in a connectivity/hub subscription and want one repeatable module that platform teams call for every zone they onboard.
Reach for a different approach when you need conditional forwarding, split-horizon to on-prem, or wildcard resolution across many zones — that is a job for Azure DNS Private Resolver rulesets, not a single zone resource.
Module structure
terraform-module-azure-private-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
resource "azurerm_private_dns_zone" "this" {
name = var.zone_name
resource_group_name = var.resource_group_name
tags = var.tags
}
# Link the zone to one or more virtual networks. Auto-registration can be
# toggled per link; only ONE link per zone may have registration enabled.
resource "azurerm_private_dns_zone_virtual_network_link" "this" {
for_each = var.virtual_network_links
name = each.key
resource_group_name = var.resource_group_name
private_dns_zone_name = azurerm_private_dns_zone.this.name
virtual_network_id = each.value.virtual_network_id
registration_enabled = each.value.registration_enabled
resolution_policy = each.value.resolution_policy
tags = var.tags
}
# Optional seed A records (e.g. point an app name at a Private Endpoint IP).
resource "azurerm_private_dns_a_record" "this" {
for_each = var.a_records
name = each.key
zone_name = azurerm_private_dns_zone.this.name
resource_group_name = var.resource_group_name
ttl = each.value.ttl
records = each.value.records
tags = var.tags
}
# variables.tf
variable "zone_name" {
description = "FQDN of the Private DNS Zone, e.g. 'privatelink.blob.core.windows.net' or 'corp.internal'."
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 multi-label DNS FQDN (at least one dot), e.g. 'corp.internal'."
}
}
variable "resource_group_name" {
description = "Name of the resource group that will hold the zone, its links, and records."
type = string
}
variable "virtual_network_links" {
description = <<-EOT
Map of virtual network links keyed by link name. Each entry binds the zone
to a VNet. Set registration_enabled = true on at most ONE link to let Azure
auto-register VM A records into the zone.
EOT
type = map(object({
virtual_network_id = string
registration_enabled = optional(bool, false)
resolution_policy = optional(string, "Default")
}))
default = {}
validation {
condition = length([for l in values(var.virtual_network_links) : l if l.registration_enabled]) <= 1
error_message = "At most one virtual network link per zone may have registration_enabled = true."
}
validation {
condition = alltrue([
for l in values(var.virtual_network_links) :
contains(["Default", "NxDomainRedirect"], l.resolution_policy)
])
error_message = "resolution_policy must be either 'Default' or 'NxDomainRedirect'."
}
}
variable "a_records" {
description = <<-EOT
Optional A records to seed into the zone, keyed by record name (use '@' for
the apex). 'records' is a list of IPv4 addresses; 'ttl' is in seconds.
EOT
type = map(object({
records = list(string)
ttl = optional(number, 300)
}))
default = {}
validation {
condition = alltrue([
for r in values(var.a_records) : r.ttl >= 1 && r.ttl <= 2147483647
])
error_message = "Each A record ttl must be between 1 and 2147483647 seconds."
}
validation {
condition = alltrue([
for r in values(var.a_records) : alltrue([
for ip in r.records :
can(regex("^(\\d{1,3}\\.){3}\\d{1,3}$", ip))
])
])
error_message = "Every entry in a_records[*].records must be a valid IPv4 address."
}
}
variable "tags" {
description = "Tags applied to the zone, links, and records."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the Private DNS Zone."
value = azurerm_private_dns_zone.this.id
}
output "name" {
description = "FQDN / name of the Private DNS Zone."
value = azurerm_private_dns_zone.this.name
}
output "number_of_record_sets" {
description = "Current count of record sets in the zone (includes auto-registered records)."
value = azurerm_private_dns_zone.this.number_of_record_sets
}
output "virtual_network_link_ids" {
description = "Map of link name => virtual network link resource ID."
value = { for k, v in azurerm_private_dns_zone_virtual_network_link.this : k => v.id }
}
output "a_record_fqdns" {
description = "Map of A record name => fully qualified domain name."
value = { for k, v in azurerm_private_dns_a_record.this : k => v.fqdn }
}
How to use it
This example provisions the Private Link zone for Blob storage, links it to a hub VNet, and seeds an apex-less record. A downstream Private Endpoint then registers into the same zone via a private_dns_zone_group.
module "private_dns_zone" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-dns-zone?ref=v1.0.0"
zone_name = "privatelink.blob.core.windows.net"
resource_group_name = azurerm_resource_group.connectivity.name
virtual_network_links = {
hub = {
virtual_network_id = azurerm_virtual_network.hub.id
registration_enabled = false # Private Link zones never auto-register
}
spoke_app = {
virtual_network_id = azurerm_virtual_network.spoke_app.id
registration_enabled = false
}
}
tags = {
environment = "prod"
owner = "platform-team"
}
}
# Downstream: a Storage Private Endpoint that lands its A record in the zone
resource "azurerm_private_endpoint" "blob" {
name = "pe-storage-blob"
location = azurerm_resource_group.connectivity.location
resource_group_name = azurerm_resource_group.connectivity.name
subnet_id = azurerm_subnet.endpoints.id
private_service_connection {
name = "psc-blob"
private_connection_resource_id = azurerm_storage_account.data.id
subresource_names = ["blob"]
is_manual_connection = false
}
private_dns_zone_group {
name = "blob-dns"
# Reference the module's output so the PE registers into THIS zone
private_dns_zone_ids = [module.private_dns_zone.id]
}
}
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/private_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-private-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/private_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 | FQDN of the Private DNS Zone (e.g. privatelink.blob.core.windows.net or corp.internal). Validated as a multi-label DNS name. |
resource_group_name |
string |
— | Yes | Resource group that holds the zone, links, and records. |
virtual_network_links |
map(object({ virtual_network_id = string, registration_enabled = optional(bool, false), resolution_policy = optional(string, "Default") })) |
{} |
No | VNet links keyed by name. At most one may set registration_enabled = true. |
a_records |
map(object({ records = list(string), ttl = optional(number, 300) })) |
{} |
No | Seed A records keyed by record name (@ for apex). IPv4 and TTL are validated. |
tags |
map(string) |
{} |
No | Tags applied to the zone, links, and records. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Private DNS Zone (pass to private_dns_zone_group on Private Endpoints). |
name |
FQDN / name of the zone. |
number_of_record_sets |
Current count of record sets, including auto-registered VM records. |
virtual_network_link_ids |
Map of link name to virtual network link resource ID. |
a_record_fqdns |
Map of A record name to its fully qualified domain name. |
Enterprise scenario
A retail bank runs a hub-and-spoke landing zone where all Private Endpoints terminate in a central connectivity subscription. The platform team calls this module once per Microsoft Private Link namespace (privatelink.database.windows.net, privatelink.vaultcore.azure.net, privatelink.blob.core.windows.net), each linked to the hub and every workload spoke with registration_enabled = false. Spoke teams never touch DNS; they simply attach their Private Endpoints to the shared zone IDs surfaced as module outputs, so a new SQL Managed Instance or Key Vault resolves to its private IP across the whole estate within minutes of terraform apply.
Best practices
- Never enable auto-registration on
privatelink.*zones. Those zones are populated by Private Endpoint DNS zone groups, not VMs; turning on registration there only adds noise and the module’s validation caps you at one registration link per zone anyway. - Use the exact Microsoft-defined zone FQDN for Private Link (e.g.
privatelink.blob.core.windows.net, region-specific for some services). A typo means the Private Endpoint’s auto-created record lands in the wrong zone and resolution silently falls back to the public IP. - Centralize zones in the hub/connectivity subscription and link spokes to them, rather than creating duplicate zones per spoke — this avoids conflicting answers and keeps record sets in one auditable place.
- Cost is effectively the record sets, not the zone. Auto-registration in large IaaS estates can grow
number_of_record_sets(and the per-million-query and per-record charges) quickly; keep registration scoped to the one VNet that genuinely needs it. - Keep TTLs modest (300s here) for records backing failover targets so a re-pointed Private Endpoint or VM IP propagates fast; raise TTL only for truly static internal records.
- Tag every zone with an owner and environment and manage links through
var.virtual_network_linksso adding or removing a spoke is a reviewed diff, not a click in the portal that drifts from state.