Update 2022-02-20

This guide is outdated! Please click here to go to the new guide I wrote.

Update 2021-08-03

With v2.69.0 of the official Terraform azurerm provider released two weeks ago, the active_directory_domain_service resource is now available. If you are freshly adding AADDS, there is no point in reading any further — use the official resources.


Bringing traditional Active Directory Domain Services (AD DS) to the cloud, typically required to set up, secure, and maintain domain controllers (DCs). Azure Active Directory Domain Services (AADDS or Azure AD DS) is a Microsoft-managed solution, providing a subset of traditional AD DS features without the need to self-manage DCs.

One such service that requires AD DS features is Azure Virtual Desktop (WVD). I have successfully deployed WVD with Terraform, but until recently, I struggled to do the same with AADDS. Today, I show you how to deploy AADDS with Terraform.

If you are lazy, you can skip to the end and use the custom Terraform module I published to the Terraform Registry.

Prerequisites

Before getting started, we need the following things:

Building Blocks

So how do we figure out what the required resources are to deploy AADDS? By reverse-engineering the AADDS configuration wizard in the Azure Portal! We launch it by adding a new managed domain after navigating to Azure AD Domain Services.

What we find is that the following resources are required:

  • Resource group
  • Virtual network and subnet
  • AAD DC Administrators user group

Using default values for the remaining configuration options, we can download an Azure Resource Manager (ARM) template in the final review step of the wizard.

Analyzing the ARM template reveals that besides the resource group, virtual network, and subnet, a Network Security Group (NSG) with the security rules required for AADDS is added.

The ARM template also contains the Microsoft.AAD/domainServices resource, with its parameters set to the configuration options from the wizard. The following is version 2021-03-01 of the ARM template format from the official Azure Template documentation:

{
  "name": "string",
  "type": "Microsoft.AAD/domainServices",
  "apiVersion": "2021-03-01",
  "location": "string",
  "tags": {},
  "properties": {
    "domainName": "string",
    "replicaSets": [
      {
        "location": "string",
        "subnetId": "string"
      }
    ],
    "ldapsSettings": {
      "ldaps": "string",
      "pfxCertificate": "string",
      "pfxCertificatePassword": "string",
      "externalAccess": "string"
    },
    "resourceForestSettings": {
      "settings": [
        {
          "trustedDomainFqdn": "string",
          "trustDirection": "string",
          "friendlyName": "string",
          "remoteDnsIps": "string",
          "trustPassword": "string"
        }
      ],
      "resourceForest": "string"
    },
    "domainSecuritySettings": {
      "ntlmV1": "string",
      "tlsV1": "string",
      "syncNtlmPasswords": "string",
      "syncKerberosPasswords": "string",
      "syncOnPremPasswords": "string",
      "kerberosRc4Encryption": "string",
      "kerberosArmoring": "string"
    },
    "domainConfigurationType": "string",
    "sku": "string",
    "filteredSync": "string",
    "notificationSettings": {
      "notifyGlobalAdmins": "string",
      "notifyDcAdmins": "string",
      "additionalRecipients": ["string"]
    }
  }
}

The docs also reference the Azure Resource Manager QuickStart Template on GitHub. Its README confirms our previous findings but shows that the configuration wizard also must perform the following steps under the hood:

With everything figured out, we can continue with the fun part: Terraform!

Putting Everything Together

Let’s register the service principal and resource provider first:

resource "azuread_service_principal" "aadds" {
  application_id = "2565bd9d-da50-47d4-8b85-4c97f669dc36"
}

resource "azurerm_resource_provider_registration" "aadds" {
  name = "Microsoft.AAD"
}

Next, we add the AAD DC Administrators user group:

resource "azuread_group" "aadds" {
  display_name = "AAD DC Administrators"
  description  = "Delegated group to administer Azure AD Domain Services"
}

Adding the resource group, virtual network, subnet, and NSG is pretty straightforward:

resource "azurerm_resource_group" "aadds" {
  name     = "aadds-rg"
  location = "Switzerland North"
}

resource "azurerm_virtual_network" "aadds" {
  name                = "aadds-vnet"
  resource_group_name = azurerm_resource_group.aadds.name
  location            = "Switzerland North"

  address_space = ["10.0.0.0/16"]

  # AADDS DCs
  dns_servers = ["10.0.0.4", "10.0.0.5"]
}

resource "azurerm_subnet" "aadds" {
  name                 = "aadds-snet"
  resource_group_name  = azurerm_resource_group.aadds.name
  virtual_network_name = azurerm_virtual_network.aadds.name

  address_prefixes = ["10.0.0.0/24"]
}

resource "azurerm_network_security_group" "aadds" {
  name                = "aadds-nsg"
  location            = "Switzerland North"
  resource_group_name = azurerm_resource_group.aadds.name

  security_rule {
    name                       = "AllowRD"
    access                     = "Allow"
    priority                   = 201
    direction                  = "Inbound"
    protocol                   = "Tcp"
    source_address_prefix      = "CorpNetSaw"
    source_port_range          = "*"
    destination_address_prefix = "*"
    destination_port_range     = "3389"
  }

  security_rule {
    name                       = "AllowPSRemoting"
    access                     = "Allow"
    priority                   = 301
    direction                  = "Inbound"
    protocol                   = "Tcp"
    source_address_prefix      = "AzureActiveDirectoryDomainServices"
    source_port_range          = "*"
    destination_address_prefix = "*"
    destination_port_range     = "5986"
  }
}

resource "azurerm_subnet_network_security_group_association" "aadds" {
  subnet_id                 = azurerm_subnet.aadds.id
  network_security_group_id = azurerm_network_security_group.aadds.id
}

Make sure to set dns_servers to the IP addresses of the DCs. You can find them on the Overview page of the managed domain after the deployment succeeded.

The final step is to add the AADDS deployment. Define the ARM template as Terraform templatefile named aadds-arm-template.tpl.json:

{
  "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "resources": [
    {
      "name": "${domainName}",
      "type": "Microsoft.AAD/domainServices",
      "apiVersion": "2021-03-01",
      "location": "${location}",
      "tags": ${tags},
      "properties": {
        "domainName": "${domainName}",
        "replicaSets": [
          {
            "location": "${location}",
            "subnetId": "${subnetId}"
          }
        ],
        "domainSecuritySettings": {
          "ntlmV1": "${ntlmV1}",
          "tlsV1": "${tlsV1}",
          "syncNtlmPasswords": "${syncNtlmPasswords}",
          "syncKerberosPasswords": "${syncKerberosPasswords}",
          "syncOnPremPasswords": "${syncOnPremPasswords}",
          "kerberosRc4Encryption": "${kerberosRc4Encryption}",
          "kerberosArmoring": "${kerberosArmoring}"
        },
        "domainConfigurationType": "${domainConfigurationType}",
        "sku": "${sku}",
        "filteredSync": "${filteredSync}",
        "notificationSettings": {
          "notifyGlobalAdmins": "${notifyGlobalAdmins}",
          "notifyDcAdmins": "${notifyDcAdmins}",
          "additionalRecipients": ${additionalRecipients}
        }
      }
    }
  ]
}

We then populate its values dynamically like so:

resource "azurerm_resource_group_template_deployment" "aadds" {
  name                = "aadds-deploy"
  resource_group_name = azurerm_resource_group.aadds.name

  deployment_mode = "Incremental"

  template_content = templatefile(
    "${path.module}/aadds-arm-template.tpl.json",
    {
      # Basics
      "domainName"              = "aadds.schnerring.net"
      "location"                = "Switzerland North"
      "sku"                     = "Standard"
      "domainConfigurationType" = "FullySynced"

      # Networking
      "subnetId" = azurerm_subnet.aadds.id

      # Administration
      "notifyGlobalAdmins"   = "Enabled"
      "notifyDcAdmins"       = "Enabled"
      "additionalRecipients" = jsonencode([])

      # Synchronization
      "filteredSync" = "Enabled"

      # Security
      "tlsV1"                 = "Enabled"
      "ntlmV1"                = "Enabled"
      "syncNtlmPasswords"     = "Enabled"
      "syncOnPremPasswords"   = "Enabled"
      "kerberosRc4Encryption" = "Enabled"
      "syncKerberosPasswords" = "Enabled"
      "kerberosArmoring"      = "Disabled"

      # Tags
      "tags" = jsonencode({})
    }
  )

  depends_on = [azurerm_resource_provider_registration.aadds]
}

Run terraform apply to deploy everything. It takes around 45 minutes to complete.

Wrapping Up

I created a custom module wrapping the above functionality and published it to the Terraform Registry. You can also find the code on my GitHub along with some examples. I decided that the creation of network resources is out of the module’s scope. Depending on what network topology you prefer, pre-provisioning the virtual network and subnet gives you more flexibility.

The module provides the same options as the Azure Portal configuration wizard. More advanced configuration options like LDAP and forests are not yet supported. Feel free to comment below, or open an issue or pull request on GitHub if you find something to improve.

A minimal deployment with the custom module looks like this:

resource "azurerm_resource_group" "aadds" {
  name     = "aadds-rg"
  location = "Switzerland North"
}

resource "azurerm_virtual_network" "aadds" {
  name                = "aadds-vnet"
  resource_group_name = azurerm_resource_group.aadds.name
  location            = "Switzerland North"

  address_space = ["10.0.0.0/16"]

  # AADDS DCs
  dns_servers = ["10.0.0.4", "10.0.0.5"]
}

resource "azurerm_subnet" "aadds" {
  name                 = "aadds-snet"
  resource_group_name  = azurerm_resource_group.aadds.name
  virtual_network_name = azurerm_virtual_network.aadds.name

  address_prefixes = ["10.0.0.0/24"]
}

module "aadds" {
  source  = "schnerring/aadds/azurerm"
  version = "0.1.1"

  resource_group_name = azurerm_resource_group.aadds.name
  location            = "Switzerland North"
  domain_name         = "aadds.schnerring.net"
  subnet_id           = azurerm_subnet.aadds.id
}