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):
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:
- 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:
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! 🐱💻