IaC Azure

Terraform Module: Azure Private DNS Resolver — hybrid name resolution without DNS VMs

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:

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

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

TerraformAzurePrivate DNS ResolverModuleIaC
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