Upcoming book:

Modern .NET Development with Azure and DevOps

Centralized Private Endpoints with Azure Bicep

Introduction

In this post, we'll be looking at improving the management of Private Endpoints using Bicep templates. While the focus for this post will be on a Hub-and-Spoke topology, where there is a central VNET for managing VPN and other networking resources, this could also be simplified easily for simpler networks.

Here's a sample diagram for what we'll be achieving with this post:

Solution diagram achieved through this post

To briefly explain what is needed, for each resource that you want to access from the hub vnet (and/or VPN), you need:

  • A resource that supports Private Endpoints (such as storage accounts, key vaults, CosmosDb accounts, etc.)
  • A Virtual Network with at least one Subnet, with space for the Network Interface Card created by the Private Endpoint
  • A Private DNS Zone for the specific type of Private Endpoint to be deployed. For example, a Private DNS Zone for privatelink.blob.core.windows.net can be used for accessing blobs in a storage account, but cannot be used for accessing file shares in the same storage account or other resources like key vaults.
  • A network link for the Private DNS Zone, so that the zone is connected to the Virtual Network that will allow access to the resource (in this case, the hub network).
  • A Private Endpoint with a Private Link connection, which creates the connection between the resource and the subnet selected, for the specified group(s).
  • And finally, a Private DNS Zone Group, which connects the Private Endpoint with the Private DNS Zone, so that the resource can be resolved via the DNS name.

Not covered in this post is the setup of DNS. There are two options for this, either using the Azure DNS Private Resolver service, or one or more Virtual Machines configured as DNS servers with DNS forwarding zones configured for the Private DNS Zones you want to access.
Microsoft provides detailed explanations on the differences between the approaches, if you are keen to learn more.

Required components

To make things a bit simpler, I'll assume that you already have a Virtual Network with a Subnet for this sample, so we'll need the resource ID for both as part of the deployment.

Private DNS Zone

We'll start with this resource as it's needed for the Virtual Network link and the Private Endpoints' DNS.

param privateDnsZoneName string
param tags object

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = {
  name: privateDnsZoneName
  tags: tags
  location: 'global'
}

The privateDnsZoneName here is expected to be one of the zone names that you can find in the Private DNS zone name column in this Microsoft Learn article.

The next component needed is a Virtual Network Link for the Private DNS Zone. This can be added easily as a child resource through Bicep native features:

param privateDnsZoneLinkName string
param vnetId string

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = {
  //...

  resource link 'virtualNetworkLinks' = {
    name: privateDnsZoneLinkName
    tags: tags
    location: 'global'
    properties: {
      registrationEnabled: false
      virtualNetwork: { id: vnetId }
    }
  }
}

Private Endpoint

We can now move to the Private Endpoints resource. Keep in mind that you'd use the same Private DNS Zone for multiple Private Endpoints, which we can design easily with Bicep. First, the resource itself:

param location string
param privateEndpointName string
param subnetId string
param resourceId string
param groupIds string[]

var uniqueId = uniqueString(privateEndpointName, resourceId)

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2023-05-01' = {
  name: '${privateEndpointName}-${uniqueId}'
  tags: tags
  location: location
  properties: {
    customNetworkInterfaceName: '${privateEndpointName}-${uniqueId}-nic'
     subnet: { id: subnetId }
     privateLinkServiceConnections: [
      {
        name: '${privateEndpointName}-${uniqueId}-link'
        properties: {
          groupIds: groupIds
          privateLinkServiceId: resourceId
        }
      }
     ]
  }
}

You'll notice that I've used uniqueString to generate a set of 13 random characters. This is just to prevent having to give each private endpoint a name manually.
You may also wonder about the groupIds property, those values come from the same Microsoft Learn article, documented in the Subresource column.

Private DNS Zone Group

The last resource to be deployed is the DNS configuration for the Private Endpoint, which is a subresource of the Private Endpoint.

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2023-05-01' = {
  //...

  resource privateEndpointDns 'privateDnsZoneGroups' = {
    name: '${privateEndpointName}-${uniqueId}-dns'
    properties: {
      privateDnsZoneConfigs: [
        {
          name: '${privateEndpointName}-${uniqueId}-dns-config'
          properties: {
            privateDnsZoneId: privateDnsZone.id
          }
        }
      ]
    }
  }
}

Simple addition of new resources

Up until this point, we have all the resources that are needed to deploy a new Private Endpoint with all of its dependencies. But we are missing the links that ensure we don't duplicate code (or forget to deploy a resource). Fortunately, Bicep's features allow us to make this dynamic very easily.

Let's start by moving the Private Endpoint resource to it's own file, PrivateEndpoint.bicep:

param privateEndpointName string
param subnetId string
param resourceId string
param privateDnsZoneId string
param groupIds string[]
param location string
param tags object

var uniqueId = uniqueString(privateEndpointName, resourceId)

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2023-05-01' = {
  name: '${privateEndpointName}-${uniqueId}'
  tags: tags
  location: location
  properties: {
    customNetworkInterfaceName: '${privateEndpointName}-${uniqueId}-nic'
     subnet: { id: subnetId }
     privateLinkServiceConnections: [
      {
        name: '${privateEndpointName}-${uniqueId}-link'
        properties: {
          groupIds: groupIds
          privateLinkServiceId: resourceId
        }
      }
     ]
  }

  resource privateEndpointDns 'privateDnsZoneGroups' = {
    name: '${privateEndpointName}-${uniqueId}-dns'
    properties: {
      privateDnsZoneConfigs: [
        {
          name: '${privateEndpointName}-${uniqueId}-dns-config'
          properties: {
            privateDnsZoneId: privateDnsZoneId
          }
        }
      ]
    }
  }
}

And let's put the Private DNS Zone into PrivateDnsZone.bicep:

param privateDnsZoneName string
param privateDnsZoneLinkName string
param vnetId string
param tags object

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = {
  name: privateDnsZoneName
  tags: tags
  location: 'global'

  resource link 'virtualNetworkLinks' = {
    name: privateDnsZoneLinkName
    tags: tags
    location: 'global'
    properties: {
      registrationEnabled: false
      virtualNetwork: { id: vnetId }
    }
  }
}

We also know that one Private DNS Zone can be linked with many Private Endpoints, so we can add those next using a for loop. At the end of PrivateDnsZone.bicep, add:

param privateEndpointName string
param subnetId string
param resourceIds string[]
param groupIds string[]
param location string

module privateEndpoints 'PrivateEndpoint.bicep' = [for (item, index) in resourceIds: {
  name: '${privateDnsZoneName}-links-${index}'
  params: {
    location: location
    tags: tags
    groupIds: groupIds
    privateDnsZoneId: privateDnsZone.id
    privateEndpointName: privateEndpointName
    resourceId: item
    subnetId: subnetId
  }
}]

The only thing missing now, is to make the main.bicep do a for loop on the zones to be created. We'll use an array of objects to store this data, and you'll see why soon.

param vnetId string
param subnetId string
param privateEndpointPrefixName string
param privateEndpointsData array
param location string = resourceGroup().location
param tags object

module privateDnsZones 'PrivateDnsZone.bicep' = [for item in privateEndpointsData: {
  name: item.privateDnsZoneName
  params: {
    privateDnsZoneLinkName: item.privateDnsZoneLinkName
    privateDnsZoneName: item.privateDnsZoneName
    vnetId: vnetId
    groupIds: item.groupIds
    privateEndpointName: privateEndpointPrefixName
    resourceIds: item.resourceIds
    subnetId: subnetId
    location: location
    tags: tags
  }
}]

If you were to create a bicepparam file for this file, you could now write this:

using './main.bicep'

param location = ''
param vnetId = ''
param subnetId = ''
param privateEndpointPrefixName = ''
param privateEndpointsData = [
  {
    privateDnsZoneName: 'privatelink.blob.core.windows.net'
    privateDnsZoneLinkName: 'vnetlink-blob'
    resourceIds: [ 'YourBlobStorageAccountId' ]
    groupIds: [ 'blob' ]
  }
  {
    privateDnsZoneName: 'privatelink.vaultcore.azure.net'
    privateDnsZoneLinkName: 'vnetlink-kv'
    resourceIds: [ 'YourKeyVaultId1', 'YourKeyVaultId2' ]
    groupIds: [ 'vault' ]
  }
]

param tags = {}

The relationship between the Private DNS Zones and the Private Endpoints is now very clear. Adding new zones and endpoints is a matter of reusing the same structure.

The Bicep visualizer (in Visual Studio Code) helps reaffirm this relationship:

Bicep visualizer showing the relationship between the resources

Conclusion

Used correctly, Bicep is a very powerful language that allows us to manage resources at scale efficiently. I have published the source code for this article on my GitHub samples repository.

Bicep projects can (and should) also be treated with CI/CD (Continuous Integration and Continuous Delivery), and if you are interested in doing that with Azure DevOps, see this blog post of mine.

I hope you found this post useful, and if you have any questions, comments, ideas... feel free to leave a comment!