Create an ARM template for a domain joined virtual machine.

Recently, I have been asked to provide an easy way to create preconfigured virtual machines on Azure (meaning using the portal, and without PowerShell) with predefined settings (VM size, disks, network,...), and also, automatically joined to a domain.
For this exercise, I will assume we already have a Virtual Network created, with one subnet or more, and with a Virtual Machine acting as a Domain Controller and a DNS Server.

[UPDATE]: The full ARM template is available here.

Creating a generic ARM Template

The easiest way to create an ARM template is to build the Virtual Machine manually, download the template, and do some refactoring. I'll create a Virtual Machine (ARM) with the following settings:

Name: VM01  
Operating System: Windows Server 2016 Datacenter  
VM Disk Type : HDD  
Username: <login_localadmin>  
Password: <pwd_localadmin>  
Resource group: RG-VM01  
Size: DS1_V2  
Storage Account : (new)  
Network: VNet1  
Subnet: Subnet1  
Public IP : (new)  
Network security group : (new)  
Diagnostic storage account : (new)  

On the Summary page, before clicking on OK, click on Download template and parameters

Then click on Download

The two files we're interested in are template.json which contains the ARM template itself, and parameters.json which contains the values we provided during the wizard. You'll find as well scripts to deploy the template, but we're not going to use them (we're going to use the Templates gallery).

If you open the template.json file, you'll see the following structure

{
  "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",

  //List of parameters used in the template
  "parameters": {
  },

  //List of variables used in the template
  "variables": {
  },

  //Array of every resources created
  "resources": [],

  //Output of the template
  "outputs": {

  }
}

As you see, there is a lot of parameters. But we don't need every one of them, especially if we want a generic template which is supposed to create similar virtual machines. That's why we can transform a lot of parameters into variables.
We'll keep only the virtualMachineName, the adminUserName, the adminPassword and copy the rest of them in the variables section with their values (from the parameters.json file). We should have something like that :

    "variables": {
        "vnetId": "[resourceId('RG-VNet1','Microsoft.Network/virtualNetworks', parameters('virtualNetworkName'))]",
        "subnetRef": "[concat(variables('vnetId'), '/subnets/', parameters('subnetName'))]",
        "location":"westeurope",
        "virtualMachineSize":"Standard_DS1_v2",
        "storageAccountName":"<storageAccountName>",
        "virtualNetworkName":"VNet1",
        "networkInterfaceName":"vm01525",
        "networkSecurityGroupName":"VM01-nsg",
        "storageAccountType":"Standard_LRS",
        "diagnosticsStorageAccountName":"<diagStorageAccountName>",
        "diagnosticsStorageAccountType":"Standard_LRS",
        "diagnosticsStorageAccountId":"Microsoft.Storage/storageAccounts/<storageAccountName>",
        "subnetName":"Subnet1",
        "publicIpAddressName":"VM01-ip",
        "publicIpAddressType":"Dynamic"
    }

Now we can remove them from the parameters section. If you are using Visual Studio Code or any other editor smart enough to validate the JSON file, you now should have a lot of errors regarding the deletion of the parameters. We just have to replace the parameter('') function with the variables('') function every time it's used in the template for a deleted parameter. For example, the Storage Account resource should now look like that:

        {
            "name": "[variables('storageAccountName')]",
            "type": "Microsoft.Storage/storageAccounts",
            "apiVersion": "2015-06-15",
            "location": "[variables('location')]",
            "properties": {
                "accountType": "[variables('storageAccountType')]"
            }
        }

The next issue to tackle is the non generalized format of the variables. For example, If we try to create multiple network interfaces with the same name, Azure will throw an error. Since our Virtual Machine Name should be unique, we can use that as a prefix for the following resources: networkInterfaceName, networkSecurityGroupName and publicIpAddressName. The concat() function is perfect for that:

    "variables": {
        ...
        "networkInterfaceName":"[concat('nic-', parameters('virtualMachineName'))]",
        "networkSecurityGroupName":"[concat('nsg-', parameters('virtualMachineName'))]",
        "publicIpAddressName":"[concat('pip-', parameters('virtualMachineName'))]",
        ...
    }

You also have to update the hard-coded Resource Group Name ('RG-VM01' in my template). Just replace every occurrence with the function used to retrieve the current Resource Group Name resourceGroup().name (except the one used to retrieve the vnetId in the variables section, which is the Resource Group name where the VNet is located).

The last problem is the names of the Storage Accounts which must be unique (accross every Azure regions) and lower case. We're going to use the uniqueString() function, which creates a deterministic hash string based on the values provided as parameters, and the toLower() function. Since the string returned is 13 characters long, and since I want a friendly name, I'm going to take only a substring of the uniquestring, and use a prefix containing the Virtual Machine name. My storageAccountName and diagnosticsStorageAccountName variables now look like :

    "variables": {
        ...
        "storageAccountName":"[concat('sa', toLower(parameters('virtualMachineName')), take(uniqueString('virtualMachineName'), 8))]",
        "diagnosticsStorageAccountName":"[concat('diag', toLower(parameters('virtualMachineName')), take(uniqueString('virtualMachineName'), 8))]",
        ...
    }

That's it! I can use this template to create generic Virtual Machines in a specific subnet.
I left the location, virtualNetworkName, storageAccountType, diagnosticsStorageAccountType, subnetName variables hard-coded, but feel free to change that if you need to.
Note: The private IP address is Dynamic, but you can change it the template if you want to, and then provide the static IP as a parameter.

Joining a domain using a custom extension

Now that we have an ARM template to create our Virtual Machine, we have to add a custom extension to join the machine to a domain. We will assume you have a custom DNS server associated with your virtual network, which can be used to resolve the FQDN of a Domain Controller from the domain you want to join.

In order to join the domain, we need additional information's in our template: the FQDN of the domain, a Domain User Name, and the associated Password. There is two additional parameters we can provide to the extension, but I've chosen to set an hard-coded value: empty string for the OUPath, and '3' to the flag Domain Join Options (infos here).
The custom extensions is like any others resources in the template. It's a JSON object in the resources array (just copy/paste 'as is' the JSON Object below in the array).

  "resources": [
    {
       ... // Virtual Machine Resource
    },
    {
      "apiVersion": "2016-03-30",
      "type": "Microsoft.Compute/virtualMachines/extensions",
      "name": "[concat(parameters('virtualMachineName'),'/joindomain')]",
      "location": "[resourceGroup().location]",
      "dependsOn": [
        "[concat('Microsoft.Compute/virtualMachines/', parameters('virtualMachineName'))]"
      ],
      "properties": {
        "publisher": "Microsoft.Compute",
        "type": "JsonADDomainExtension",
        "typeHandlerVersion": "1.3",
        "autoUpgradeMinorVersion": true,
        "settings": {
          "Name": "[parameters('domainToJoin')]",
          "OUPath": "[variables('ouPath')]",
          "User": "[concat(parameters('domainToJoin'), '\\', parameters('domainUsername'))]",
          "Restart": "true",
          "Options": "[variables('domainJoinOptions')]"
        },
        "protectedsettings": {
          "Password": "[parameters('domainPassword')]"
        }
      }
    },
    ... // Others Resources
]

As I said, in order to the JSON extension to work, I add 3 parameters :

    "parameters": {
        ...
        "domainToJoin":{
            "type": "string"
        },
        "domainUsername":{
            "type":"string"
        },
        "domainPassword":{
            "type":"securestring"
        }
    }

and 2 variables :

    "variables": {
        ...
        "ouPath":"",
        "domainJoinOptions":3
    },

The template is now ready to be added to your Templates gallery.

Using the Templates gallery

In the Azure portal, search for Templates in the top search bar.

As you see in the screenshot, the Templates gallery is still in preview.
Click Add

Add general information, then, in the ARM template blade, paste the full template.

Click OK, Click Add. You can now use it to create a virtual machine by clicking on the template, then clicking Deploy.

Last thing, by default, the template is only visible to you. You have to share it if you want someone else to use it.

Tuning the parameters

As you see in the previous screenshot, parameters are just textbox. There's no input validation before creating the Virtual Machine. For example, it will throw an error during deployment if you type a Virtual Machine name too long, since our Storage Account name uses it, and is limited to 24 characters (alphanumeric lowercase).
The local admin password for the Virtual Machine must exceed 12 characters as well.
In order to deal with that, you can add some constraints, and a short description for each parameter. You can also add a default value, like for the FQDN of the domain to join.

  "parameters": {
    "virtualMachineName": {
      "type": "string",
      "minlength": 1,
      "maxlength": 12,
      "metadata": {
        "description": "The Virtual Machine Name must be between 1 and 15 characters long"
      }
    },
    "adminUsername": {
      "type": "string",
      "minlength": 3,
      "maxlength": 12,
      "metadata": {
        "description": "Username of the local administrator of the Virtual Machine"
      }
    },
    "adminPassword": {
      "type": "securestring",
      "minLength": 12,
      "metadata": {
        "description": "Password must have 3 of the following : 1 lower case character, 1 upper chase character, 1 number and 1 special character."
      }
    },
    "domainToJoin": {
      "type": "string",
      "defaultValue": "<default fqdn domain>",
      "metadata": {
        "description": "The FQDN of the AD domain to join"
      }
    },
    "domainUsername": {
      "type": "string",
      "metadata": {
        "description": "Username of the account on the domain"
      }
    },
    "domainPassword": {
      "type": "securestring",
      "metadata": {
        "description": "Password of the account on the domain"
      }
    }
  },
  ...

Those default values, constraints, and descriptions will reflect in the deployment form. You can take a look in the Azure documentation to use different types of parameters.