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
- You run performance or soak tests in CI/CD (Azure Pipelines / GitHub Actions) and need the Load Testing resource provisioned as code, not clicked once and forgotten.
- You want the load-test engine to authenticate to Key Vault (for endpoint secrets, client certs, or to read the CMK) via a managed identity instead of secrets baked into the
.jmx. - You operate under compliance that mandates customer-managed key encryption for all data at rest, including test artifacts and results.
- You manage more than one environment (dev / staging / prod) or more than one app team and want identical, auditable Load Testing resources per scope.
- You need the resource ID / data-plane URI as a Terraform output to feed a
loadtestCLI step, anaz load test createcall, or a downstream RBAC assignment.
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 config — live/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 config — live/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
- Co-locate the resource with the target. Set
locationto the same region as the system under test. Engines in a different region add network latency to every sample and quietly inflate your p95/p99 — the single most common cause of “the load test is slower than prod.” - Use a user-assigned identity for anything secret-backed or CMK-encrypted. A system-assigned identity dies with the resource and can’t be pre-granted RBAC; a user-assigned identity lets you wire Key Vault and target permissions before the resource exists, avoiding the chicken-and-egg first apply. Reuse the same identity for
cmk_identity_idanduser_assigned_identity_ids. - Encrypt with a customer-managed key in regulated subscriptions. Test results often contain real request/response payloads. Set
cmk_key_vault_key_idto a versioned key on a vault with purge protection and soft delete enabled, and grant the identityWrap/Unwrap/Get— without those, the apply fails at encryption setup. - Control cost at the test, not the resource. The Load Testing resource itself is effectively free to keep around; you pay per virtual-user-hour when a run executes. Don’t destroy/recreate the resource between runs — cap engine count and test duration in the test plan instead, and tag with
cost-centerso VUH charges are attributable. - Name by scope, lock the version. Use a
lt-<workload>-<env>convention (lt-checkout-prod) so RBAC and cost reports read cleanly, and always pin the module?ref=v1.0.0so an upstream change to identity or encryption wiring never lands silently in prod. - Grant the system identity
Monitoring Readeron the target. Server-side metric correlation is the whole point of managed load testing; without that role assignment the dashboard shows client-side numbers only and you lose the ability to pinpoint where the bottleneck is.