As you might already know, Azure DNS Private Resolver is an Azure service that support DNS forwarding between Azure and on-premises DNS servers. It is very useful to provide Azure DNS resolution to on-premises clients (for example to access private endpoints), or to provide on-premises DNS resolution to Azure clients (to access on-prem resources).
Last week I needed to configure a lab to test a couple of questions in my head:
- Do you need a VNet peering between the DNS client and the VNet containing the private resolver?
- If not, do you need to link DNS private zones to the VNet?
- And lastly, what does all this look like for P2S clients?
So I started furiously typing my keyboard (in this script you see every single command) to deploy this topology to Azure:

Here the main conclusions that I arrived to, and that we will explore in this post:
- If using VNet links to forwarding rulesets, no IP connectivity between the client VNet and the private resolver VNet is required
- If using VNet links to forwarding rulesets, linking private zones to the local VNet is actually required (linking the private zone to the hub is not enough)
- For P2S VPNs you want to have the hub VNet using an IP address as DNS server, either a DNS proxy such as Azure Firewall, or the private resolver’s inbound endpoint IP address
By the way, in case you are wondering why I am using the Azure Firewall here, since it is not strictly required (you could use the private resolver’s inbound endpoint for the configuration in spoke 2), it is just because it is a very common scenario for customers to have a firewall in the hub, and have it configured as DNS proxy to enable FQDN-based network rules in the Azure Firewall policy.
Before moving on, if you don’t know what Azure DNS Private Resover is, you might want to check these other sources:
- Official docs on Azure DNS Private Resolver
- Azure GBB Microhack on Private Resolver
- Daniel Mauser’s lab on Private Resolver
- John Savill’s deep dive video
- Adam Stuart’s video on outbound endpoints
Using Forwarding Ruleset VNet links
Let’s double check that the VNet custom DNS server settings are configured as described in the diagram (not setting the dnsServer attribute for a VNet means using the default VNet Azure resolver):
❯ az network firewall show -n $azfw_name -g $rg -o tsv --query 'ipConfigurations[0].privateIpAddress' 192.168.1.4 ❯ az network vnet show -n $vnet_name -g $rg --query 'dhcpOptions.dnsServers[0]' -o tsv 192.168.1.4 ❯ az network vnet show -n $spoke1_vnet_name -g $rg --query 'dhcpOptions.dnsServers[0]' -o tsv ❯ az network vnet show -n $spoke2_vnet_name -g $rg --query 'dhcpOptions.dnsServers[0]' -o tsv 192.168.1.4
The previous output shows that both the hub and the spoke 2 VNets have the firewall configured as DNS server, but what does the firewall configuration look like? Let’s have a look:
❯ az network firewall policy show -n $azfw_policy_name -g $rg --query 'dnsSettings' { "enableProxy": true, "requireProxyForNetworkRules": null, "servers": [] }
The previous output tells us that the Azure Firewall has DNS proxy functionality enabled, but it doesn’t have any DNS servers configured, meaning that it is going to use Azure’s native DNS resolver.
Now let’s make sure that we only have VNet peerings between the hub and spoke2, but not spoke1:
❯ az network vnet peering list -g $rg --vnet-name $vnet_name -o table AllowForwardedTraffic AllowGatewayTransit AllowVirtualNetworkAccess DoNotVerifyRemoteGateways Name PeeringState PeeringSyncLevel ProvisioningState ResourceGroup ResourceGuid UseRemoteGateways ----------------------- --------------------- --------------------------- --------------------------- ----------- -------------- ------------------ ------------------- --------------- ------------------------------------ ------------------- True False True False hubtospoke2 Connected FullyInSync Succeeded dns efc6aed7-8b96-040a-3fd5-72eec18a7e4b False ❯ az network vnet peering list -g $rg --vnet-name $spoke1_vnet_name -o table ❯ az network vnet peering list -g $rg --vnet-name $spoke2_vnet_name -o table AllowForwardedTraffic AllowGatewayTransit AllowVirtualNetworkAccess DoNotVerifyRemoteGateways Name PeeringState PeeringSyncLevel ProvisioningState ResourceGroup ResourceGuid UseRemoteGateways ----------------------- --------------------- --------------------------- --------------------------- ----------- -------------- ------------------ ------------------- --------------- ------------------------------------ ------------------- True False True False spoke2tohub Connected FullyInSync Succeeded dns efc6aed7-8b96-040a-3fd5-72eec18a7e4b False
As additional verification, let’s check what the OS in the spoke VMs (Ubuntu) is using as DNS server. Our spoke 1 VM should be using Azure (168.63.129.16
), and our spoke 2 VM Azure Firewall’s IP address (192.168.1.4
):
❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke1_vm_pip" "netplan ip leases eth0 | grep DNS" DNS=168.63.129.16 ❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke2_vm_pip" "netplan ip leases eth0 | grep DNS" DNS=192.168.1.4
One last thing: the forwarding ruleset that we are using in the private resolver should be associated to the hub and to spoke1, but we do not need it associated to spoke2:
❯ az dns-resolver vnet-link list --ruleset-name $fwd_ruleset_name -g $rg -o table Name ProvisioningState ResourceGroup ------ ------------------- --------------- hub Succeeded dns spoke1 Succeeded dns
Onprem DNS resolution
Finally, the moment of truth: resolving DNS names. We will use two test names:
test.contoso.com
, defined in an onprem server. It should resolve to1.2.3.4
test.contoso.corp
, defined in an Azure DNS private zone. It should resolve to5.6.7.8
Let’s start with spoke2, where we don’t expect any problem. This request will go through Azure Firewall (configured as DNS proxy) over the VNet peering, which will forward the request to the Azure resolver, which will forward the request to the private resolver (through the private link):
❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke2_vm_pip" "nslookup test.contoso.com" Server: 127.0.0.53 Address: 127.0.0.53#53 Non-authoritative answer: Name: test.contoso.com Address: 1.2.3.4
For the VM in spoke 1 it will work as well, but this time instead the resolution going through the link between the forwarding ruleset and the spoke 2 VNet:
❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke1_vm_pip" "nslookup test.contoso.com" Server: 127.0.0.53 Address: 127.0.0.53#53 Non-authoritative answer: Name: test.contoso.com Address: 1.2.3.4
DNS Private Zones
Before testing resolution of names defined in private zones, let’s have a quick look at the environment:
❯ az network private-dns zone list -g $rg -o table ZoneName ResourceGroup RecordSets MaxRecordSets VirtualNetworkLinks MaxVirtualNetworkLinks VirtualNetworkLinksWithRegistration MaxVirtualNetworkLinksWithRegistration ProvisioningState ------------ --------------- ------------ --------------- --------------------- ------------------------ ------------------------------------- ---------------------------------------- ------------------- contoso.corp dns 2 25000 0 1000 0 100 Succeeded ❯ az network private-dns record-set a list -z $private_dns_zone -g $rg --query '[].{RecordName:name,IPv4Address:aRecords[0].ipv4Address}' -o table RecordName IPv4Address ------------ ------------- test 5.6.7.8 ❯ az network private-dns link vnet list -g $rg -z $private_dns_zone -o table LinkName ResourceGroup RegistrationEnabled VirtualNetwork LinkState ProvisioningState ---------- --------------- --------------------- ---------------------------------------------------------------------------------------------------------------------- ----------- ------------------- hub dns False /subscriptions/e7da9914-9b05-4891-893c-546cb7b0422e/resourceGroups/dns/providers/Microsoft.Network/virtualNetworks/hub Completed Succeeded
We have the private zone contoso.corp
, where we have an A record for test
with the IP address 5.6.7.8
, and we have the private zone linked only to the hub, but not to the spokes (essentially, the link shown in the picture at the beginning of this post between the DNS private zone and the spoke1 VNet is not there yet). Let’s see if this works for the spokes:
❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke1_vm_pip" "nslookup test.contoso.corp" Server: 127.0.0.53 Address: 127.0.0.53#53 ** server can't find test.contoso.corp: NXDOMAIN ❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke2_vm_pip" "nslookup test.contoso.corp" Server: 127.0.0.53 Address: 127.0.0.53#53 Non-authoritative answer: Name: test.contoso.corp Address: 5.6.7.8
Since the VM in spoke 2 is using the Azure Firewall as custom DNS server, which is deployed in the hub (where the private zone is linked), spoke2 VMs can successfully resolve names in private DNS zones. However, the case of the VM in spoke1 is interesting: it can leverage the forwarding rules in the private resolver, but it cannot leverage the private resolver to access private zones linked to the hub VNet. To make this work, we need to link the private zone to spoke1 as well:
❯ az network private-dns link vnet create -g $rg -z $private_dns_zone -n $spoke1_vnet_name --virtual-network $spoke1_vnet_name --registration-enabled false -o none ❯ ssh -n -o BatchMode=yes -o StrictHostKeyChecking=no "$spoke1_vm_pip" "nslookup test.contoso.corp" Server: 127.0.0.53 Address: 127.0.0.53#53 Non-authoritative answer: Name: test.contoso.corp Address: 5.6.7.8
Private Resolver and Point-to-Site VPNs
The key to realize here is that an Azure P2S client will get assigned as DNS server whatever the VNet containing the VPN gateway has configured as custom DNS server. Since the hub VNet (where the VPN gateway is) has as custom DNS the Azure Firewall’s IP address (192.168.1.4
), that is the DNS server that a VPN client should receive:

In the previous screenshot, note how the DNS server is set to 192.168.1.4. And sure enough, our system can get the IP addresses for the hostnames we are testing. First of all, let’s verify connectivity to our hub VNet, where we have a test VM deployed with the IP address 192.168.10.4
:
❯ traceroute 192.168.10.4 traceroute to 192.168.10.4 (192.168.10.4), 30 hops max, 60 byte packets 1 172.22.192.1 (172.22.192.1) 0.737 ms 0.644 ms 0.606 ms 2 clientvm.internal.cloudapp.net (192.168.10.4) 27.315 ms 30.103 ms 29.735 ms
This is running on my WSL, so we are going through the Windows OS (172.22.192.1), and then through the VPN Gateway (which, fun fact, is transparent to traceroute). Now let’s test domain resolution:
❯ nslookup test.contoso.com Server: 172.22.192.1 Address: 172.22.192.1#53 Non-authoritative answer: Name: test.contoso.com Address: 1.2.3.4 ❯ nslookup test.contoso.corp Server: 172.22.192.1 Address: 172.22.192.1#53 Non-authoritative answer: Name: test.contoso.corp Address: 5.6.7.8
Adding up
Et voilà! This was the main section of the post. If you are wondering which of the two scenarios you should use, you know what’s coming: it depends. If you have an Azure Firewall, you probably want to use it as DNS proxy to have access to FQDN-based network rules, so I think that is going to be the most common design. However, there might be certain scenarios where you want to leverage the design without VNet peerings, for example in situations where you have some IP overlap.
Following you can find some useful Azure CLI commands for the Azure DNS private resolver.
Reference: Creating an Azure DNS Private Resolver with Azure CLI
You can find the full list of commands in the Azure CLI script in Github, here an extract of the relevant commands that create the private resolver, the endpoints and the ruleset:
in_ep_subnet_id=$(az network vnet subnet show --vnet-name $vnet_name -n $resolver_in_subnet_name -g $rg --query id -o tsv) in_ep_config="[{private-ip-address:'',private-ip-allocation-method:'Dynamic',id:'${in_ep_subnet_id}'}]" out_ep_subnet_id=$(az network vnet subnet show --vnet-name $vnet_name -n $resolver_out_subnet_name -g $rg --query id -o tsv) out_ep_config="[{private-ip-address:'',private-ip-allocation-method:'Dynamic',id:'${out_ep_subnet_id}'}]" echo "Creating DNS Private Resolver..." vnet_id=$(az network vnet show -n $vnet_name -g $rg --query id -o tsv) az dns-resolver create -n $resolver_name --id $vnet_id -g $rg -o none az dns-resolver inbound-endpoint create --dns-resolver-name $resolver_name --name $in_ep_name \ --ip-configurations $in_ep_config -g $rg -o none az dns-resolver outbound-endpoint create --dns-resolver-name $resolver_name --name $out_ep_name \ --id $out_ep_subnet_id -g $rg -o none in_ep_id=$(az dns-resolver inbound-endpoint show --dns-resolver-name $resolver_name --name $in_ep_name -g $rg --query id -o tsv) out_ep_id=$(az dns-resolver outbound-endpoint show --dns-resolver-name $resolver_name --name $out_ep_name -g $rg --query id -o tsv) az dns-resolver forwarding-ruleset create -n $fwd_ruleset_name -g $rg \ --outbound-endpoints "[{'id': '$out_ep_id'}]" az dns-resolver forwarding-rule create --ruleset-name $fwd_ruleset_name -n $fwd_rule_name \ --domain-name "${sample_domain}." --forwarding-rule-state "Enabled" \ --target-dns-servers "[{ip-address:'$dns_vm_privateip',port:53}]" -g $rg -o none spoke1_vnet_id=$(az network vnet show -n $spoke1_vnet_name -g $rg --query id -o tsv) az dns-resolver vnet-link create --ruleset-name $fwd_ruleset_name \ --id "$spoke1_vnet_id" -g $rg --name $spoke1_vnet_name -o none spoke2_vnet_id=$(az network vnet show -n $spoke2_vnet_name -g $rg --query id -o tsv) az dns-resolver vnet-link create --ruleset-name $fwd_ruleset_name \ --id "$spoke2_vnet_id" -g $rg --name $spoke2_vnet_name -o none hub_vnet_id=$(az network vnet show -n $vnet_name -g $rg --query id -o tsv) az dns-resolver vnet-link create --ruleset-name $fwd_ruleset_name \ --id "$hub_vnet_id" -g $rg --name $vnet_name -o none
Reference: Diagnostic commands
All commands require an Azure extension. Here an example of a command that will trigger the installation of the extension:
❯ az dns-resolver inbound-endpoint list --dns-resolver-name $resolver_name -g $rg -o table The command requires the extension dns-resolver. Do you want to install it now? The command will continue to run after the extension is installed. (Y/n): Y Run 'az config set extension.use_dynamic_install=yes_without_prompt' to allow installing extensions without prompt.
All commands in this page have been tested with version 0.2.0 of the extension. If you have a more modern version, it can be that your outputs are different:
❯ az extension show -n dns-resolver -o table ExtensionType Name Path Version --------------- ------------ -------------------------------------------- --------- whl dns-resolver /home/jose/.azure/cliextensions/dns-resolver 0.2.0
The first example of diagnostic commands is of course the list of private resolvers in a resource group:
❯ az dns-resolver list -g $rg -o table DnsResolverState Location Name ProvisioningState ResourceGroup ResourceGuid ------------------ ---------- ----------- ------------------- --------------- ------------------------------------ Connected westeurope hubresolver Succeeded dns 1ab3d593-16f4-4a6f-bfed-ffacdb9f2b9e
Once you know the name of your private resolver, you can show its endpoints:
❯ az dns-resolver inbound-endpoint list --dns-resolver-name $resolver_name -g $rg -o table Location Name ProvisioningState ResourceGroup ResourceGuid ---------- ------ ------------------- --------------- ------------------------------------ westeurope hubin Succeeded dns 43ae0169-bd8b-48df-a17b-84551c19c25f
A problem with the previous command is that it doesn’t show the allocated IP address to the endpoint, so we can use a query with a JMES expression for that:
❯ az dns-resolver inbound-endpoint list --dns-resolver-name $resolver_name -g $rg --query '[].{Name:name,Location:location,IP:ipConfigurations[0].privateIpAddress}' -o table Name Location IP ------ ---------- ------------- hubin westeurope 192.168.101.4
You can have a look at the outbound endpoints too:
❯ az dns-resolver outbound-endpoint list --dns-resolver-name $resolver_name -g $rg -o table Location Name ProvisioningState ResourceGroup ResourceGuid ---------- ------ ------------------- --------------- ------------------------------------ westeurope hubout Succeeded dns ab614dda-1584-45a2-948c-f629a5735102
Or the forwarding rulesets:
❯ az dns-resolver forwarding-ruleset list -g $rg -o table Location Name ProvisioningState ResourceGroup ResourceGuid ---------- ------------- ------------------- --------------- ------------------------------------ westeurope hubfwdruleset Succeeded dns 24aca0fe-37bc-472e-899a-2abea47cd779
Again, the previous command is missing some crucial information, namely the outbound point the ruleset is associated to. This command will show the first outbound endpoint association:
❯ az dns-resolver forwarding-ruleset list -g $rg --query '[].{Name:name,OutboundEndpoint:dnsResolverOutboundEndpoints[0].id,Location:location}' -o table Name OutboundEndpoint Location ------------- ---------------------------------------------------------------------------------------------------------------------------------------------------- ---------- hubfwdruleset /subscriptions/e7da9914-9b05-4891-893c-546cb7b0422e/resourceGroups/dns/providers/Microsoft.Network/dnsResolvers/hubresolver/outboundEndpoints/hubout westeurope
Once you have identified the ruleset, you can show the rules inside of it:
❯ az dns-resolver forwarding-rule list --ruleset-name $fwd_ruleset_name -g $rg -o table DomainName ForwardingRuleState Name ProvisioningState ResourceGroup ------------ --------------------- ----------- ------------------- --------------- contoso.com. Enabled contosocom Succeeded dns
And here again, with the help of a bit of JMES you can show the first target DNS server configured:
❯ az dns-resolver forwarding-rule list --ruleset-name $fwd_ruleset_name -g $rg --query '[].{Name:name,DomainName:domainName,State:forwardingRuleState,TargetDnsServer:targetDnsServers[0].ipAddress}' -o table Name DomainName State TargetDnsServer ----------- ------------ ------- ----------------- contosocom contoso.com. Enabled 172.16.0.4
And finally, you can check to what VNets the forwarding ruleset is linked to:
❯ az dns-resolver vnet-link list --ruleset-name $fwd_ruleset_name -g $rg -o table Name ProvisioningState ResourceGroup ------ ------------------- --------------- hub Succeeded dns spoke1 Succeeded dns spoke2 Succeeded dns