Creating an Azure Terraform Module for Identity Resources

Recently I have been working to create some Terraform Modules, which allow repeated deployment of Azure Resources. One of these modules is designed to create Identity Resources, based on the Cloud Adoption Framework. I wanted to create something that could be used to repeatedly deploy these Resources across however many Azure Regions are required, and expanded to include additional Resources or Use Cases. For this example, I am creating a module that deploys the Identity section from the Landing Zone Diagram on the CAF Page (link above):

CAF Landing Zone Diagram
CAF Landing Zone Diagram – with the area I wish to create Resources for highlighted in red.

Use Cases

Why use this module? This module will deploy the basic elements required to provide identity services, using Active Directory Domain Controllers. Supporting Resources are also provided, however you may wish to include additional resources as required. Typical uses cases for this module include:

  • Building out core Services in Azure Regions
  • Integrating the module into a wider Azure deployment
  • Creating specific Landing Zones for Resource Deployment
  • Supporting Resource Deployments of specific types – e.g. Citrix environments, 3rd party applications etc.
  • Lab environments / Testing / Development

Module Resources

The Resources the Module will create are listed below:

Two Resource Groups, One with a Key Vault, and the other with Log Analytics, Recovery Services Vault, A VNET with two Virtual Machines, and an Availability Set.

  • A Resource Group (in our Primary Region) that will hold our Key Vault
  • A Key Vault (in our Primary Region) that will hold the randomly generated password for the IaaS VMs.
  • Within each Azure Region we specify, the following Resources will be created:
      • A Resource Group for Identity
      • A Log Analytics Workspace
      • A Recovery Services Vault
      • A Virtual Network, Subnet, and NSG
      • An Availability Set
      • 2x Network Interfaces
      • 2x Virtual Machines
      • 2x Data Disks (attached to the above Virtual Machines, with caching set to none)

✅ The main objective of this module is to allow you to deploy to multiple regions, simply by specifying the Regions in a Variable – without having to copy/change large amounts of code. As an example, if you specify 3 Regions, your deployment would be like the image below:

Example deployment of this Module with 3 Regions deployed to.
Example deployment of the Module, with 3 Regions specified

Module Files and Usage

The structure of the module files is simple:

  • azuredeploy.tf
    • modules\identity-resources:
      • identity.tf
      • outputs.tf
      • variables.tf

A copy of the module is available within my GitHub Repo. The core contents of the module are within the identity.tf and azuredeploy.tf files, which I have outlined below.

Please note, I sporadically update my GitHub content to keep providers/documentation updated, so please check there for the latest files 😊

azuredeploy.tf
#Providers
terraform {
  required_providers {
    azurerm = {
      # Specify what version of the provider we are going to utilise
      source  = "hashicorp/azurerm"
      version = ">= 3.12.0"
    }
    random = {
      source  = "hashicorp/random"
      version = ">= 3.3.2"
    }
  }
}
provider "azurerm" {
  features {
  }
}
# Module Configuration
module "identity-resources" {
  source = "./modules/identity-resources"
  # Global Variables
  pri-location = "uksouth"
  dcsize       = "Standard_D2s_v4"
  dcadmin      = "vmadmin"
  # Region Specific Variables
  regions = {
    region1 = {
      location = "uksouth"
      vnetcidr = ["10.10.0.0/16"]
      snetcidr = ["10.10.1.0/24"]
    }
  }
}

As you can see, within azuredeploy.tf we are defining our Module Variables – which provides inputs to the Module, specifiying how and where our Resources will be created. As you can see, within “# Global Variables” we define the primary location (where the Key Vault is created), and the Size and Administrator username for the Virtual Machines.

Within the section called “# Region Specific Variables” we define, using a map variable, the Regions that we wish to deploy to, along with the variables that are specific to those regions. In the case of this module, three Variables per Region are defined: the Azure Region, the VNET CIDR Range, and the Subnet CIDR Range.

As an example, if you wanted to deploy to two Azure Regions, the section would be updated like the below:

# Region Specific Variables
regions = {
  region1 = {
    location = "uksouth"
    vnetcidr = ["10.10.0.0/16"]
    snetcidr = ["10.10.1.0/24"]
  }
  region2 = {
    location = "eastus"
    vnetcidr = ["10.20.0.0/16"]
    snetcidr = ["10.20.1.0/24"]
  }
}

✅ You can add the required number of Regions to this section for your environment. No need to change any code, just add the Regions as Variables.

identity.tf

Within the identity.tf file the core content of the module is held, and this section defines the Azure Resources that the module will create. The code for this file is shown below:

# Resource Groups
resource "azurerm_resource_group" "rg" {
  for_each = var.regions
  name     = "rg-identity-${each.value.location}"
  location = each.value.location
}
resource "azurerm_resource_group" "rg-kv" {
  name     = "rg-identity-kv-${var.pri-location}"
  location = var.pri-location
}
# KeyVault to Store VM Setup Passwords
# Create KeyVault ID
resource "random_id" "kvname" {
  byte_length = 6
  prefix      = "kv-id-"
}
# Create KeyVault
#Keyvault Creation
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault" "kv1" {
  name                        = random_id.kvname.hex
  location                    = var.pri-location
  resource_group_name         = azurerm_resource_group.rg-kv.name
  enabled_for_disk_encryption = true
  tenant_id                   = data.azurerm_client_config.current.tenant_id
  soft_delete_retention_days  = 7
  purge_protection_enabled    = false

  sku_name = "standard"

  access_policy {
    tenant_id = data.azurerm_client_config.current.tenant_id
    object_id = data.azurerm_client_config.current.object_id

    key_permissions = [
      "Get",
    ]

    secret_permissions = [
      "Get", "Backup", "Delete", "List", "Purge", "Recover", "Restore", "Set",
    ]

    storage_permissions = [
      "Get",
    ]
  }
}
# Create KeyVault VM password
resource "random_password" "vmpassword" {
  length  = 20
  special = true
}
# Create Key Vault Secret
resource "azurerm_key_vault_secret" "vmpassword" {
  name         = "vmpassword"
  value        = random_password.vmpassword.result
  key_vault_id = azurerm_key_vault.kv1.id
  depends_on   = [azurerm_key_vault.kv1]
  content_type = "Default Password for created virtual machines"
}
# Availability Sets
resource "azurerm_availability_set" "as" {
  for_each                    = var.regions
  name                        = "as-identity-${each.value.location}"
  location                    = each.value.location
  resource_group_name         = azurerm_resource_group.rg[each.key].name
  platform_fault_domain_count = 2
}
# Virtual Networks
resource "azurerm_virtual_network" "vnet" {
  for_each            = var.regions
  name                = "vnet-identity-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  address_space       = each.value.vnetcidr
}
# Subnets
resource "azurerm_subnet" "snet" {
  for_each             = var.regions
  name                 = "subnet-identity-${each.value.location}"
  resource_group_name  = azurerm_resource_group.rg[each.key].name
  virtual_network_name = azurerm_virtual_network.vnet[each.key].name
  address_prefixes     = each.value.snetcidr
}
# NSG
resource "azurerm_network_security_group" "nsg" {
  for_each            = var.regions
  name                = "nsg-identity-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
}
# NSG Association
resource "azurerm_subnet_network_security_group_association" "nsga" {
  for_each                  = var.regions
  subnet_id                 = azurerm_subnet.snet[each.key].id
  network_security_group_id = azurerm_network_security_group.nsg[each.key].id
}
# NICs
resource "azurerm_network_interface" "nic1" {
  for_each            = var.regions
  name                = "dc1-nic-identity-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  ip_configuration {
    name                          = "ipconfig-nic1-${each.value.location}"
    subnet_id                     = azurerm_subnet.snet[each.key].id
    private_ip_address_allocation = "Dynamic"
  }
}
resource "azurerm_network_interface" "nic2" {
  for_each            = var.regions
  name                = "dc2-nic-identity-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  ip_configuration {
    name                          = "ipconfig-nic1-${each.value.location}"
    subnet_id                     = azurerm_subnet.snet[each.key].id
    private_ip_address_allocation = "Dynamic"
  }
}
# Data Disks for NTDS
resource "azurerm_managed_disk" "ntds1" {
  for_each             = var.regions
  name                 = "dc1-ntds-identity-${each.value.location}"
  location             = each.value.location
  resource_group_name  = azurerm_resource_group.rg[each.key].name
  storage_account_type = "StandardSSD_LRS"
  create_option        = "Empty"
  disk_size_gb         = "20"
  max_shares           = "2"
}
resource "azurerm_managed_disk" "ntds2" {
  for_each             = var.regions
  name                 = "dc2-ntds-identity-${each.value.location}"
  location             = each.value.location
  resource_group_name  = azurerm_resource_group.rg[each.key].name
  storage_account_type = "StandardSSD_LRS"
  create_option        = "Empty"
  disk_size_gb         = "20"
  max_shares           = "2"
}
# Domain Controller VMs
resource "azurerm_windows_virtual_machine" "dc1" {
  for_each            = var.regions
  name                = "dc1-id-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  size                = var.dcsize
  admin_username      = var.dcadmin
  admin_password      = azurerm_key_vault_secret.vmpassword.value
  availability_set_id = azurerm_availability_set.as[each.key].id
  network_interface_ids = [
    azurerm_network_interface.nic1[each.key].id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "StandardSSD_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2019-Datacenter"
    version   = "latest"
  }
}
resource "azurerm_windows_virtual_machine" "dc2" {
  for_each            = var.regions
  name                = "dc2-id-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  size                = var.dcsize
  admin_username      = var.dcadmin
  admin_password      = azurerm_key_vault_secret.vmpassword.value
  availability_set_id = azurerm_availability_set.as[each.key].id
  network_interface_ids = [
    azurerm_network_interface.nic2[each.key].id,
  ]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "StandardSSD_LRS"
  }

  source_image_reference {
    publisher = "MicrosoftWindowsServer"
    offer     = "WindowsServer"
    sku       = "2019-Datacenter"
    version   = "latest"
  }
}
# Attach NTDS Disks
resource "azurerm_virtual_machine_data_disk_attachment" "ntds1" {
  for_each           = var.regions
  managed_disk_id    = azurerm_managed_disk.ntds1[each.key].id
  virtual_machine_id = azurerm_windows_virtual_machine.dc1[each.key].id
  lun                = "10"
  caching            = "None"
}
resource "azurerm_virtual_machine_data_disk_attachment" "ntds2" {
  for_each           = var.regions
  managed_disk_id    = azurerm_managed_disk.ntds2[each.key].id
  virtual_machine_id = azurerm_windows_virtual_machine.dc2[each.key].id
  lun                = "10"
  caching            = "None"
}
# Recovery Services Vault
resource "azurerm_recovery_services_vault" "rsv" {
  for_each            = var.regions
  name                = "rsv-identity-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  sku                 = "Standard"
  soft_delete_enabled = true
}
# Log Analytics Workspace
resource "azurerm_log_analytics_workspace" "law" {
  for_each            = var.regions
  name                = "law-identity-${each.value.location}"
  location            = each.value.location
  resource_group_name = azurerm_resource_group.rg[each.key].name
  sku                 = "PerGB2018"
  retention_in_days   = 30
}
The Importance of for_each!

One of the key concepts in the above identity.tf file is the use of for_each within the Resource blocks. Using for_each allows each Resource Type to be specified only once, and then created for each Region defined in our Variables.

❗ There is one small exception to this – and that’s when multiple resources need to be created (for example, two Virtual Machines), and you can see in the code I have some Resources which have a 1 and 2 Resource block, so that two Resources are created for each Region. This is a workaround as the use of for_each and count is not supported within a single Resource block.

Let’s take a look at a Resource Group as an example of using for_each:

# Resource Groups
resource "azurerm_resource_group" "rg" {
  for_each = var.regions
  name     = "rg-identity-${each.value.location}"
  location = each.value.location
}

Within the Resource Group block, you can see the use of for_each = var.regions – this allows us to specify that for each Region defined within the Regions Variable map, a Resource should be created. Again within the name attribute we are automatically generating this based on each of the Locations specified within our Variables. This is the same for the Location attribute. As a practical example of how useful and powerful this is, consider the Variables block below:

# Region Specific Variables
regions = {
  region1 = {
    location = "uksouth"
    vnetcidr = ["10.10.0.0/16"]
    snetcidr = ["10.10.1.0/24"]
  }
}

This will provide a single Resource Group, called rg-identity-uksouth within the UK South Azure Region. Now, if we adjust this block to the code shown below, and add two more Regions:

regions = {
   region1 = {
     location = "uksouth"
     vnetcidr = ["10.10.0.0/16"]
     snetcidr = ["10.10.1.0/24"]
   }
   region2 = {
     location = "eastus"
     vnetcidr = ["10.20.0.0/16"]
     snetcidr = ["10.20.1.0/24"]
   }
   region3 = {
     location = "westus"
     vnetcidr = ["10.30.0.0/16"]
     snetcidr = ["10.30.1.0/24"]
   }
 }

This will provide 3 Resource Groups; rg-identity-uksouth, rg-identity-eastus, rg-identity-westus, within the UK South, East US, and West US Azure Regions respectively.

✅ When we consider that all Resources within the module operate like this, thanks to the use of for_each, it becomes clear how quickly we can build Resources across additional Regions.  All we have to do is define our Regions, and Resources will be created by Terraform in each Region. 

Conclusion

I hope this post has been useful in demonstrating how this module works, and how for_each can be used to create repeatable blocks that can be deployed to multiple Regions with ease. Any questions/comments/issues please feel free to reach out via my Contact Form, Twitter, or open an Issue on GitHub! 🐱‍💻

Skip to content