Quick take — Build a reusable Terraform module for Azure Private DNS Resolver on azurerm ~> 4.0 — inbound and outbound endpoints plus forwarding rulesets for hybrid and split-horizon DNS. 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_resolver" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-dns-resolver?ref=v1.0.0"
name = "..." # Resolver name; prefixes endpoint and ruleset names. 3-8…
resource_group_name = "..." # Resource group for the resolver, ruleset and rules.
location = "..." # Azure region; must match the bound VNet's region.
virtual_network_id = "..." # Full resource ID of the VNet the resolver binds to (usu…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Private DNS Resolver is a fully managed, PaaS replacement for the DNS forwarder VMs that teams have historically deployed in a hub VNet to bridge Azure Private DNS zones with on-premises DNS. Instead of running and patching a pair of BIND or Windows DNS servers, you provision a azurerm_private_dns_resolver resource bound to a VNet, then attach two kinds of endpoints:
- Inbound endpoints give on-premises resolvers a stable private IP in Azure to forward queries to, so a corporate DNS server can resolve
privatelink.blob.core.windows.netand other Azure Private DNS zone records. - Outbound endpoints let workloads in Azure forward specific DNS namespaces (for example
corp.contoso.com) back to on-premises DNS servers, driven by a DNS forwarding ruleset of one or more forwarding rules.
Each endpoint must live in its own dedicated, delegated subnet (Microsoft.Network/dnsResolvers), and that subnet rule is the single most common reason a hand-rolled deployment fails. Wrapping all of this in a reusable module pins the delegation, the ~> 4.0 provider, the resolver-to-VNet binding, the endpoints, and the ruleset-to-VNet links into one tested unit so every landing zone gets identical, conditional-by-flag hybrid DNS instead of click-ops that drifts across subscriptions.
When to use it
- You are retiring DNS forwarder VMs in a hub VNet and want a managed, zone-redundant resolver with no OS to patch.
- On-premises clients need to resolve Azure Private Endpoint records (Private Link FQDNs) hosted in Azure Private DNS zones over ExpressRoute or VPN.
- Azure workloads need conditional forwarding for one or more on-premises domains (Active Directory, legacy apps) without changing every VNet’s custom DNS server IPs.
- You operate a hub-and-spoke topology and want a single forwarding ruleset linked to many spoke VNets.
- You are standardizing landing zones and need hybrid DNS to be a toggle (
enable_inbound/enable_outbound) rather than bespoke infrastructure per environment.
Skip it if you only need name resolution inside Azure with no on-premises integration — auto-registration on a plain azurerm_private_dns_zone_virtual_network_link is cheaper and sufficient there.
Module structure
terraform-module-azure-private-dns-resolver/
├── 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 resolver itself — bound 1:1 to a VNet by ID.
resource "azurerm_private_dns_resolver" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
virtual_network_id = var.virtual_network_id
tags = var.tags
}
# Inbound endpoint: an IP in Azure that on-prem DNS forwards to.
# Requires a subnet delegated to Microsoft.Network/dnsResolvers.
resource "azurerm_private_dns_resolver_inbound_endpoint" "this" {
count = var.enable_inbound ? 1 : 0
name = "${var.name}-in"
private_dns_resolver_id = azurerm_private_dns_resolver.this.id
location = var.location
tags = var.tags
ip_configurations {
subnet_id = var.inbound_subnet_id
private_ip_allocation_method = var.inbound_static_ip == null ? "Dynamic" : "Static"
private_ip_address = var.inbound_static_ip
}
}
# Outbound endpoint: the egress point Azure workloads use to forward queries.
resource "azurerm_private_dns_resolver_outbound_endpoint" "this" {
count = var.enable_outbound ? 1 : 0
name = "${var.name}-out"
private_dns_resolver_id = azurerm_private_dns_resolver.this.id
location = var.location
subnet_id = var.outbound_subnet_id
tags = var.tags
}
# Forwarding ruleset — only meaningful with an outbound endpoint.
resource "azurerm_private_dns_resolver_dns_forwarding_ruleset" "this" {
count = var.enable_outbound ? 1 : 0
name = "${var.name}-ruleset"
resource_group_name = var.resource_group_name
location = var.location
private_dns_resolver_outbound_endpoint_ids = [azurerm_private_dns_resolver_outbound_endpoint.this[0].id]
tags = var.tags
}
# One forwarding rule per on-prem domain → target DNS servers.
resource "azurerm_private_dns_resolver_forwarding_rule" "this" {
for_each = var.enable_outbound ? var.forwarding_rules : {}
name = each.key
dns_forwarding_ruleset_id = azurerm_private_dns_resolver_dns_forwarding_ruleset.this[0].id
domain_name = each.value.domain_name
enabled = each.value.enabled
dynamic "target_dns_servers" {
for_each = each.value.target_dns_servers
content {
ip_address = target_dns_servers.value.ip_address
port = target_dns_servers.value.port
}
}
}
# Link the ruleset to one or more VNets (hub + spokes) so their
# workloads honour the forwarding rules.
resource "azurerm_private_dns_resolver_virtual_network_link" "this" {
for_each = var.enable_outbound ? var.ruleset_vnet_links : {}
name = each.key
dns_forwarding_ruleset_id = azurerm_private_dns_resolver_dns_forwarding_ruleset.this[0].id
virtual_network_id = each.value
}
# variables.tf
variable "name" {
type = string
description = "Name of the Private DNS Resolver; used as a prefix for endpoints and the ruleset."
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{1,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 3-80 chars, start alphanumeric, and contain only letters, numbers, '.', '-' or '_'."
}
}
variable "resource_group_name" {
type = string
description = "Resource group that will hold the resolver, ruleset and forwarding rules."
}
variable "location" {
type = string
description = "Azure region. Must support Private DNS Resolver and match the target VNet's region."
}
variable "virtual_network_id" {
type = string
description = "Resource ID of the VNet the resolver is bound to (typically the hub VNet)."
validation {
condition = can(regex("/virtualNetworks/", var.virtual_network_id))
error_message = "virtual_network_id must be a full VNet resource ID."
}
}
variable "enable_inbound" {
type = bool
description = "Create an inbound endpoint so on-premises DNS can resolve Azure private zones."
default = true
}
variable "inbound_subnet_id" {
type = string
description = "Subnet ID delegated to Microsoft.Network/dnsResolvers for the inbound endpoint. Required when enable_inbound is true."
default = null
}
variable "inbound_static_ip" {
type = string
description = "Optional static private IP for the inbound endpoint (must fall within the inbound subnet). Null = Dynamic allocation."
default = null
validation {
condition = var.inbound_static_ip == null || can(cidrhost("${var.inbound_static_ip}/32", 0))
error_message = "inbound_static_ip must be a valid IPv4 address or null."
}
}
variable "enable_outbound" {
type = bool
description = "Create an outbound endpoint + forwarding ruleset for conditional forwarding to on-premises domains."
default = true
}
variable "outbound_subnet_id" {
type = string
description = "Subnet ID delegated to Microsoft.Network/dnsResolvers for the outbound endpoint. Required when enable_outbound is true."
default = null
}
variable "forwarding_rules" {
type = map(object({
domain_name = string
enabled = optional(bool, true)
target_dns_servers = list(object({
ip_address = string
port = optional(number, 53)
}))
}))
description = "Map of forwarding rules keyed by rule name. domain_name must be FQDN with a trailing dot (e.g. 'corp.contoso.com.')."
default = {}
validation {
condition = alltrue([for r in values(var.forwarding_rules) : endswith(r.domain_name, ".")])
error_message = "Each forwarding rule domain_name must end with a trailing dot, e.g. 'corp.contoso.com.'."
}
validation {
condition = alltrue([for r in values(var.forwarding_rules) : length(r.target_dns_servers) > 0])
error_message = "Each forwarding rule must list at least one target DNS server."
}
}
variable "ruleset_vnet_links" {
type = map(string)
description = "Map of link name => VNet resource ID. Links the forwarding ruleset to each VNet (hub and spokes) that should honour the rules."
default = {}
}
variable "tags" {
type = map(string)
description = "Tags applied to all resources created by the module."
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the Private DNS Resolver."
value = azurerm_private_dns_resolver.this.id
}
output "name" {
description = "Name of the Private DNS Resolver."
value = azurerm_private_dns_resolver.this.name
}
output "inbound_endpoint_id" {
description = "Resource ID of the inbound endpoint (null when disabled)."
value = try(azurerm_private_dns_resolver_inbound_endpoint.this[0].id, null)
}
output "inbound_endpoint_ip" {
description = "Private IP of the inbound endpoint — point on-premises conditional forwarders here (null when disabled)."
value = try(azurerm_private_dns_resolver_inbound_endpoint.this[0].ip_configurations[0].private_ip_address, null)
}
output "outbound_endpoint_id" {
description = "Resource ID of the outbound endpoint (null when disabled)."
value = try(azurerm_private_dns_resolver_outbound_endpoint.this[0].id, null)
}
output "dns_forwarding_ruleset_id" {
description = "Resource ID of the DNS forwarding ruleset — reuse to attach additional VNet links (null when outbound disabled)."
value = try(azurerm_private_dns_resolver_dns_forwarding_ruleset.this[0].id, null)
}
How to use it
module "private_dns_resolver" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-private-dns-resolver?ref=v1.0.0"
name = "kv-hub-dnsr"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
virtual_network_id = azurerm_virtual_network.hub.id
# Inbound: on-prem forwards Azure private-zone lookups to this IP.
enable_inbound = true
inbound_subnet_id = azurerm_subnet.dnsr_inbound.id
inbound_static_ip = "10.10.4.4"
# Outbound: Azure forwards these domains back to on-prem DNS.
enable_outbound = true
outbound_subnet_id = azurerm_subnet.dnsr_outbound.id
forwarding_rules = {
"corp-contoso" = {
domain_name = "corp.contoso.com."
target_dns_servers = [
{ ip_address = "192.168.1.10" },
{ ip_address = "192.168.1.11" },
]
}
"legacy-internal" = {
domain_name = "internal.contoso.local."
target_dns_servers = [
{ ip_address = "192.168.1.10" },
]
}
}
# Link the ruleset to the hub and every spoke that needs the rules.
ruleset_vnet_links = {
"hub" = azurerm_virtual_network.hub.id
"spoke-app" = azurerm_virtual_network.spoke_app.id
}
tags = {
environment = "production"
workload = "platform-dns"
}
}
# Downstream: feed the inbound endpoint IP to the firewall's DNS proxy
# config (or to documentation/automation for the on-prem team).
resource "azurerm_firewall_policy" "hub" {
name = "kv-hub-fwpolicy"
resource_group_name = azurerm_resource_group.hub.name
location = "centralindia"
dns {
proxy_enabled = true
servers = [module.private_dns_resolver.inbound_endpoint_ip]
}
}
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_resolver/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-resolver?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
virtual_network_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/private_dns_resolver && 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 |
|---|---|---|---|---|
name |
string |
— | Yes | Resolver name; prefixes endpoint and ruleset names. 3-80 chars, alphanumeric start. |
resource_group_name |
string |
— | Yes | Resource group for the resolver, ruleset and rules. |
location |
string |
— | Yes | Azure region; must match the bound VNet’s region. |
virtual_network_id |
string |
— | Yes | Full resource ID of the VNet the resolver binds to (usually the hub). |
enable_inbound |
bool |
true |
No | Create the inbound endpoint for on-prem → Azure resolution. |
inbound_subnet_id |
string |
null |
No | Delegated subnet ID for the inbound endpoint (required when enable_inbound). |
inbound_static_ip |
string |
null |
No | Static private IP for the inbound endpoint; null = Dynamic. |
enable_outbound |
bool |
true |
No | Create the outbound endpoint + forwarding ruleset. |
outbound_subnet_id |
string |
null |
No | Delegated subnet ID for the outbound endpoint (required when enable_outbound). |
forwarding_rules |
map(object) |
{} |
No | Forwarding rules keyed by name; each maps a trailing-dot FQDN to target DNS servers. |
ruleset_vnet_links |
map(string) |
{} |
No | Link name => VNet ID; attaches the ruleset to hub and spoke VNets. |
tags |
map(string) |
{} |
No | Tags applied to every resource. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Private DNS Resolver. |
name |
Name of the Private DNS Resolver. |
inbound_endpoint_id |
Resource ID of the inbound endpoint (null when disabled). |
inbound_endpoint_ip |
Private IP of the inbound endpoint — target for on-prem conditional forwarders. |
outbound_endpoint_id |
Resource ID of the outbound endpoint (null when disabled). |
dns_forwarding_ruleset_id |
Ruleset ID — reuse to attach extra VNet links from other stacks. |
Enterprise scenario
A bank running a hub-and-spoke landing zone is decommissioning a pair of Windows DNS forwarder VMs that bridged Azure and its on-premises Active Directory over ExpressRoute. The platform team deploys this module once in the hub: the inbound endpoint at 10.10.4.4 becomes the conditional-forwarder target the on-prem DNS team points privatelink.database.windows.net at, while a forwarding ruleset sends corp.contoso.com. back to the data-centre domain controllers and is linked to all twelve spoke VNets. The two DNS VMs — and their patching, licensing and 2 a.m. failover pages — are retired, and hybrid DNS becomes a reviewed Terraform change instead of a manual server build.
Best practices
- Dedicate and delegate the subnets. Each endpoint needs its own subnet delegated to
Microsoft.Network/dnsResolversand sized at least/28; never share it with workloads, NAT gateways or other delegations, or endpoint creation fails. - Pin inbound to a static IP. On-premises conditional forwarders hard-code the target address, so set
inbound_static_ipand treat it as a stable contract — a dynamic IP that shifts on redeploy silently breaks on-prem resolution. - Always use trailing-dot FQDNs in rules.
domain_namemust end with a.(e.g.corp.contoso.com.); the module’s validation enforces this so a missing dot can’t ship a non-matching rule. List two or moretarget_dns_serversper rule for resilience. - Cost is per endpoint, not per query for the resolver hours. You pay an hourly rate per inbound/outbound endpoint plus DNS query volume, so consolidate to one resolver in the hub and reuse its ruleset across spokes via
ruleset_vnet_linksinstead of deploying a resolver per VNet. - Get zone-redundancy for free, but keep the region right. The resolver is zone-redundant by design — the win over self-managed VMs — yet it must sit in the same region as its bound VNet; deploy a second resolver in your DR region rather than expecting cross-region failover.
- Name for the hub and tag for ownership. Use a region/hub-scoped name like
kv-hub-dnsrand apply consistenttags(workloadplatform-dns) so the resolver is unmistakably platform-owned and never garbage-collected during spoke teardown.