IaC Azure

Terraform Module: Azure Private DNS Zone — Private name resolution wired to your VNets

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

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 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/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

TerraformAzurePrivate 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