Load Balancing VMs in Azure Resource Manager

Here I want to show, in details, how you would go about to expose load balanced web server VMs using Azure Resource Manager (ARM) resources.

It sounds trivial but funnily enough I didn’t find an ARM template fully doing it without bugs.

I want to explain how it works and all the moving pieces (and there are a few).  I’ll walk you through the portal in order to create the artefacts and I’ll give the ARM template at the end.

Here’s a diagram of the final state:


Basically, we’ll have 2 VMs (which of course you could extend to n VMs) in a Virtual Network with their VHD in a blob storage, those 2 VMs will have HTTP requests load balanced to them from a public IP address and will have RDP NAT to individual VMs (i.e. port 3389 will go to VM-0, port 3390 will go to VM-1).

The RDP NAT isn’t mandatory ; it could have been done differently, for instance using a point-to-site VPN to enter the Virtual Network.  But since we covered the Load Balancer, I thought I might show the NAT rules while we’re there.

As you can see, there are quite a few puzzle pieces (NICs aren’t even represented!).  If you compare that with Cloud Service, it might seem complicated.  The thing is Cloud Service aggregated a bunch of features and at it end it was hard or impossible to cover other scenarios.  The new model is more modular with more (simpler) components.

Let’s start!

Resource Group

We’re in ARM and the first thing we’ll do is to create a resource group.  The resource group will contain all the Azure artefacts.

One of the nice things of resource group is that when you do some exploration or POCs, once you’re done you can simply delete the resource group and all the artefacts underneath will disappear.

Give the resource group the name you want and create it in the region you prefer.  It is absolutely possible to create a resource group in region X and artefacts underneath in region Y, but I find that confusing unless you’re addressing a DR scenario within one resource group.

Public IP Address

The first piece of the puzzle, the Public IP address.  Type “Public IP Address” in the marketplace and choose the one published by Microsoft.


For the name, call it Public-IP  but it really doesn’t matter:  this is the resource name and nobody else than you will see that.

Dynamic or Static?  In this post, we’ll go with dynamic because it’s the cheapest option and I’m stingy, am I?

Basically, a static IP address gives you a public IP address that won’t change.  It can be useful in some scenarios but is unnecessary in most and you pay for it.

A dynamic IP is an IP address that will change on Azure whims.  But you do not pay for it.  With dynamic IP addresses, you do not bind on the IP address, you bind on the DNS name label.  Azure makes sure the fully qualified domain name (FQDN) is in sync with the IP address whenever it changes.

Idle timeout is usually ok at the default value.  This configuration allows you to decide when to kill an idle TCP connection.

DNS label must be unique.  I often use my initials, VPL, but it isn’t the most professional approach.  You then select the resource group you just created and make sure it’s in the same region.

DNS Configuration

We have a public IP.  This is good but it’s on an Azure domain name.  It will be:

<The lable you chose>.<Name of the region>.cloudapp.azure.com

It’s ok for demo, might even be alright for dev & QA.  For production, it’s a bit like using your hotmail account for doing business:  it doesn’t blink “we are serious” very brightly.

So what you’ll wanna do is to map your real domain name, let’s say www.fantasticweb.com, to the cloudapp one.

This is done by entering a Canonical Name (CNAME) record either in your registrar or the upcoming Azure DNS Service.  What the hell is that?  A CNAME gives DNS levels of indirection to resolve DNS.  For instance we could have:

DNS Source Target Record Type
Your Registrar www.fantastic.com <something>.cloudapp.azure.com CNAME
Azure <something>.cloudapp.azure.com A

So when somebody browses for www.fantastic.com, unbeknown to them, there is a bit of a dance happening to find the actual target IP address.

You can learn more about it here.

Virtual Network

Let’s create a Virtual Network to put our VMs in.

This is one of the major differences with ARM (vs Cloud Services):  VMs always live in a Virtual Network.  You can leave the door open if you like the air the get in, but you need a vnet.

So in the Azure Marketplace, type “Virtual Network”, take the one published by Microsoft.  Make sure you choose the Resource Manager before hitting Create


You can name the vnet…  “vnet”.  Make sure you put it in the right resource group and then hit create.

This creates a vnet with a default subnet.  In this post, I won’t secure the network.  If you want to do that, I suggest you read this post.


We’ll need storage to store the virtual hard drives of the VMs.

Let’s create a storage account.  Again, make sure you select “Resource Manager” as deployment model before hitting create.  Make sure you put it in the same resource group & region.

Availability Set

Next, we’ll create an availability set.  An availability set is a declarative construct that says to Azure:  please distribute the VMs that are in it across different failure & update domains.  For more details on availability sets, please read this post.

In the Azure Marketplace, search for “Availability Set”, select the one from Microsoft.

You must give it a name.  I suggest “AvailSet”.  Make sure you put it in the right resource group and Azure region.

You could configure the number of update domains & failure domains here.  This is why an availability set is “a thing” in ARM while it used to be simply a name in ASM:  in order to attach more configuration to it.

Virtual Machines

Here we’ll create 2 virtual machines.  I’m going to create Windows Server 2012 VMs but you could go ahead and create any Linux VMs.  The reminder of the post would be identical.

In Azure Marketplace, search for “windows 2012” and select “Windows Server 2012 R2 Datacenter” published by Microsoft.


VMs require a fair bit of configuration, so let’s go with it.

In the (1) Basics configuration, give your vm the name “VM-0”.  Give a login name / password ; this will be the local admin account of the VM.  Make sure it is in the right resource group and Azure region.

For the size (2), since it’s a demo, go with something small, such as a A2.

For the configuration (3), this is where the meat is.  Select the storage account we’ve created.  Select the virtual network we created.  For public IP address, select None.  For Network Security Group, select None.  At the bottom, for Availability Set, select “AvailSet” that we’ve just created.

You can now create the VM.  This is quite slow, so go ahead and create the second one.  Configurations will be identical but for its name, “VM-1”.

Load Balancer

Ok, now let’s stick everything together!

In the Azure Marketplace, type “Load Balancer” and select the one published by Microsoft.

Let’s call it “LB”, keep the scheme to “public”, choose the public IP as the one we’ve created, i.e. “Public-IP”.  As usual, let’s put it in the same region & resource group.


Let’s create it.

Then let’s configure the backend pools.


We’ll add one, then name it “Web” and “Add a virtual Machine”.

For the availability set, we choose the one we’ve created, i.e. “AvailSet” and for the virtual machines, we select both VM-0 & VM-1.

This defines the targets for the load balancer.

Now, let’s define a probe.


Let’s add one, call it “TCP-Probe”, give it the protocol TCP and leave the port to 80, interval to 5 seconds and unhealthy threshold to 2.


That probe is basically going to define when a VM is healthy or not.  When the VM can’t let the probe connect, it’s going to be removed from the load balancer roaster.

Load Balancing Rules

Now for the load balancing rules.


Let’s create one, call it “Http”, with protocol TCP, port 80, backend port 80, the back-end pool “Web” (that we just created), Probe “TCP-Probe” (that we also just created), Session Persistence “None”, Idle timeout 4 mins, Floating IP “Disabled”.

Basically, we say that TCP requests coming on the public IP will be forwarded to the availability set on their port 80.

NAT Rules

Let’s setup a NAT Rule to forward RDP requests as follow.


So, we’ll create 2 NAT rules.


For the first one, let’s call it “RDP-to-VM-0”, let’s select the RDP Service, protocol TCP, Port 3389, target “VM-0”, port mapping “default”.

For the second one, let’s call it “RDP-to-VM-1”, let’s select the RDP Service, protocol TCP, Port 3390 (override it), target “VM-1”, port mapping “custom” & target port 3389.


That’s all the configuration we need.

From here, you can connect to your VMs.


What we’ll want to do for testing is to configure a simple web site on each VM giving a different page each.  Let’s say that VM-0 prints out “VM-0 Site” and VM-1 prints out “VM-1 Site”.

To do that is a bit outside the scope of this post.  Basically, you need to add the “web server” role to your VMs then localize where IIS root folder is (typically in C:\inetpub\wwwroot) and drop a file “index.html” there with the desired output.

Now if you hit the FQDN of your public IP with a browser, you should see one of the two outputs.  If you hit F5, you’ll see…  the same?  Why is that?

That’s because your browser keeps the connection alive and we have 4 minutes before the load balancer times it out.

If you want to have a more spectacular test, go in Visual Studio and create a Console app with the following code:

[code language=”csharp”] using System; using System.IO; using System.Net;

namespace TestLbConsole { class Program { static void Main(string[] args) { for (int i = 0; i != 10; ++i) { var request = WebRequest.Create("http://vpl-ip.southcentralus.cloudapp.azure.com/") as HttpWebRequest;

            request.KeepAlive = false;

            using (var response = request.GetResponse())
            using (var stream = response.GetResponseStream())
            using (var reader = new StreamReader(stream))
                var payload = reader.ReadToEnd();

} } [/code]

Since we disable the KeepAlive, we now hit a different VM each time!


So, that was a bit complicated but hopefully, you followed all along.

There are lots of bit and pieces, but they each are simple.

Here is the ARM template allowing you to reproduce what we did in this article.

Please note I wasn’t able to incorporate the NAT rules into the ARM template.  This is due to a current limitation of the schema:  we can’t have loops within NAT rules.  So you’ll need to enter those rules manually in order to RDP into the VMs.

[code language=”javascript”]

{ "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", "contentVersion": "", "parameters": { "vm-admin-name": { "type": "string", "defaultValue": "Macgyver" }, "vm-admin-password": { "type": "securestring" }, "vm-prefix": { "type": "string", "defaultValue": "VM-" }, "storage-account-name": { "type": "string" }, "number-of-instance": { "type": "int", "defaultValue": 2 }, "vm-size": { "type": "string", "minLength": 1, "defaultValue": "Standard_A1", "allowedValues": [ "Basic_A0", "Basic_A1", "Basic_A2", "Basic_A3", "Basic_A4", "Standard_A0", "Standard_A1", "Standard_A2", "Standard_A3", "Standard_A4", "Standard_A5", "Standard_A6", "Standard_A7", "Standard_D1", "Standard_D2", "Standard_D3", "Standard_D4", "Standard_D11", "Standard_D12", "Standard_D13", "Standard_D14" ] } }, "variables": { "AvailSet-name": "AvailSet", "LoadBalancer-name": "LB", "backend-address-pool-name": "Web", "PublicIP-name": "Public-IP", "VNet-name": "VNet", "vm-subnet-name": "vm-subnet", "NIC-prefix": "NIC-", "VM-prefix": "VM-" }, "resources": [ { "type": "Microsoft.Compute/availabilitySets", "name": "[variables(‘AvailSet-name’)]", "apiVersion": "2015-06-15", "location": "[resourceGroup().location]", "tags": { "displayName": "Availability Set" }, "properties": { "platformUpdateDomainCount": 5, "platformFaultDomainCount": 3 } }, { "apiVersion": "2015-06-15", "type": "Microsoft.Network/networkInterfaces", "name": "[concat(variables(‘NIC-prefix’), copyindex())]", "location": "[resourceGroup().location]", "tags": { "displayName": "Network Interface" }, "copy": { "name": "nic-loop", "count": "[parameters(‘number-of-instance’)]" }, "dependsOn": [ "[concat(‘Microsoft.Network/virtualNetworks/’, variables(‘VNet-Name’))]", "[concat(‘Microsoft.Network/loadBalancers/’, variables(‘LoadBalancer-name’))]" ], "properties": { "ipConfigurations": [ { "name": "ipconfig1", "properties": { "privateIPAllocationMethod": "Dynamic", "subnet": { "id": "[concat(resourceId(‘Microsoft.Network/virtualNetworks’, variables(‘VNet-Name’)), ‘/subnets/’, variables(‘vm-subnet-name’))]" }, "loadBalancerBackendAddressPools": [ { "id": "[concat(resourceId(‘Microsoft.Network/loadBalancers’, variables(‘LoadBalancer-name’)), ‘/backendAddressPools/’, variables(‘backend-address-pool-name’))]" } ] } } ] } }, { "apiVersion": "2015-06-15", "type": "Microsoft.Compute/virtualMachines", "name": "[concat(variables(‘VM-prefix’), copyindex())]", "copy": { "name": "vm-loop", "count": "[parameters(‘number-of-instance’)]" }, "location": "[resourceGroup().location]", "tags": { "displayName": "Virtual Machines" }, "dependsOn": [ "[concat(‘Microsoft.Storage/storageAccounts/’, parameters(‘storage-account-name’))]", "nic-loop", "[concat(‘Microsoft.Compute/availabilitySets/’, variables(‘AvailSet-name’))]" ], "properties": { "availabilitySet": { "id": "[resourceId(‘Microsoft.Compute/availabilitySets’, variables(‘AvailSet-name’))]" }, "hardwareProfile": { "vmSize": "[parameters(‘vm-size’)]" }, "osProfile": { "computerName": "[concat(variables(‘VM-prefix’), copyIndex())]", "adminUsername": "[parameters(‘vm-admin-name’)]", "adminPassword": "[parameters(‘vm-admin-password’)]" }, "storageProfile": { "imageReference": { "publisher": "MicrosoftWindowsServer", "offer": "WindowsServer", "sku": "2012-Datacenter", "version": "latest" }, "osDisk": { "name": "osdisk", "vhd": { "uri": "[concat(‘http://’, parameters(‘storage-account-name’), ‘.blob.core.windows.net/vhds/’, ‘osdisk’, copyindex(), ‘.vhd’)]" }, "caching": "ReadWrite", "createOption": "FromImage" } }, "networkProfile": { "networkInterfaces": [ { "id": "[resourceId(‘Microsoft.Network/networkInterfaces’, concat(variables(‘NIC-prefix’), copyindex()))]" } ] } } }, { "type": "Microsoft.Network/loadBalancers", "name": "[variables(‘LoadBalancer-name’)]", "apiVersion": "2015-06-15", "location": "[resourceGroup().location]", "tags": { "displayName": "Load Balancer" }, "properties": { "frontendIPConfigurations": [ { "name": "LoadBalancerFrontEnd", "properties": { "privateIPAllocationMethod": "Dynamic", "publicIPAddress": { "id": "[resourceId(‘Microsoft.Network/publicIPAddresses’, variables(‘PublicIP-name’))]" } } } ], "backendAddressPools": [ { "name": "[variables(‘backend-address-pool-name’)]" } ], "loadBalancingRules": [ { "name": "Http", "properties": { "frontendIPConfiguration": { "id": "[concat(resourceId(‘Microsoft.Network/loadBalancers’, variables(‘LoadBalancer-name’)), ‘/frontendIPConfigurations/LoadBalancerFrontEnd’)]" }, "frontendPort": 80, "backendPort": 80, "enableFloatingIP": false, "idleTimeoutInMinutes": 4, "protocol": "Tcp", "loadDistribution": "Default", "backendAddressPool": { "id": "[concat(resourceId(‘Microsoft.Network/loadBalancers’, variables(‘LoadBalancer-name’)), ‘/backendAddressPools/’, variables(‘backend-address-pool-name’))]" }, "probe": { "id": "[concat(resourceId(‘Microsoft.Network/loadBalancers’, variables(‘LoadBalancer-name’)), ‘/probes/TCP-Probe’)]" } } } ], "probes": [ { "name": "TCP-Probe", "properties": { "protocol": "Tcp", "port": 80, "intervalInSeconds": 5, "numberOfProbes": 2 } } ], "inboundNatRules": [ ], "outboundNatRules": [ ], "inboundNatPools": [ ] }, "dependsOn": [ "[resourceId(‘Microsoft.Network/publicIPAddresses’, variables(‘PublicIP-name’))]" ] }, { "type": "Microsoft.Network/publicIPAddresses", "name": "[variables(‘PublicIP-name’)]", "apiVersion": "2015-06-15", "location": "[resourceGroup().location]", "tags": { "displayName": "Public IP" }, "properties": { "publicIPAllocationMethod": "Dynamic", "idleTimeoutInMinutes": 4, "dnsSettings": { "domainNameLabel": "vpl-ip" } } }, { "type": "Microsoft.Network/virtualNetworks", "name": "[variables(‘VNet-name’)]", "apiVersion": "2015-06-15", "location": "[resourceGroup().location]", "tags": { "displayName": "Virtual Network" }, "properties": { "addressSpace": { "addressPrefixes": [ "" ] }, "subnets": [ { "name": "[variables(‘vm-subnet-name’)]", "properties": { "addressPrefix": "" } } ] } }, { "type": "Microsoft.Storage/storageAccounts", "name": "[parameters(‘storage-account-name’)]", "apiVersion": "2015-06-15", "location": "[resourceGroup().location]", "tags": { "displayName": "Storage Account" }, "properties": { "accountType": "Standard_LRS" } } ] }


Here are the template’s parameters:

Name Type Description
vm-admin-name string Name of the local admin user on the vms.
vm-admin-password secured string Password of the local admin user.
storage-account-name string Name of the storage account where the vhds are stored. This needs to be unique within all storage accounts (not only yours).
number-of-instance int The number of vms to scale out.
vm-size string The size of vm (e.g. A2).

8 responses

  1. Trinity 2016-07-05 at 04:18

    As you wisely point out in the beginning of this post, plenty templates out there are bugged. This was exactly what I needed to get my Load Balancer to run properly. Thank you very much!

  2. Vincent-Philippe Lauzon 2016-07-05 at 10:48

    You’re very welcome!

  3. Bertus 2017-02-17 at 05:05

    How to deploy the script?

  4. Vincent-Philippe Lauzon 2017-02-17 at 05:53

    It’s an ARM template, so you can look at https://docs.microsoft.com/en-us/azure/azure-resource-manager/resource-group-template-deploy on how to deploy ARM templates.

  5. Rick 2017-03-06 at 03:56

    Nice write up, I followed it and it works as described. One small question though, regarding the storage account, Do the 2 VMs share the same storage account where if I write something on VM-0 should appear on VM-1 also?

  6. Vincent-Philippe Lauzon 2017-03-29 at 09:20

    They share the same Storage Account but not the same vhd file. So no to your last question. Actually, Azure doesn’t support having VMs sharing the same VHD. VHDs are attached to one and only one VM at the time.

    The only way to “share a disk” is to use File Storage and using its SMB endpoint to mount a share on both VMs.

  7. Prathamesh 2017-07-15 at 04:18

    HI, I have existing Vnet and I am using ARM template to create Azure internal load balancer with Static Private IP address. Though my load balancer is created and backend pool is also created but failing to add an availability set under backend pool. Please suggest how to accomplish same using same ARM template. Appreciate prompt response.

  8. Vincent-Philippe Lauzon 2017-07-17 at 15:09

    Availability set is a one time decision: at the creation of VMs. You can’t join or leave an availability set.

    So unfortunately in your case, if you have pre-existing VMs, you won’t be able to put them in the Availability Set.

    The only solution I see is to destroy the VMs while keeping the disks. You can then recreate the VMs using the disks and then joining the availability set. You can do that either using the portal or in an ARM template (see https://vincentlauzon.com/2016/05/30/recreating-vms-in-azure/).

Leave a comment