Quick take — Provision Azure API Management with Terraform: a var-driven azurerm module covering SKU sizing, system-assigned identity, named values, a sample API/policy, and diagnostics for production gateways. 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 "api_management" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-api-management?ref=v1.0.0"
name = "..." # Globally unique APIM name; becomes `<name>.azure-api.ne…
resource_group_name = "..." # Resource group that holds the service.
location = "..." # Azure region (e.g. `centralindia`).
publisher_name = "..." # Publisher name shown in the developer portal.
publisher_email = "..." # Publisher email; receives APIM notifications.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure API Management (APIM) is a managed gateway that sits in front of your backend APIs and gives you a single, governed front door: request routing, throttling, JWT validation, transformation, response caching, a developer portal, and per-product subscription keys. The control plane is one big azurerm_api_management resource, but a real deployment is never just that resource — you also need a publisher identity, a managed identity so the gateway can pull secrets from Key Vault, named values for environment-specific config, diagnostic settings wired to Log Analytics, and at least one API with a policy attached.
Wrapping all of that in a reusable Terraform module matters more for APIM than for most Azure services for two practical reasons. First, the SKU choice is expensive and slow: Developer has no SLA, Premium is billed per scale unit and per region, and changing tiers can take 30–45 minutes to provision. You want that decision behind a single, validated variable so nobody fat-fingers a Premium_4 into a dev subscription. Second, APIM is the security boundary for your whole API estate — TLS protocol floors, min_api_version (locking the management API), and identity-based Key Vault access should be set the same way on every instance. A module turns those guardrails into the default instead of a checklist.
When to use it
- You are standing up one or more API gateways per environment (dev/test/prod) and want identical security and diagnostic posture across all of them.
- You front internal or partner APIs and need JWT validation, rate limiting, IP filtering, or backend transformation applied consistently rather than hand-edited in the portal.
- You want the gateway’s managed identity to read certificates and backend secrets from Key Vault without storing credentials in Terraform state or pipeline variables.
- You are adopting a landing-zone / module-library approach where platform teams publish a versioned APIM module and product teams consume it via a pinned
ref. - Skip this module (or use a thinner one) if you only need an unmanaged, single ad-hoc test instance — though even then the validation guardrails are cheap insurance.
Module structure
terraform-module-azure-api-management/
├── versions.tf # provider + Terraform version pins
├── main.tf # apim instance, identity, named value, sample API + policy, diagnostics
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, name, gateway URL, identity principal id, public IPs
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Premium is the only SKU that supports multi-region + zones; guard it in code.
is_premium = startswith(var.sku_name, "Premium")
default_tags = merge(
{
managed_by = "terraform"
module = "terraform-module-azure-api-management"
component = "api-management"
},
var.tags
)
}
resource "azurerm_api_management" "this" {
name = var.name
location = var.location
resource_group_name = var.resource_group_name
publisher_name = var.publisher_name
publisher_email = var.publisher_email
sku_name = "${var.sku_name}_${var.sku_capacity}"
# Lock the management plane to a known-good API version and disable the
# legacy Git-based config when not explicitly needed.
min_api_version = var.min_api_version
public_network_access_enabled = var.public_network_access_enabled
# Availability zones are only honoured on Premium; pass null otherwise so
# azurerm does not reject a Developer/Standard plan.
zones = local.is_premium ? var.zones : null
identity {
type = var.identity_type
identity_ids = var.identity_type == "UserAssigned" || var.identity_type == "SystemAssigned, UserAssigned" ? var.user_assigned_identity_ids : null
}
# Enforce a modern TLS floor on the gateway. Disable old protocols/ciphers.
security {
enable_backend_tls10 = false
enable_backend_tls11 = false
enable_frontend_tls10 = false
enable_frontend_tls11 = false
tls_rsa_with_aes128_cbc_sha_ciphers_enabled = false
tls_rsa_with_aes128_cbc_sha256_ciphers_enabled = false
tls_rsa_with_aes256_cbc_sha_ciphers_enabled = false
tls_rsa_with_aes256_cbc_sha256_ciphers_enabled = false
triple_des_ciphers_enabled = false
}
tags = local.default_tags
}
# Named values: environment-specific config (e.g. backend base URL) that
# policies reference as {{backend-base-url}}. Secret values can be backed by
# Key Vault via the gateway's managed identity.
resource "azurerm_api_management_named_value" "this" {
for_each = var.named_values
name = each.key
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
display_name = each.value.display_name
secret = each.value.secret
value = each.value.key_vault_secret_id == null ? each.value.value : null
dynamic "value_from_key_vault" {
for_each = each.value.key_vault_secret_id == null ? [] : [each.value.key_vault_secret_id]
content {
secret_id = value_from_key_vault.value
}
}
tags = each.value.tags
}
# A representative API so the module is useful out of the box. Disable it by
# leaving var.sample_api null.
resource "azurerm_api_management_api" "sample" {
count = var.sample_api == null ? 0 : 1
name = var.sample_api.name
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
revision = "1"
display_name = var.sample_api.display_name
path = var.sample_api.path
protocols = ["https"]
service_url = var.sample_api.service_url
subscription_required = var.sample_api.subscription_required
}
# Gateway-style policy on the sample API: rate-limit and strip backend headers.
resource "azurerm_api_management_api_policy" "sample" {
count = var.sample_api == null ? 0 : 1
api_name = azurerm_api_management_api.sample[0].name
resource_group_name = var.resource_group_name
api_management_name = azurerm_api_management.this.name
xml_content = <<XML
<policies>
<inbound>
<base />
<rate-limit calls="${var.sample_api.rate_limit_calls}" renewal-period="${var.sample_api.rate_limit_renewal_seconds}" />
<set-header name="X-Forwarded-Host" exists-action="override">
<value>@(context.Request.OriginalUrl.Host)</value>
</set-header>
</inbound>
<backend>
<base />
</backend>
<outbound>
<base />
<set-header name="Server" exists-action="delete" />
<set-header name="X-Powered-By" exists-action="delete" />
</outbound>
<on-error>
<base />
</on-error>
</policies>
XML
}
# Ship gateway + WebSocket logs and metrics to Log Analytics for observability.
resource "azurerm_monitor_diagnostic_setting" "this" {
count = var.log_analytics_workspace_id == null ? 0 : 1
name = "diag-${var.name}"
target_resource_id = azurerm_api_management.this.id
log_analytics_workspace_id = var.log_analytics_workspace_id
enabled_log {
category = "GatewayLogs"
}
enabled_log {
category = "WebSocketConnectionLogs"
}
metric {
category = "AllMetrics"
}
}
variables.tf
variable "name" {
description = "Globally unique name of the API Management service (becomes <name>.azure-api.net)."
type = string
validation {
condition = can(regex("^[a-zA-Z][a-zA-Z0-9-]{0,48}[a-zA-Z0-9]$", var.name))
error_message = "name must be 2-50 chars, start with a letter, end alphanumeric, and contain only letters, digits and hyphens."
}
}
variable "resource_group_name" {
description = "Name of the resource group that will contain the API Management service."
type = string
}
variable "location" {
description = "Azure region for the API Management service (e.g. centralindia)."
type = string
}
variable "publisher_name" {
description = "Name of the API publisher shown in the developer portal."
type = string
}
variable "publisher_email" {
description = "Email of the API publisher; receives APIM notifications."
type = string
validation {
condition = can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.publisher_email))
error_message = "publisher_email must be a valid email address."
}
}
variable "sku_name" {
description = "API Management pricing tier. Premium is required for zones/multi-region; Consumption is serverless and not supported by this module."
type = string
default = "Developer"
validation {
condition = contains(["Developer", "Basic", "Standard", "Premium"], var.sku_name)
error_message = "sku_name must be one of: Developer, Basic, Standard, Premium."
}
}
variable "sku_capacity" {
description = "Number of scale units. Developer and Basic only support 1."
type = number
default = 1
validation {
condition = var.sku_capacity >= 1 && var.sku_capacity <= 12
error_message = "sku_capacity must be between 1 and 12."
}
}
variable "min_api_version" {
description = "Lock the APIM management API to this version (e.g. 2022-08-01) to disable legacy management endpoints. Null leaves the default."
type = string
default = "2022-08-01"
}
variable "public_network_access_enabled" {
description = "Whether the gateway is reachable from the public internet. Set false when fronting with Private Endpoint / internal VNet."
type = bool
default = true
}
variable "identity_type" {
description = "Managed identity type for the gateway."
type = string
default = "SystemAssigned"
validation {
condition = contains(["SystemAssigned", "UserAssigned", "SystemAssigned, UserAssigned"], var.identity_type)
error_message = "identity_type must be SystemAssigned, UserAssigned, or 'SystemAssigned, UserAssigned'."
}
}
variable "user_assigned_identity_ids" {
description = "List of user-assigned managed identity resource IDs. Required when identity_type includes UserAssigned."
type = list(string)
default = []
}
variable "zones" {
description = "Availability zones for the gateway. Only applied on Premium; ignored otherwise."
type = list(string)
default = null
}
variable "named_values" {
description = "Map of named values exposed to policies. Provide either an inline value or a key_vault_secret_id (versionless URI) for secrets."
type = map(object({
display_name = string
value = optional(string)
secret = optional(bool, false)
key_vault_secret_id = optional(string)
tags = optional(list(string))
}))
default = {}
validation {
condition = alltrue([
for nv in values(var.named_values) :
(nv.value == null) != (nv.key_vault_secret_id == null)
])
error_message = "Each named value must set exactly one of value or key_vault_secret_id."
}
}
variable "sample_api" {
description = "Optional representative API + rate-limit policy to seed the gateway. Set null to skip."
type = object({
name = string
display_name = string
path = string
service_url = string
subscription_required = optional(bool, true)
rate_limit_calls = optional(number, 600)
rate_limit_renewal_seconds = optional(number, 60)
})
default = null
}
variable "log_analytics_workspace_id" {
description = "Log Analytics workspace resource ID for gateway diagnostics. Null disables the diagnostic setting."
type = string
default = null
}
variable "tags" {
description = "Additional tags merged onto the API Management service."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Resource ID of the API Management service."
value = azurerm_api_management.this.id
}
output "name" {
description = "Name of the API Management service."
value = azurerm_api_management.this.name
}
output "gateway_url" {
description = "Public gateway base URL (https://<name>.azure-api.net)."
value = azurerm_api_management.this.gateway_url
}
output "developer_portal_url" {
description = "URL of the (new) developer portal."
value = azurerm_api_management.this.developer_portal_url
}
output "management_api_url" {
description = "URL of the APIM management API endpoint."
value = azurerm_api_management.this.management_api_url
}
output "identity_principal_id" {
description = "Principal ID of the system-assigned managed identity (null if none). Use to grant Key Vault / backend access."
value = try(azurerm_api_management.this.identity[0].principal_id, null)
}
output "identity_tenant_id" {
description = "Tenant ID of the system-assigned managed identity (null if none)."
value = try(azurerm_api_management.this.identity[0].tenant_id, null)
}
output "public_ip_addresses" {
description = "Public IP addresses of the gateway, useful for backend allow-lists."
value = azurerm_api_management.this.public_ip_addresses
}
output "sample_api_id" {
description = "Resource ID of the seeded sample API (null when sample_api is not set)."
value = try(azurerm_api_management_api.sample[0].id, null)
}
How to use it
data "azurerm_key_vault" "platform" {
name = "kv-platform-prod"
resource_group_name = "rg-platform-prod"
}
data "azurerm_key_vault_secret" "backend_url" {
name = "orders-backend-base-url"
key_vault_id = data.azurerm_key_vault.platform.id
}
module "api_management" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-api-management?ref=v1.0.0"
name = "apim-kloudvin-prod"
resource_group_name = "rg-apis-prod"
location = "centralindia"
publisher_name = "KloudVin Platform"
publisher_email = "platform@kloudvin.com"
sku_name = "Premium"
sku_capacity = 2
zones = ["1", "2"]
identity_type = "SystemAssigned"
# Backend URL pulled from Key Vault, exposed to policies as {{orders-backend-base-url}}.
named_values = {
"orders-backend-base-url" = {
display_name = "orders-backend-base-url"
secret = true
key_vault_secret_id = data.azurerm_key_vault_secret.backend_url.versionless_id
}
}
sample_api = {
name = "orders"
display_name = "Orders API"
path = "orders"
service_url = "https://orders.internal.kloudvin.com"
rate_limit_calls = 1200
}
log_analytics_workspace_id = azurerm_log_analytics_workspace.platform.id
tags = {
environment = "prod"
cost_center = "apis"
}
}
# Downstream: grant the gateway's managed identity read access to the Key Vault
# so it can resolve the secret-backed named value at runtime.
resource "azurerm_role_assignment" "apim_kv_reader" {
scope = data.azurerm_key_vault.platform.id
role_definition_name = "Key Vault Secrets User"
principal_id = module.api_management.identity_principal_id
}
# Downstream: register the gateway's egress IPs in a backend NSG allow-list.
output "apim_egress_ips" {
value = module.api_management.public_ip_addresses
}
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/api_management/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-api-management?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
publisher_name = "..."
publisher_email = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/api_management && 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 | Globally unique APIM name; becomes <name>.azure-api.net. |
resource_group_name |
string |
— | Yes | Resource group that holds the service. |
location |
string |
— | Yes | Azure region (e.g. centralindia). |
publisher_name |
string |
— | Yes | Publisher name shown in the developer portal. |
publisher_email |
string |
— | Yes | Publisher email; receives APIM notifications. |
sku_name |
string |
"Developer" |
No | Tier: Developer, Basic, Standard, or Premium. |
sku_capacity |
number |
1 |
No | Scale units (1–12); Developer/Basic only support 1. |
min_api_version |
string |
"2022-08-01" |
No | Locks the management API version to disable legacy endpoints. |
public_network_access_enabled |
bool |
true |
No | Whether the gateway is reachable from the public internet. |
identity_type |
string |
"SystemAssigned" |
No | SystemAssigned, UserAssigned, or both. |
user_assigned_identity_ids |
list(string) |
[] |
No | UAMI resource IDs (required when identity includes UserAssigned). |
zones |
list(string) |
null |
No | Availability zones; applied on Premium only. |
named_values |
map(object) |
{} |
No | Named values for policies; inline value or Key Vault secret ID. |
sample_api |
object |
null |
No | Optional seed API + rate-limit policy. |
log_analytics_workspace_id |
string |
null |
No | Workspace ID for gateway diagnostics; null disables it. |
tags |
map(string) |
{} |
No | Extra tags merged onto the service. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the API Management service. |
name |
Name of the API Management service. |
gateway_url |
Public gateway base URL (https://<name>.azure-api.net). |
developer_portal_url |
URL of the developer portal. |
management_api_url |
URL of the APIM management API endpoint. |
identity_principal_id |
Principal ID of the system-assigned identity (for Key Vault / backend grants). |
identity_tenant_id |
Tenant ID of the system-assigned identity. |
public_ip_addresses |
Gateway public IPs, useful for backend allow-lists. |
sample_api_id |
Resource ID of the seeded sample API (null when not set). |
Enterprise scenario
A retail group exposes its order, inventory, and loyalty services to mobile apps and a handful of B2B partners. The platform team publishes this module at v1.0.0 and stamps a Premium gateway with two scale units across availability zones 1 and 2 in Central India, with public network access disabled and the gateway joined to an internal VNet behind a Private Endpoint. Each product team consumes the module to register its own API and rate-limit policy, while the gateway’s system-assigned identity (surfaced via identity_principal_id) is granted Key Vault Secrets User so backend base URLs and partner API keys never leave Key Vault. Gateway logs flow to a central Log Analytics workspace, giving the SOC a single place to alert on 401/429 spikes across the entire API estate.
Best practices
- Right-size the SKU and treat it as a contract.
Developerhas no SLA — never run production on it. ReservePremiumfor cases that genuinely need zones, multi-region, or VNet integration, since it bills per scale unit per region. The validatedsku_name/sku_capacitysplit keeps aPremium_4out of a sandbox by accident. - Resolve every secret through the managed identity, not state. Use secret-backed
named_valueswithkey_vault_secret_idand grant the gateway Key Vault Secrets User, so backend credentials and certificates never land in Terraform state or pipeline variables. Prefer versionless secret URIs so rotation does not require a redeploy. - Enforce the TLS floor in the module, not per-instance. The
securityblock disables TLS 1.0/1.1 and weak CBC/3DES ciphers on every gateway by default; combine withmin_api_versionto shut the legacy management plane. This is your API perimeter — make the hardened posture the default. - Lock down network exposure for internal estates. Set
public_network_access_enabled = falseand front the gateway with a Private Endpoint or internal VNet mode when APIs are not internet-facing; exportpublic_ip_addressesonly to seed backend allow-lists. - Wire diagnostics on day one. Always pass
log_analytics_workspace_idsoGatewayLogsand metrics are captured from the first request — retrofitting observability after an incident is too late, and APIM’s 429/5xx telemetry is the earliest signal of backend trouble. - Name and tag for the gateway role. Use a stable
apim-<workload>-<env>convention (the name is global and hard to change) and tag with environment plus cost center so the per-unitPremiumspend is attributable in cost reports.