When setting up private networking in Azure, ensuring seamless name resolution for Point-to-Site (P2S) VPN clients accessing services via Private Endpoints can be tricky. One of the key challenges is properly configuring Azure DNS Resolver and ensuring that DNS Zones are centrally managed within a Virtual WAN (V-WAN) topology. The goal of this setup is to allow private name resolution across workloads without duplicating DNS zones and without requiring direct access to core networking resource groups.

This article walks through the setup of a V-WAN, a Private DNS Resolver, and the necessary configurations to link workloads to central Private DNS Zones using Terraform. This is a Terraform based implementation of the Virtual Hub Extension Pattern and employing the guidance of how to take advantage of the Azure Private DNS Resolver to allow VPN traffic to resolve the domain names of private-endpoint attached Azure Services.

One key learning is that DNS Zones should be centrally attached to a core virtual network shared across the V-WAN. Workloads need to reference these zones using a data source rather than creating new ones, ensuring a scalable and maintainable architecture. However, this also introduces an access control challenge, as workload teams might require permissions to create Virtual Network links in a core network they don’t own. We’ll also discuss potential role definitions to mitigate this issue.

By the end of this guide, you should have a solid understanding of how to configure private networking in Azure with Terraform, ensuring that P2S VPN clients can resolve Private Endpoint-attached services efficiently.

Setup the V-WAN

The first step in establishing private networking is setting up a Virtual WAN (V-WAN). This serves as the backbone of the private network, connecting all regional hubs and enabling seamless communication between workloads.

module "vwan" {

  source  = "Azure-Terraformer/vwan/azurerm"
  version = "1.0.1"

  resource_group_name   = azurerm_resource_group.main.name
  location              = azurerm_resource_group.main.location
  name                  = "${var.application_name}-${var.environment_name}"
  primary_address_space = var.address_space
  additional_regions    = var.additional_regions

}

Setup the Private DNS Resolver

Next, we set up the Private DNS Resolver, which allows workloads and VPN clients to resolve private domain names. This is deployed in a virtual network linked to the V-WAN.

resource "azurerm_virtual_network" "dns" {

  name                = "vnet-${var.application_name}-dns-${var.environment_name}"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = [var.dns_address_space]

}

resource "azurerm_virtual_hub_connection" "main" {
  name                      = "dns-hub-connection"
  virtual_hub_id            = module.vwan.primary_virtual_hub_id
  remote_virtual_network_id = azurerm_virtual_network.dns.id
}

It’s a good idea to attach the core Private DNS Zones to this central virtual network so that they are centrally managed and accessible to all workloads connected to the V-WAN.

resource "azurerm_private_dns_zone" "storage_blob" {

  name                = "privatelink.blob.core.windows.net"
  resource_group_name = azurerm_resource_group.main.name

}

resource "azurerm_private_dns_zone_virtual_network_link" "storage_blob" {

  name                  = "${var.application_name}-${var.environment_name}-storage-blob"
  resource_group_name   = azurerm_resource_group.main.name
  private_dns_zone_name = azurerm_private_dns_zone.storage_blob.name
  virtual_network_id    = azurerm_virtual_network.dns.id
  registration_enabled  = false

}

Then I setup a pair of subnets on this network for the future home of my Azure Private DNS Resolver Network:

locals {
  dns_resolver_inbound_subnet_address_space  = cidrsubnet(var.dns_address_space, 4, 0)
  dns_resolver_outbound_subnet_address_space = cidrsubnet(var.dns_address_space, 4, 1)
}

# minimum subnet size of /28
# https://learn.microsoft.com/en-us/azure/dns/dns-private-resolver-overview
resource "azurerm_subnet" "dns_resolver_inbound" {

  name                 = "snet-dns-inbound"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.dns.name
  address_prefixes     = [local.dns_resolver_inbound_subnet_address_space]

  delegation {
    name = "Microsoft.Network.dnsResolvers"
    service_delegation {
      name    = "Microsoft.Network/dnsResolvers"
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
    }
  }

}
resource "azurerm_subnet" "dns_resolver_outbound" {
  name                 = "snet-dns-outbound"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.dns.name
  address_prefixes     = [local.dns_resolver_outbound_subnet_address_space]

  delegation {
    name = "Microsoft.Network.dnsResolvers"
    service_delegation {
      actions = ["Microsoft.Network/virtualNetworks/subnets/join/action"]
      name    = "Microsoft.Network/dnsResolvers"
    }
  }
}

Then I setup the Azure Private DNS Resolver with an Inbound and Outbound endpoint — both attached to their respective subnets.

resource "azurerm_private_dns_resolver" "main" {

  name                = "dnspr-${var.application_name}-${var.environment_name}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  virtual_network_id  = azurerm_virtual_network.dns.id

}

resource "azurerm_private_dns_resolver_inbound_endpoint" "main" {

  name                    = "${azurerm_private_dns_resolver.main.name}-inbound"
  private_dns_resolver_id = azurerm_private_dns_resolver.main.id
  location                = azurerm_private_dns_resolver.main.location

  ip_configurations {
    private_ip_allocation_method = "Dynamic"
    subnet_id                    = azurerm_subnet.dns_resolver_inbound.id
  }

}

resource "azurerm_private_dns_resolver_outbound_endpoint" "main" {
  name                    = "${azurerm_private_dns_resolver.main.name}-outbound"
  private_dns_resolver_id = azurerm_private_dns_resolver.main.id
  location                = azurerm_private_dns_resolver.main.location
  subnet_id               = azurerm_subnet.dns_resolver_outbound.id
}

Setup the Point-to-Site (P2S) VPN

With the core network in place, we set up a P2S VPN to enable remote clients to securely connect to the private network:

locals {
  dns_resolver_inbound_ip_address = azurerm_private_dns_resolver_inbound_endpoint.main.ip_configurations[0].private_ip_address
}

module "vpn" {

  source  = "Azure-Terraformer/vwan-vpn/azurerm"
  version = "1.0.2"

  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  name                = "${var.application_name}-${var.environment_name}"
  virtual_hub_id      = module.vwan.primary_virtual_hub_id
  address_space       = var.vpn_address_space
  tenant_id           = data.azuread_client_config.current.tenant_id
  audience            = local.azure_vpn_app_id
  dns_servers         = [local.dns_resolver_inbound_ip_address]

}

Setup a Storage Account with a Private Endpoint

Now that the network is in place, I can start attaching additional workloads to it. The Virtual WAN is the network. The Virtual Hubs that attach to the V-WAN extend this network to the regions that I want my workload(s) to be hosted within and the Virtual Networks of those workloads attach to the corresponding Virtual Hubs for the Azure regions they are hosted within.

As an Azure Terraformer, one of the first workloads I want to bring on is a Terraform State Backend in the form of an Azure Storage Account. Therefore, I need to provision one but I need to do it with a Private Endpoint to connect it to the network that I’ve established.

It needs to have its own Virtual Network that connects it to one of the Virtual Hubs:

resource "azurerm_virtual_network" "main" {

  name                = "vnet-${var.application_name}-${var.environment_name}"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = [var.address_space]

}

data "azurerm_virtual_hub" "core" {
  name                = var.virtual_hub.name
  resource_group_name = var.virtual_hub.resource_group
}

resource "azurerm_virtual_hub_connection" "main" {
  name                      = "terraform-state-hub-connection"
  virtual_hub_id            = data.azurerm_virtual_hub.core.id
  remote_virtual_network_id = azurerm_virtual_network.main.id
}

It needs to link to the central DNS Zones that I provisioned in my core-network environment.

data "azurerm_private_dns_zone" "storage_blob" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = var.virtual_hub.resource_group
}

resource "azurerm_private_dns_zone_virtual_network_link" "storage_blob" {

  name                  = "${var.application_name}-${var.environment_name}-storage-blob"
  resource_group_name   = var.virtual_hub.resource_group
  private_dns_zone_name = data.azurerm_private_dns_zone.storage_blob.name
  virtual_network_id    = azurerm_virtual_network.main.id
  registration_enabled  = false

}

Notice that I am using a Data Source to reference the privatelink.blob.core.windows.net DNS Zone and that I have to reference. an external Resource Group — the one for my core network. Unlike Bicep or ARM, Terraform’s ability to provision across Resource Group boundaries with ease makes these types of “attachable” infrastructure scenarios that much easier.

Lastly, I need a subnet and a Private Endpoint to attach the Storage Account to this workload network.

locals {
  default_subnet_address_space = cidrsubnet(var.address_space, 2, 0)
}

resource "azurerm_subnet" "default" {

  name                 = "snet-default"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.main.name
  address_prefixes     = [local.default_subnet_address_space]

}

resource "azurerm_private_endpoint" "storage" {

  name                = "pe-${var.application_name}-${var.environment_name}-storage"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  subnet_id           = azurerm_subnet.default.id

  private_dns_zone_group {
    name                 = "storage"
    private_dns_zone_ids = [data.azurerm_private_dns_zone.storage_blob.id]
  }

  private_service_connection {
    name                           = "pec-${var.application_name}-${var.environment_name}-storage"
    private_connection_resource_id = azurerm_storage_account.main.id
    is_manual_connection           = false
    subresource_names              = ["blob"]
  }

}

The Private endpoint links the Storage Account to the Subnet. The Subnet links us to the Virtual Network, the Virtual Network links us to the Virtual Hub, the Virtual Hub links us to the V-WAN which connects us to the Azure DNS Resolver, Point-to-Site (P2S) VPN and the rest of the broader network.

Connect to the VPN

I need to go download the VPN client configuration and import it into my Azure VPN client. Once, I’ve done that, I can connect and now my workstation is connected to the Azure V-WAN.

Now that I’ve setup my Blob Storage Account with a Private Endpoint I can verify my connectivity to it but simply doing an nslookup using its private link DNS fully qualified domain name (FQDN).

nslookup sttf8476dmzr.privatelink.blob.core.windows.net

Alt

Setup the DNS Resolver’s Inbound Endpoint’s Private IP Address as a DNS Server in your Azure VPN configuration.

It starts out like this:

<clientconfig i:nil="true" />

But you need to replace the node with this:

  <clientconfig>
    <dnsservers>
        <dnsserver>10.47.3.4</dnsserver>
    </dnsservers>
  </clientconfig>

Alt

After you do that you should be able to resolve the Private Link DNS name!

Alt

Troubleshooting and Best Practices

  • Create an Virtual Network for “Core” Network Infrastructure that follows the Single Responsability Principal (SRP). This is a great spot to put highly reusable stuff such as a DNS Zones, Private DNS Resolver, and Point-to-Site VPN Gateways.
  • Using Data Sources for DNS Zones: Instead of creating new DNS Zones, reference the centrally managed ones using data.azurerm_private_dns_zone.
  • Managing Access to Core Networking Resources: Workloads need permission to create Virtual Network links in the core network. Consider defining a custom role that allows only this specific action.
  • Verifying Name Resolution: Ensure P2S VPN clients can resolve private domain names by checking their DNS configuration and performing nslookup tests.

Conclusion

By centralizing Private DNS Zones in a shared virtual network within the V-WAN, we create a scalable, maintainable and extensible networking architecture that supports multiple workloads without duplicating infrastructure. This approach ensures that as new workloads are deployed, they can seamlessly integrate with the existing network by referencing the core Private DNS Zones. Thus allowing us to easily attach Private Endpoints to the various Azure Services that are needed by our diverse set of workloads.

However, access management remains a challenge, and organizations should carefully design role-based access control to grant workload teams the necessary permissions without overexposing critical resources.

With these principles in mind, you can confidently set up private networking in Azure using Terraform, ensuring secure and efficient connectivity for all workloads all with those pesky public endpoints turned off for good!

Alt