IaC Azure

Terraform Module: Azure Load Testing — a reusable, fully-managed JMeter/Locust load test resource

Quick take — Provision Azure Load Testing as a reusable Terraform module on azurerm ~> 4.0: managed JMeter/Locust runs, system- or user-assigned identity, customer-managed key encryption, and the outputs CI/CD needs. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "load_testing" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-load-testing?ref=v1.0.0"

  name                = "..."  # Name of the Load Testing resource (3-64 chars; starts w…
  resource_group_name = "..."  # Resource group that holds the resource.
  location            = "..."  # Azure region; keep it equal to the system under test to…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Load Testing is a fully-managed load-generation service. You hand it an Apache JMeter (.jmx) or Locust script, Azure spins up the test engines for you, drives synthetic traffic at your endpoint, and streams back client-side metrics (response time, throughput, error rate) correlated against the server-side metrics of whatever you’re hammering — App Service, AKS, an APIM gateway, a Cosmos DB account. You never patch a load agent or babysit a fleet of VMs; the engine instances are ephemeral and billed per virtual-user-hour.

The Azure resource you actually create is small but deceptively important: azurerm_load_test provisions the Load Testing resource (the regional control-plane account) that owns all your test plans, test runs, and engine pools. The interesting production decisions live on that resource — which managed identity it uses to read secrets from Key Vault and pull metrics from the targets under test, and whether the test artifacts and results are encrypted with a customer-managed key (CMK).

Wrapping it in a module matters because those decisions are exactly the ones that drift when each team clicks through the portal: one resource ends up with no identity (so secret-backed tests silently fail to authenticate), another stores results under a Microsoft-managed key in a regulated subscription, a third lands in eastus while the system it tests lives in westeurope (cross-region latency pollutes every result). The module makes identity, encryption, region, and tagging a single, reviewed, version-pinned decision you stamp out per environment.

When to use it

Skip it if you only ever run a one-off manual test from the portal — the managed resource is cheap to create by hand and the module’s value is in repeatability.

Module structure

terraform-module-azure-load-testing/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # azurerm_load_test + identity / encryption wiring
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # id, name, data-plane URI, identity principal id
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}
# main.tf

resource "azurerm_load_test" "this" {
  name                = var.name
  resource_group_name = var.resource_group_name
  location            = var.location
  description         = var.description

  # System- and/or user-assigned identity. The load-test engine assumes this
  # identity to read secrets/certs from Key Vault and to pull the CMK.
  dynamic "identity" {
    for_each = var.identity_type == null ? [] : [1]
    content {
      type = var.identity_type
      identity_ids = (
        endswith(var.identity_type, "UserAssigned")
        ? var.user_assigned_identity_ids
        : null
      )
    }
  }

  # Optional customer-managed key encryption for test artifacts + results.
  # Requires a user-assigned identity that has Get/Unwrap/Wrap on the key.
  dynamic "encryption" {
    for_each = var.cmk_key_vault_key_id == null ? [] : [1]
    content {
      key_url = var.cmk_key_vault_key_id

      identity {
        type        = "UserAssigned"
        identity_id = var.cmk_identity_id
      }
    }
  }

  tags = var.tags
}
# variables.tf

variable "name" {
  type        = string
  description = "Name of the Azure Load Testing resource (3-64 chars; letters, numbers, hyphens and underscores; must start with a letter or number)."

  validation {
    condition     = can(regex("^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$", var.name))
    error_message = "name must be 3-64 chars, start with a letter or number, and contain only letters, numbers, hyphens or underscores."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Name of the resource group that will hold the Load Testing resource."
}

variable "location" {
  type        = string
  description = "Azure region. Keep this in the SAME region as the system under test so engine-to-target latency does not skew results."
}

variable "description" {
  type        = string
  description = "Free-text description shown in the portal for this Load Testing resource."
  default     = null
}

variable "identity_type" {
  type        = string
  description = "Managed identity type: 'SystemAssigned', 'UserAssigned', or 'SystemAssigned, UserAssigned'. Set null to attach no identity (secret-backed and CMK tests will not work)."
  default     = "SystemAssigned"

  validation {
    condition = var.identity_type == null || contains(
      ["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"],
      var.identity_type
    )
    error_message = "identity_type must be one of: SystemAssigned, UserAssigned, 'SystemAssigned, UserAssigned', or null."
  }
}

variable "user_assigned_identity_ids" {
  type        = list(string)
  description = "Resource IDs of user-assigned managed identities to attach. Required when identity_type includes 'UserAssigned'."
  default     = []

  validation {
    condition     = alltrue([for id in var.user_assigned_identity_ids : can(regex("/userAssignedIdentities/", id))])
    error_message = "Each entry must be a full user-assigned managed identity resource ID."
  }
}

variable "cmk_key_vault_key_id" {
  type        = string
  description = "Versioned Key Vault key ID used to encrypt test artifacts and results (customer-managed key). Set null to use the Microsoft-managed key."
  default     = null
}

variable "cmk_identity_id" {
  type        = string
  description = "Resource ID of the user-assigned identity used to access the CMK. Required when cmk_key_vault_key_id is set, and it must also appear in user_assigned_identity_ids."
  default     = null

  validation {
    condition     = var.cmk_identity_id == null || can(regex("/userAssignedIdentities/", var.cmk_identity_id))
    error_message = "cmk_identity_id must be a full user-assigned managed identity resource ID."
  }
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to the Load Testing resource."
  default     = {}
}
# outputs.tf

output "id" {
  description = "Resource ID of the Azure Load Testing resource."
  value       = azurerm_load_test.this.id
}

output "name" {
  description = "Name of the Azure Load Testing resource."
  value       = azurerm_load_test.this.name
}

output "data_plane_uri" {
  description = "Data-plane URI of the Load Testing resource (used by the 'az load' CLI and the loadtest API to create and run tests)."
  value       = azurerm_load_test.this.data_plane_uri
}

output "identity_principal_id" {
  description = "Principal (object) ID of the system-assigned identity, if one was created. Use it for Key Vault / target-resource RBAC. Null when no system-assigned identity exists."
  value       = try(azurerm_load_test.this.identity[0].principal_id, null)
}

output "identity_tenant_id" {
  description = "Tenant ID of the resource's managed identity, if any. Null when no identity exists."
  value       = try(azurerm_load_test.this.identity[0].tenant_id, null)
}

How to use it

# A user-assigned identity that the load-test engine will use both to read
# endpoint secrets from Key Vault and to unwrap the customer-managed key.
resource "azurerm_user_assigned_identity" "loadtest" {
  name                = "id-loadtest-prod"
  resource_group_name = azurerm_resource_group.perf.name
  location            = azurerm_resource_group.perf.location
}

module "load_testing" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-load-testing?ref=v1.0.0"

  name                = "lt-checkout-prod"
  resource_group_name = azurerm_resource_group.perf.name
  location            = azurerm_resource_group.perf.location
  description         = "Load testing for the checkout API — keep in West Europe with the AKS cluster under test."

  # Attach both identities so the engine has a system identity for target
  # metrics RBAC and a user identity it shares with the CMK.
  identity_type              = "SystemAssigned, UserAssigned"
  user_assigned_identity_ids = [azurerm_user_assigned_identity.loadtest.id]

  # Customer-managed key encryption for test artifacts and results.
  cmk_key_vault_key_id = azurerm_key_vault_key.loadtest.id
  cmk_identity_id      = azurerm_user_assigned_identity.loadtest.id

  tags = {
    environment = "prod"
    team        = "checkout"
    cost-center = "perf-eng"
  }
}

# Downstream reference: grant the resource's SYSTEM-assigned identity the
# "Monitoring Reader" role on the AKS cluster so the engine can correlate
# client-side load metrics with server-side cluster metrics.
resource "azurerm_role_assignment" "loadtest_reads_aks_metrics" {
  scope                = azurerm_kubernetes_cluster.checkout.id
  role_definition_name = "Monitoring Reader"
  principal_id         = module.load_testing.identity_principal_id
}

# Another downstream use: feed the data-plane URI into a CI variable that the
# 'az load test-run create' step consumes.
output "loadtest_data_plane_uri" {
  value = module.load_testing.data_plane_uri
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/load_testing/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-load-testing?ref=v1.0.0"
}

inputs = {
  name = "..."
  resource_group_name = "..."
  location = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/load_testing && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Name of the Load Testing resource (3-64 chars; starts with letter/number; letters, numbers, -, _).
resource_group_name string Yes Resource group that holds the resource.
location string Yes Azure region; keep it equal to the system under test to avoid latency skew.
description string null No Portal description for the resource.
identity_type string "SystemAssigned" No SystemAssigned, UserAssigned, "SystemAssigned, UserAssigned", or null for none.
user_assigned_identity_ids list(string) [] No User-assigned identity resource IDs; required when identity_type includes UserAssigned.
cmk_key_vault_key_id string null No Versioned Key Vault key ID for customer-managed encryption; null uses the Microsoft-managed key.
cmk_identity_id string null No User-assigned identity ID used to access the CMK; required when cmk_key_vault_key_id is set.
tags map(string) {} No Tags applied to the resource.

Outputs

Name Description
id Resource ID of the Azure Load Testing resource.
name Name of the Load Testing resource.
data_plane_uri Data-plane URI consumed by the az load CLI / loadtest API to create and run tests.
identity_principal_id Principal ID of the system-assigned identity (null if none) for Key Vault / target RBAC.
identity_tenant_id Tenant ID of the resource’s managed identity (null if none).

Enterprise scenario

A retail platform team gates every release of its checkout API behind a 30-minute soak test in Azure Pipelines. They stamp out lt-checkout-prod from this module in West Europe — the same region as the AKS cluster under test — with a user-assigned identity that holds Key Vault Get on the API’s bearer-token secret and Unwrap/Wrap on the CMK that encrypts every test result for PCI evidence. The pipeline reads data_plane_uri from the module output to run the JMeter plan, while the system-assigned identity (granted Monitoring Reader on the cluster) lets the service overlay pod CPU and request-queue depth on the same dashboard, so a regression in p95 latency fails the build before it ever reaches customers.

Best practices

TerraformAzureLoad TestingModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading