IaC Azure

Terraform Module: Azure API Management — A reusable, policy-ready API gateway

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

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 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/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

TerraformAzureAPI ManagementModuleIaC
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