Manage the custom DNS server list of an existing Azure virtual network as its own resource, separate from the virtual network itself. Targets
hashicorp/azurerm ~> 4.0.
- π Sets the ordered custom DNS server list on an existing virtual network via
azurerm_virtual_network_dns_servers. - π§ Points a network's name resolution at a private-resolution data plane β a Private DNS Resolver inbound endpoint, a hub DNS forwarder, or custom DNS hosts β so private endpoints and on-premises names resolve for every workload in the network.
- π§± Manages the DNS list on a dedicated resource, decoupled from the virtual network resource, so a DNS/platform team changes resolution without re-planning the network the network team owns.
- π Secure by default β the empty call sets no custom servers and leaves the network on Azure-provided default DNS; a custom path is an explicit, reviewable opt-in.
- π§° Emits the assignment
id, the targetvirtual_network_id, and the effectivedns_serversfor downstream wiring.
π‘ Why it matters: In a hub-and-spoke or private-endpoint topology, the virtual network's DNS setting is what makes private zones and hybrid names resolvable. Owning that one setting on its own resource keeps a high-churn, cross-team knob out of the virtual network's lifecycle, so a resolver change never forces a virtual-network plan.
If this module saves you time, please consider supporting its continued development:
- β Star the repository β it helps others find the project.
- π€ Connect on LinkedIn β linkedin.com/in/microsoftexpert
- β Buy me a coffee β buymeacoffee.com/microsoftexpert
graph LR
rg["terraform-azurerm-resource-group"]
vnet["terraform-azurerm-virtual-network"]
resolver["terraform-azurerm-<br/>private-dns-resolver-inbound-endpoint"]
this["terraform-azurerm-virtual-network-dns-servers"]
target["azurerm_virtual_network_dns_servers.this"]
rg -->|"resource_group_name"| vnet
vnet -->|"virtual_network_id"| this
resolver -->|"private_ip_address to dns_servers"| this
this -->|"manages"| target
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef keystone fill:#004578,stroke:#004578,color:#ffffff;
classDef sibling fill:#eef3f8,stroke:#b8c4d0,color:#1b2733;
class this me;
class target keystone;
class rg,vnet,resolver sibling;
This module sits downstream of the virtual network it configures. It consumes a virtual_network_id from
terraform-azurerm-virtual-network and a set of DNS server addresses β typically a Private DNS Resolver
inbound endpoint IP, a hub forwarder, or custom DNS hosts β and manages only the network's DNS server list.
graph LR
vnid["virtual_network_id (required, force-new)"]
dns["dns_servers (list, default empty)"]
timeouts["timeouts (optional)"]
this["azurerm_virtual_network_dns_servers.this"]
oid["output: id"]
ovn["output: virtual_network_id"]
odns["output: dns_servers"]
vnid -->|"targets VNet"| this
dns -->|"custom DNS list"| this
timeouts -->|"operation timeouts"| this
this -->|"emits"| oid
this -->|"emits"| ovn
this -->|"emits"| odns
classDef me fill:#0078D4,stroke:#004578,color:#ffffff;
classDef keystone fill:#004578,stroke:#004578,color:#ffffff;
classDef sibling fill:#eef3f8,stroke:#b8c4d0,color:#1b2733;
class this keystone;
class vnid,dns,timeouts,oid,ovn,odns sibling;
Resource inventory
| Resource | Count | Role |
|---|---|---|
azurerm_virtual_network_dns_servers.this |
1 | The keystone; sets the custom DNS server list on the referenced virtual network. |
There are no child collections β this is a single-resource standalone module.
| Requirement | Value |
|---|---|
| Terraform | >= 1.12.0 |
| azurerm provider | ~> 4.0 |
| Provider block | None in this module β the caller configures provider "azurerm" { features {} }, auth, and subscription. |
Schema notes that bite (verified against the live provider schema):
virtual_network_idis force-new / immutable β changing it destroys and recreates the DNS-server assignment.- This resource and an inline
dns_serversargument onazurerm_virtual_networkmanage the same underlying setting. Managing both against one network produces a perpetual diff; use exactly one. - The resource has no
name,location,resource_group_name, ortagsβ those are all inherited from the referenced virtual network β so this module intentionally exposes none of them. - Setting
dns_serversto an empty list reverts the network to Azure-provided default DNS (168.63.129.16); it does not delete the network.
Network Contributoron the resource group (or the virtual network scope) that contains the target virtual network, or a custom role grantingMicrosoft.Network/virtualNetworks/readandMicrosoft.Network/virtualNetworks/writeat that scope. Updating the DNS server list is a write on the parent virtual network. Least-privilege: scope the assignment to the single virtual network where practical.
- The
Microsoft.Networkresource provider registered on the target subscription. - The target virtual network already exists; its resource ID is the
virtual_network_idinput. - Any DNS endpoint the list points at β a Private DNS Resolver inbound endpoint, a hub DNS forwarder, or custom DNS virtual machines β is already provisioned and reachable from the network's address space.
- The caller configures
provider "azurerm" { features {} }, auth, and subscription; the module declares none of these.
terraform-azurerm-virtual-network-dns-servers/
βββ providers.tf # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
βββ variables.tf # virtual_network_id, dns_servers (shape-checked, not IPv4), timeouts
βββ main.tf # azurerm_virtual_network_dns_servers.this + dynamic timeouts
βββ outputs.tf # id, virtual_network_id, dns_servers
βββ README.md # this document
βββ SCOPE.md # cross-module contract
βββ LICENSE # MIT
βββ .gitignore # canonical library ignore set
provider "azurerm" {
features {}
}
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network/providers/Microsoft.Network/virtualNetworks/vnet-hub"
dns_servers = ["10.0.0.4"]
}βΉοΈ The caller configures the provider, authentication, and the mandatory
features {}block β this module declares none of them. Always pin?ref=v1.0.0, never a branch.
Consumes
| Input | Type | Source |
|---|---|---|
virtual_network_id |
string |
terraform-azurerm-virtual-network (id) |
dns_servers |
list(string) |
Private DNS Resolver inbound endpoint IP, hub DNS forwarder, or custom DNS host addresses |
Emits
| Output | Description | Consumed by |
|---|---|---|
id |
The assignment Resource ID | downstream references, documentation |
virtual_network_id |
Resource ID of the configured virtual network | compositions wiring the same network elsewhere |
dns_servers |
Effective ordered custom DNS server list (empty = Azure default DNS) | validation, documentation, IPAM |
1 Β· Minimal call β single custom DNS server
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = ["10.0.0.4"]
}π‘ The smallest useful call: point the network at one custom DNS server. Order matters β the first reachable server answers.
2 Β· Point a network at a Private DNS Resolver inbound endpoint
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.spoke_vnet_id
dns_servers = [var.resolver_inbound_endpoint_ip] # e.g. "10.10.0.4"
}π‘ A Private DNS Resolver inbound endpoint is the recommended target for private-endpoint and hybrid resolution β it replaces custom DNS virtual machines with a managed service.
3 Β· Dual custom DNS servers (primary + secondary)
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = ["10.0.0.4", "10.0.0.5"] # primary, then secondary
}βΉοΈ List order is significant. Clients try servers in order, so place the most reliable resolver first.
4 Β· Revert to Azure-provided default DNS (empty list)
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = [] # the default β hands resolution back to Azure default DNS (168.63.129.16)
}π The empty list is the secure default. It resolves public names and same-network private-DNS-zone links, but not custom or on-premises zones. Setting the list back to empty reverts the network to Azure default DNS without deleting it.
5 Β· Hub-and-spoke DNS pattern β spoke points at the hub forwarder
module "spoke_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.spoke_vnet_id
dns_servers = [var.hub_dns_forwarder_ip] # centralize resolution in the hub
}π‘ In hub-and-spoke, every spoke points its DNS at the hub's forwarder (a resolver inbound endpoint or DNS host). Conditional forwarding and on-premises resolution then live in one place.
6 Β· Custom DNS virtual machines (self-managed resolvers)
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = ["10.20.1.10", "10.20.1.11"] # DNS forwarder VMs in a shared subnet
}
β οΈ Self-managed DNS VMs must stay reachable from the network's address space. Prefer a Private DNS Resolver where possible to remove the VM maintenance burden.
7 Β· Why a separate resource β ownership boundary
# The network team owns the virtual network (terraform-azurerm-virtual-network) and does NOT set dns_servers
# inline. The DNS/platform team owns resolution through this module against the same network id.
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.shared_vnet_id
dns_servers = [var.resolver_inbound_endpoint_ip]
}
β οΈ Manage the DNS list here or inline on the virtual network, never both β co-managing the same setting from two resources produces a perpetual diff. Splitting it out keeps a resolver change from forcing a virtual-network plan.
8 Β· Ordered failover β three resolvers
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = ["10.0.0.4", "10.0.1.4", "10.0.2.4"] # zone-spread resolvers, tried in order
}βΉοΈ Spread resolvers across zones and list them in preference order for resilient resolution.
9 Β· Manage DNS for many networks with for_each
variable "network_dns" {
type = map(object({
virtual_network_id = string
dns_servers = list(string)
}))
}
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
for_each = var.network_dns
virtual_network_id = each.value.virtual_network_id
dns_servers = each.value.dns_servers
}π‘ A stable map key per network keeps
for_eachfrom re-indexing when you add or remove one network.
10 Β· Explicit operation timeouts
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = ["10.0.0.4"]
timeouts = {
create = "30m"
update = "30m"
delete = "30m"
}
}βΉοΈ Timeouts are the only universal-tail input this resource supports;
tagsis intentionally absent.
11 Β· Wire the virtual-network module output directly
module "vnet" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
name = "vnet-hub"
resource_group_name = "rg-network-hub"
location = "eastus"
address_space = ["10.20.0.0/16"]
}
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = module.vnet.id
dns_servers = ["10.0.0.4"]
}π‘ Consume the sibling network module's
idoutput rather than hardcoding a resource ID string.
12 Β· Hybrid resolution β on-premises DNS plus a resolver
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.vnet_id
dns_servers = [
var.resolver_inbound_endpoint_ip, # Azure private zones + conditional forwarding
var.onprem_dns_primary_ip, # reachable over VPN/ExpressRoute
]
}
β οΈ On-premises servers must be reachable across the connected VPN or ExpressRoute path, and Azure private zones are best served by a resolver inbound endpoint rather than an on-premises server.
13 Β· Enabling private-endpoint name resolution
# Private endpoints publish A records into a private DNS zone. Workloads only resolve them when the network's
# DNS points at a resolver/forwarder that can answer for that zone.
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = var.app_vnet_id
dns_servers = [var.resolver_inbound_endpoint_ip]
}π Custom DNS is what turns a private endpoint into a resolvable, private-only name for the workloads in the network β a core building block of a no-public-exposure data plane.
14 Β· ποΈ End-to-end composition
provider "azurerm" {
features {}
}
module "rg" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"
name = "rg-network-prod"
location = "eastus2"
}
module "vnet" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network.git?ref=v1.0.0"
name = "vnet-hub-prod"
resource_group_name = module.rg.name
location = module.rg.location
address_space = ["10.0.0.0/16"]
subnets = {
resolver_inbound = {
name = "snet-resolver-inbound"
address_prefixes = ["10.0.0.0/28"]
}
}
}
module "resolver" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-resolver.git?ref=v1.0.0"
name = "pdr-hub-prod"
resource_group_name = module.rg.name
location = module.rg.location
virtual_network_id = module.vnet.id
}
# Inbound endpoints are a SEPARATE resource, and therefore a separate module.
module "resolver_inbound" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-dns-resolver-inbound-endpoint.git?ref=v1.0.0"
name = "pdr-hub-prod-inbound"
private_dns_resolver_id = module.resolver.id
location = "eastus"
ip_configurations = [{
subnet_id = module.vnet.subnet_ids["resolver_inbound"]
}]
}
# This module sets the hub network's DNS to the resolver's inbound endpoint IP.
module "vnet_dns" {
source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-network-dns-servers.git?ref=v1.0.0"
virtual_network_id = module.vnet.id
dns_servers = [module.resolver_inbound.private_ip_address]
}π‘ The composition wires real sibling outputs: the resource group feeds the virtual network, the virtual network feeds both the resolver and this module, and the inbound endpoint module's
private_ip_addressbecomes the network's DNS server. Keeping the DNS list on its own resource means changing the resolver never re-plans the virtual network.
Primary identity
| Name | Type | Required | Description |
|---|---|---|---|
virtual_network_id |
string |
β | Resource ID of the existing virtual network whose DNS list is managed. Force-new. |
Configuration
| Name | Type | Default | Description |
|---|---|---|---|
dns_servers |
list(string) |
[] |
Ordered custom DNS server IPv4 addresses; empty means Azure default DNS. |
Universal tail
| Name | Type | Default | Description |
|---|---|---|---|
timeouts |
object({...}) |
null |
Optional create/read/update/delete timeouts. |
βΉοΈ This resource does not support
tags,name,location, orresource_group_name; the module exposes none of them.
Full object() schemas
variable "virtual_network_id" {
type = string
# Full VNet resource ID:
# /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Network/virtualNetworks/<vnet>
# Immutable: changing it forces replacement of the DNS-server assignment.
}
variable "dns_servers" {
type = list(string)
default = []
# Entries are checked for shape, not for IPv4 -- Azure supports dual-stack virtual networks and
# the provider itself requires only a non-empty string. Empty list = Azure-provided default DNS.
}
variable "timeouts" {
type = object({
create = optional(string)
read = optional(string)
update = optional(string)
delete = optional(string)
})
default = null
}| Output | Description | Kind |
|---|---|---|
id |
Resource ID of the virtual network DNS-servers assignment (emitted first) | Passthrough |
virtual_network_id |
Resource ID of the virtual network whose DNS server list this module manages | Passthrough |
virtual_network_name |
Name of the virtual network being configured, parsed from virtual_network_id | Derived |
resource_group_name |
Resource group the assignment lives in, parsed from virtual_network_id | Derived |
subscription_id |
Subscription the virtual network lives in, parsed from virtual_network_id | Derived |
dns_servers |
The effective ordered list of custom DNS server addresses set on the virtual network, as the provider reads it back | Passthrough |
dns_server_count |
Number of custom DNS servers configured, against the fixed Azure ceiling of 20 per virtual network | Passthrough |
primary_dns_server |
The first entry in the list, or null when there is none | Derived |
uses_azure_provided_dns |
True when no custom DNS server is configured and the network therefore resolves through Azure-provided DNS at 168.63.129.16 | Passthrough |
has_a_single_point_of_dns_failure |
True when exactly one custom DNS server is configured | Passthrough |
duplicate_dns_servers |
Addresses that appear more than once in the list | Derived |
entries_that_are_not_ipv4_shaped |
Entries that do not look like a dotted-quad IPv4 address | Derived |
an_empty_list_means_azure_provided_dns_not_no_dns |
Always true, and the single most misread thing about this resource | Constant |
destroying_this_resource_reverts_the_network_to_azure_provided_dns |
Always true | Constant |
creating_this_resource_silently_replaces_any_existing_dns_server_list |
Always true, and the reason to check a network's current DNS before pointing this module at it | Constant |
is_a_singleton_per_virtual_network |
Always true | Constant |
an_inline_dns_servers_argument_on_the_virtual_network_would_fight_this_resource |
Always true | Constant |
every_change_rewrites_the_whole_virtual_network_object |
Always true, and the reason this resource is more disruptive than its two arguments suggest | Constant |
a_change_takes_effect_only_after_a_lease_renewal_or_restart |
Always true, and the most common reason a correct apply appears to do nothing | Constant |
resolution_order_is_significant_and_there_is_no_round_robin |
Always true | Constant |
a_reachable_but_broken_first_resolver_is_never_failed_over_from |
Always true, and the failure mode a second entry in the list does NOT protect against | Constant |
custom_dns_makes_port_53_traffic_bypass_network_security_groups |
Always true once any custom DNS server is set, and it is a security control changing behaviour with no corresponding change in the plan | Constant |
network_interface_dns_settings_override_this_list |
Always true | Constant |
azure_private_dns_zones_need_a_resolver_or_forwarder_in_the_list |
Always true, and the reason a private endpoint stops resolving the moment custom DNS is set | Constant |
windows_forwarders_need_a_timeout_above_four_seconds |
Always true where the servers named here are Windows DNS servers forwarding to Azure | Constant |
virtual_network_id_is_force_new |
Always true | Constant |
resource_ids_are_parsed_case_sensitively |
Always true | Constant |
the_resource_has_no_tags_name_location_or_resource_group |
Always true | Constant |
this_module_creates_no_resolver_forwarder_or_zone |
Always true | Constant |
accepts_no_credential |
Always true | Constant |
- Single-resource standalone.
main.tfrenders exactly one resource,azurerm_virtual_network_dns_servers.this, plus adynamic "timeouts"block that renders only when thetimeoutsobject is supplied. Every optional nested field usestry(..., null)so an omitted key renders as absent, not as an error. - Force-new identity.
virtual_network_idis immutable. A change destroys and recreates the assignment; because the assignment reverts the network to default DNS on delete, plan such a change during a maintenance window. - Do not double-manage. The
dns_serversargument onazurerm_virtual_networkand this resource write the same setting. Pick one owner. This module deliberately references the network byidand never mutates the network resource, keeping ownership boundaries clean. - Empty means default, not deletion. An empty
dns_serverslist is a valid, safe state that hands resolution back to Azure default DNS. It is the module's default so the empty call is safe. - No
tags. Unlike most resources in this suite,azurerm_virtual_network_dns_serverssupports no tags, name, location, or resource group of its own β all are inherited from the referenced network β so the universal tail here istimeoutsonly. features {}dependence. The provider will not initialize without a caller-sideprovider "azurerm" { features {} }block; that belongs to the root module.
| Concern | Secure default (empty call) | Opt-out (caller types it) |
|---|---|---|
| Custom DNS override | dns_servers = [] β Azure-provided default DNS |
supply one or more server IPs |
| Resolution path | No custom private-resolution path until explicitly wired | point at a resolver / forwarder / DNS host |
| Ownership | DNS list owned on its own resource, network referenced by id |
(not applicable β the module never co-manages the network) |
| Secrets | None accepted or emitted | (not applicable) |
# From the module folder β offline, no cloud calls:
terraform init -backend=false
terraform validate
terraform fmt -check- Pin the module with
?ref=v1.0.0β never a branch. - This library is plan-only during authoring; a human runs
terraform plan/applyfrom CI against real credentials. No apply happens here.
The offline proof gate covers everything static analysis can prove:
terraform init -backend=falseresolves the pinnedazurerm ~> 4.0provider without a backend.terraform validateproves the configuration is internally consistent and type-correct against the pinned provider schema β it surfaces every typing mistake theobject()schemas and the IPv4validation {}block are designed to catch.terraform fmt -checkenforces canonical formatting.
What only terraform plan (run by a human from CI) exercises: the ARM write against the live virtual network
and the actual DNS-server assignment. This module is never applied during authoring.
Outputs:
id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/virtualNetworks/vnet-hub-prod/dnsServers/default"
virtual_network_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-network-prod/providers/Microsoft.Network/virtualNetworks/vnet-hub-prod"
dns_servers = [
"10.0.0.4",
]
| Symptom | Cause | Fix |
|---|---|---|
| Perpetual diff on the DNS server list | The same network sets dns_servers inline on azurerm_virtual_network and via this module. |
Remove the inline dns_servers from the network resource; let this module own it (or vice versa) β never both. |
| Plan shows the assignment being replaced | virtual_network_id changed, and it is force-new. |
Confirm the intended network ID; expect recreation and schedule it during a maintenance window. |
virtual_network_id must be a virtual network Resource ID of the form ... |
A name or partial path was passed instead of a full Resource ID β or a segment was miscased, which the provider parses case-sensitively. | Pass the complete /subscriptions/.../virtualNetworks/<name> ID, e.g. module.vnet.id, with resourceGroups and Microsoft.Network spelled exactly. |
A dns_servers entry is rejected |
The entry carries a CIDR suffix, a comma, whitespace, or looks like a hostname. IPv4 is not required β an IPv6 resolver is legal on a dual-stack network. | Supply one bare IP address per entry (for example 10.0.0.4). |
| Private endpoints or on-premises names still do not resolve | The network points at Azure default DNS, or the target resolver cannot answer for the zone. | Set dns_servers to a resolver inbound endpoint / forwarder that holds the private zone or conditional-forwarding rules. |
Error: provider ... features on init/plan |
No caller-side features {} block. |
Add provider "azurerm" { features {} } to the root module. |
- Terraform Registry β
azurerm_virtual_network_dns_servers - Terraform Registry β
azurerm_virtual_network - Microsoft Learn β Azure DNS Private Resolver
- Microsoft Learn β Name resolution for resources in Azure virtual networks
- Sibling modules:
terraform-azurerm-virtual-network,terraform-azurerm-private-dns-zone,terraform-azurerm-private-endpoint. - This module's
SCOPE.md.
π "Infrastructure as Code should be standardized, consistent, and secure."