5. Deployment
Getting Started proved the gateway exists. This page makes it repeatable, reviewable, and reversible — a parameterised module, remote state, three environments, a pipeline with no secrets in it, and an answer to "roll it back" that works at 2 a.m.
APIM has one property that reshapes every deployment decision on this page, so it goes first.
The one thing that makes APIM different: the resource is slow and the configuration is fast
Creating or materially reconfiguring a classic APIM instance takes tens of minutes ⚠️ verify current figures. Creating an API, a policy, or a product inside an existing instance takes seconds. Those two facts pull in opposite directions, and the resolution is to split your deployment in two:
| Infrastructure deployment | API configuration deployment | |
|---|---|---|
| What it manages | The APIM instance, tier, units, VNet, custom domains, identity, loggers, Key Vault access | APIs, operations, policies, products, subscriptions, named values, backends |
| How often it runs | Rarely — a handful of times a year | Constantly — every API change, every team |
| Owned by | The platform team | Each API team |
| Tool | Terraform (this page's primary), or Bicep | Terraform for a small portfolio; APIOps-style extract/publish for a large one |
| Failure blast radius | The whole gateway | One API |
If you take one thing from this page: do not put a thirty-minute instance create on the critical path of an API team's pull request. Long-lived instances per environment, configuration deployed into them, is the pattern. Ephemeral per-PR gateways are a Consumption-or-v2-tier idea at best.

Tool order
Terraform is primary throughout this article, Ansible is secondary for day-2 and post-provision work, Bicep/ARM is third. For APIM specifically:
- Terraform (
azurerm) — the full worked example below. Excellent for the instance and good for a moderate API portfolio. Its weak spot is scale: a hundred APIs with a hundred policy documents makes for slow plans and painful merge conflicts on one state file. azapi— needed whenever a feature outrunsazurerm: workspaces, some v2-tier properties, new AI-gateway backend features, and anything in preview. Mixing the two providers in one configuration is normal and supported.- Ansible (
azure.azcollection) — genuinely useful here for day-2 operations rather than provisioning: rotating subscription keys, publishing a portal, running backups, purging a soft-deleted instance, and orchestrating the "wait forty minutes then do the next thing" sequences that Terraform handles awkwardly. - Bicep / ARM — Microsoft's own APIM samples and quickstarts are Bicep-first, and Bicep gets new APIM features on day one. If your organisation lives in Azure DevOps, this is a defensible primary choice rather than a third option.
- APIOps — not a fourth IaC tool but a pattern (with Microsoft-published tooling): an
extractor pulls the current configuration of an APIM instance out to a git-friendly folder
structure of OpenAPI specs and policy XML files, and a publisher applies that folder to a
target instance. It exists because Terraform-per-API doesn't scale to a large federated portfolio
⚠️ verify the current tooling name, repository and support status. If you have more than a couple
of dozen APIs owned by more than a couple of teams, evaluate it before writing your fiftieth
azurerm_api_management_apiblock.
The Terraform module
A three-file module, parameterised by environment. main.tf / variables.tf / outputs.tf.
variables.tf
variable "name_prefix" {
description = "Short, lowercase, alphanumeric. Becomes part of the globally unique gateway name."
type = string
}
variable "environment" {
description = "dev | staging | prod"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be dev, staging or prod."
}
}
variable "location" {
type = string
default = "uksouth"
}
variable "sku_name" {
description = "<tier>_<units>, e.g. Developer_1, Standard_2, Premium_2, StandardV2_1."
type = string
}
variable "publisher_name" { type = string }
variable "publisher_email" { type = string }
variable "custom_domain" {
description = "Optional gateway hostname; null to use the default *.azure-api.net."
type = string
default = null
}
variable "key_vault_certificate_id" {
description = "Versionless Key Vault secret ID of the gateway certificate. Required if custom_domain is set."
type = string
default = null
}
variable "subnet_id" {
description = "Subnet for VNet injection. Null for a public instance."
type = string
default = null
}
variable "virtual_network_type" {
description = "None | External | Internal"
type = string
default = "None"
}
variable "log_analytics_workspace_id" { type = string }
variable "tags" {
type = map(string)
default = {}
}
main.tf
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
azapi = { source = "Azure/azapi", version = "~> 2.0" }
}
}
provider "azurerm" {
features {
key_vault {
# APIM pulls its gateway certificate from Key Vault. If a pipeline ever destroys
# the vault, purge protection is what stops the certificate disappearing for good.
purge_soft_delete_on_destroy = false
recover_soft_deleted_key_vaults = true
}
}
}
locals {
base_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
Service = "api-management"
})
# Globally unique: the gateway hostname is DNS in a shared namespace.
apim_name = "apim-${var.name_prefix}-${var.environment}"
}
resource "azurerm_resource_group" "this" {
name = "rg-${var.name_prefix}-apim-${var.environment}"
location = var.location
tags = local.base_tags
}
resource "azurerm_api_management" "this" {
name = local.apim_name
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
publisher_name = var.publisher_name
publisher_email = var.publisher_email
sku_name = var.sku_name
# A user-assigned identity survives the instance being destroyed and recreated,
# which matters when recreation takes half an hour and Key Vault access policies
# or role assignments reference the principal ID.
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.apim.id]
}
virtual_network_type = var.virtual_network_type
dynamic "virtual_network_configuration" {
for_each = var.subnet_id == null ? [] : [1]
content {
subnet_id = var.subnet_id
}
}
dynamic "hostname_configuration" {
for_each = var.custom_domain == null ? [] : [1]
content {
proxy {
host_name = var.custom_domain
key_vault_id = var.key_vault_certificate_id # versionless => auto-renew
default_ssl_binding = true
negotiate_client_certificate = false
}
}
}
# Disable the legacy direct management endpoint; it authenticates outside Entra ID.
# (Property name/availability varies by provider version — verify before relying on it.)
# management_api_enabled = false # ⚠️ verify current azurerm attribute name
security {
enable_backend_ssl30 = false
enable_backend_tls10 = false
enable_backend_tls11 = false
enable_frontend_ssl30 = false
enable_frontend_tls10 = false
enable_frontend_tls11 = false
tls_ecdhe_ecdsa_with_aes128_cbc_sha_ciphers_enabled = false
tls_ecdhe_rsa_with_aes128_cbc_sha_ciphers_enabled = false
# ⚠️ verify the current attribute names against your azurerm version — this block
# has been renamed more than once.
}
tags = local.base_tags
# Classic tiers really do take this long. A provider timeout mid-create leaves an
# orphaned instance that Terraform will then refuse to manage.
timeouts {
create = "120m"
update = "120m"
delete = "120m"
}
}
resource "azurerm_user_assigned_identity" "apim" {
name = "id-${local.apim_name}"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
tags = local.base_tags
}
# --- Telemetry: diagnostic settings are NOT on by default --------------------
resource "azurerm_monitor_diagnostic_setting" "apim" {
name = "diag-${local.apim_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" }
# DeveloperPortalAuditLogs and others exist per tier — ⚠️ verify the current category list
# with: az monitor diagnostic-settings categories list --resource <apim-id>
metric { category = "AllMetrics" }
}
# --- A named value sourced from Key Vault, not from a variable ---------------
resource "azurerm_api_management_named_value" "backend_key" {
name = "backend-api-key"
resource_group_name = azurerm_resource_group.this.name
api_management_name = azurerm_api_management.this.name
display_name = "backend-api-key"
secret = true
value_from_key_vault {
# Versionless secret ID so rotation in Key Vault is picked up automatically.
secret_id = "https://kv-${var.name_prefix}-${var.environment}.vault.azure.net/secrets/backend-api-key"
identity_client_id = azurerm_user_assigned_identity.apim.client_id
}
}
# --- A reusable backend with a circuit breaker -------------------------------
# Circuit breaker / backend pool support has arrived at different times in azurerm;
# azapi is the escape hatch when it lags. ⚠️ verify current azurerm coverage.
resource "azapi_resource" "orders_backend" {
type = "Microsoft.ApiManagement/service/backends@2023-05-01-preview"
name = "orders-backend"
parent_id = azurerm_api_management.this.id
body = {
properties = {
protocol = "http"
url = "https://orders-${var.environment}.internal.contoso.com"
circuitBreaker = {
rules = [{
name = "trip-on-5xx"
failureCondition = {
count = 5
interval = "PT1M"
statusCodeRanges = [{ min = 500, max = 599 }]
}
tripDuration = "PT1M"
acceptRetryAfter = true
}]
}
}
}
}
# --- Global policy: the cross-cutting rules, in one place --------------------
resource "azurerm_api_management_policy" "global" {
api_management_id = azurerm_api_management.this.id
xml_content = file("${path.module}/policies/global.xml")
}
policies/global.xml
Keeping policy XML in its own file rather than a heredoc is worth doing on day one: it gets syntax highlighting, a readable diff, and the ability to be linted.
<policies>
<inbound>
<!-- Every request, every API: a correlation ID and a token check. -->
<set-header name="x-correlation-id" exists-action="skip">
<value>@(context.RequestId.ToString())</value>
</set-header>
<validate-jwt header-name="Authorization"
failed-validation-httpcode="401"
failed-validation-error-message="Unauthorized">
<openid-config url="https://login.microsoftonline.com/{{tenant-id}}/v2.0/.well-known/openid-configuration" />
<audiences>
<audience>{{api-audience}}</audience>
</audiences>
</validate-jwt>
<rate-limit-by-key calls="600" renewal-period="60"
counter-key="@(context.Subscription?.Id ?? context.Request.IpAddress)" />
</inbound>
<backend>
<base />
</backend>
<outbound>
<!-- Never leak the backend's identity or internal error detail. -->
<set-header name="X-Powered-By" exists-action="delete" />
<set-header name="Server" exists-action="delete" />
<base />
</outbound>
<on-error>
<base />
<set-body>@{
return new JObject(
new JProperty("error", "request_failed"),
new JProperty("correlationId", context.RequestId.ToString())
).ToString();
}</set-body>
</on-error>
</policies>
Note {{tenant-id}} and {{api-audience}} — named values, so the same policy file deploys
unchanged to every environment and picks up the environment's own identity configuration.
outputs.tf
output "apim_id" { value = azurerm_api_management.this.id }
output "gateway_url" { value = azurerm_api_management.this.gateway_url }
output "developer_portal_url" { value = azurerm_api_management.this.developer_portal_url }
output "identity_principal_id" { value = azurerm_user_assigned_identity.apim.principal_id }
output "public_ip_addresses" { value = azurerm_api_management.this.public_ip_addresses }
That last output is more useful than it looks: backend firewalls and partner allow-lists are written against the gateway's outbound IPs, and those IPs change when the instance is recreated — which is one more reason recreation is an event, not a routine.
The loop
terraform init -backend-config=backends/prod.hcl
terraform plan -var-file=envs/prod.tfvars -out=tfplan
terraform apply tfplan
Remote state and locking
Local state on a team is a footgun; two engineers applying at once against a resource that takes thirty minutes to converge is a genuinely bad afternoon.
# backends/prod.hcl
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstatecontoso"
container_name = "tfstate"
key = "apim/prod.tfstate"
use_azuread_auth = true # Entra ID auth instead of a storage account key
The azurerm backend uses native blob leases for locking — no separate lock table, unlike the
DynamoDB dance in AWS. The blob is the state and the lease is the lock.
Two honest options for environment separation, and you should pick one and never mix:
- Directory (or repo) per environment, each with its own backend key and
.tfvars. More files, no chance of applying prod with dev's variables, easier to give different pipelines different permissions. This is the one to pick for APIM, because environments are separate instances with genuinely different tiers and networking. - Terraform workspaces, one state per workspace in one backend. Fewer files, but a single
misplaced
terraform workspace selectis a production incident, and per-environment RBAC becomes awkward.
Protect the state file itself: it contains your policy XML, named values that aren't Key Vault references, and subscription keys. Restrict the container with RBAC, enable versioning and soft delete on the storage account, and never let it be public.
Ansible — day-2 operations
Ansible's honest role here is not provisioning. It's the imperative, ordered, "do this then wait then do that" work that Terraform models badly: backups, key rotation, portal publication, and purging a soft-deleted instance so the next create can use the name.
# apim-day2.yml — run with: ansible-playbook apim-day2.yml -e env=prod
- name: API Management day-2 operations
hosts: localhost
connection: local
gather_facts: false
vars:
resource_group: "rg-contoso-apim-{{ env }}"
apim_name: "apim-contoso-{{ env }}"
backup_storage_account: "stapimbackupcontoso"
backup_container: "apim-backups"
tasks:
# Authentication: a service principal via env vars, or (preferably) the
# managed identity of the runner. azure.azcollection reads AZURE_* env vars.
- name: Confirm the instance exists and is healthy
azure.azcollection.azure_rm_apimanagement_info:
resource_group: "{{ resource_group }}"
name: "{{ apim_name }}"
register: apim_state
- name: Fail fast if the gateway is not in a good state
ansible.builtin.assert:
that:
- apim_state.api_management is defined
fail_msg: "APIM {{ apim_name }} not found in {{ resource_group }}"
# Configuration backup. This is APIM's own backup format, restorable with
# `az apim restore` — it is a safety net, not a replacement for git.
- name: Back up APIM configuration to blob storage
ansible.builtin.command:
argv:
- az
- apim
- backup
- --resource-group
- "{{ resource_group }}"
- --name
- "{{ apim_name }}"
- --backup-name
- "{{ apim_name }}-{{ ansible_date_time.date | default(lookup('pipe','date +%F')) }}"
- --storage-account-name
- "{{ backup_storage_account }}"
- --storage-account-container
- "{{ backup_container }}"
- --access-token-type
- "Managed" # ⚠️ verify current auth options for az apim backup
changed_when: true
# Idempotency demonstration: this task converges to a state. Run the playbook
# twice and the second run reports ok, not changed, because the product already
# matches the declared shape.
- name: Ensure the partner product exists with the right terms
azure.azcollection.azure_rm_apimanagementproduct:
resource_group: "{{ resource_group }}"
service_name: "{{ apim_name }}"
product_id: partner
display_name: "Partner APIs"
description: "APIs exposed to contracted partners"
subscription_required: true
approval_required: true
state: present
# ⚠️ verify the exact module name/parameters against your installed
# azure.azcollection version — the APIM modules have been renamed.
Idempotency, shown rather than claimed: run the playbook twice. The first run reports
changed=1 for the product; the second reports ok, because the module reads current state and
only writes a difference. The az apim backup shell-out is not idempotent — it creates a new
backup each time — which is exactly why it's marked changed_when: true rather than pretending
otherwise. That distinction is the honest boundary between what Ansible models well and what it
merely runs.
The other genuinely useful day-2 playbook is the purge: after destroying an instance, purge the soft-deleted record so the name is free.
- name: Purge a soft-deleted APIM so the name can be reused
ansible.builtin.command:
argv: [az, apim, deletedservice, purge, --service-name, "{{ apim_name }}", --location, "{{ location }}"]
when: purge_deleted | default(false) | bool
Bicep / ARM equivalent
Bicep module for the same instance, plus what-if
Bicep gets new APIM features first, and Microsoft's own quickstarts are written in it. If your organisation is Azure DevOps–native, this is a reasonable primary rather than a third choice.
// apim.bicep
@description('Short lowercase prefix; part of the globally unique gateway name.')
param namePrefix string
@allowed(['dev', 'staging', 'prod'])
param environment string
param location string = resourceGroup().location
param publisherName string
param publisherEmail string
param logAnalyticsWorkspaceId string
@description('Tier and capacity, e.g. Developer/1, Standard/2, Premium/2.')
param skuName string = 'Developer'
param skuCapacity int = 1
var apimName = 'apim-${namePrefix}-${environment}'
resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = {
name: 'id-${apimName}'
location: location
}
resource apim 'Microsoft.ApiManagement/service@2023-05-01-preview' = {
name: apimName
location: location
sku: {
name: skuName
capacity: skuCapacity
}
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${identity.id}': {}
}
}
properties: {
publisherName: publisherName
publisherEmail: publisherEmail
// Turn off the legacy management endpoint that authenticates outside Entra ID.
// ⚠️ verify the current property name for your API version.
}
tags: {
Environment: environment
ManagedBy: 'bicep'
}
}
resource api 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' = {
parent: apim
name: 'orders'
properties: {
displayName: 'Orders'
path: 'orders'
protocols: ['https']
subscriptionRequired: true
format: 'openapi'
value: loadTextContent('./specs/orders.yaml')
}
}
resource apiPolicy 'Microsoft.ApiManagement/service/apis/policies@2023-05-01-preview' = {
parent: api
name: 'policy'
properties: {
format: 'rawxml'
value: loadTextContent('./policies/orders.xml')
}
}
resource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {
scope: apim
name: 'diag-${apimName}'
properties: {
workspaceId: logAnalyticsWorkspaceId
logs: [
{ categoryGroup: 'allLogs', enabled: true }
]
metrics: [
{ category: 'AllMetrics', enabled: true }
]
}
}
output gatewayUrl string = apim.properties.gatewayUrl
output principalId string = identity.properties.principalId
loadTextContent is the Bicep feature that makes APIM pleasant: your OpenAPI spec and your policy
XML stay as real files that editors and linters understand, and the template just references them.
Preview the change before applying it:
az deployment group what-if \
--resource-group rg-contoso-apim-prod \
--template-file apim.bicep \
--parameters namePrefix=contoso environment=prod \
publisherName="Contoso" publisherEmail="apis@contoso.com" \
logAnalyticsWorkspaceId="$LAW_ID"
⚠️ Deployment modes: incremental vs. complete
ARM deployments default to incremental mode, which leaves resources in the resource group that aren't in the template alone. Complete mode (
--mode Complete) deletes anything in the resource group that the template doesn't declare.For APIM this is unusually dangerous, for a reason specific to the service: the instance's child entities — APIs, products, policies, named values — are ARM resources too. A complete-mode deployment of a template that declares only the service, or only some of its APIs, is a plausible way to remove API configuration you did not intend to touch ⚠️ verify exactly how complete mode treats child resources for this provider before ever running it in anger. And because recreating an APIM instance takes tens of minutes and produces new outbound IP addresses, the recovery from a complete-mode mistake is measured in hours, not minutes.
Use incremental. If you need "delete what's no longer declared," use deployment stacks, which give you managed deletion with an explicit, reviewable action rather than a mode flag.
CI/CD — GitHub Actions with workload identity federation
No client secrets, no publish profiles, no long-lived credentials in GitHub. The runner presents its OIDC token to Entra ID, which trades it for an Azure access token scoped by a federated credential on an app registration.
One-time setup (per environment):
# App registration + service principal
APP_ID=$(az ad app create --display-name "gh-apim-prod" --query appId -o tsv)
az ad sp create --id "$APP_ID"
# Federated credential: trust this repo's 'prod' environment only.
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "gh-apim-prod",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:contoso/api-platform:environment:prod",
"audiences": ["api://AzureADTokenExchange"]
}'
# Least privilege: scope to the resource group, not the subscription.
az role assignment create --assignee "$APP_ID" \
--role "Contributor" \
--scope "/subscriptions/$SUB_ID/resourceGroups/rg-contoso-apim-prod"
Note the subject string: it pins the trust to one repository and one GitHub environment. A
federated credential subject of repo:contoso/api-platform:ref:refs/heads/main trusts any workflow
on main, which is looser than it sounds once anyone can open a workflow-modifying PR.
# .github/workflows/apim.yml
name: API Management
on:
pull_request:
paths: ['infra/apim/**', 'apis/**', 'policies/**']
push:
branches: [main]
paths: ['infra/apim/**', 'apis/**', 'policies/**']
permissions:
id-token: write # required for OIDC
contents: read
pull-requests: write
env:
TF_DIR: infra/apim
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Catch policy mistakes before they reach a gateway.
- name: Lint policy XML
run: |
for f in policies/*.xml; do
xmllint --noout "$f"
grep -q "<base />" "$f" || { echo "::error file=$f::missing <base /> — parent scope policy will be skipped"; exit 1; }
done
- name: Lint OpenAPI specs
run: npx --yes @redocly/cli lint apis/*.yaml
plan:
needs: validate
runs-on: ubuntu-latest
environment: dev
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v3
- name: Terraform plan
working-directory: ${{ env.TF_DIR }}
env:
ARM_USE_OIDC: "true"
ARM_USE_AZUREAD: "true"
run: |
terraform init -backend-config=backends/dev.hcl
terraform plan -var-file=envs/dev.tfvars -no-color -out=tfplan
# Post the plan on the PR so a human reads it before prod ever sees it.
deploy-prod:
if: github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-latest
environment: prod # ← manual approval gate lives here
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID_PROD }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID_PROD }}
- uses: hashicorp/setup-terraform@v3
- name: Terraform apply
working-directory: ${{ env.TF_DIR }}
env:
ARM_USE_OIDC: "true"
ARM_USE_AZUREAD: "true"
run: |
terraform init -backend-config=backends/prod.hcl
terraform apply -auto-approve -var-file=envs/prod.tfvars
- name: Smoke test the gateway
run: |
URL=$(terraform -chdir=${{ env.TF_DIR }} output -raw gateway_url)
curl -fsS -o /dev/null -w '%{http_code}\n' "$URL/status-0123456789abcdef" || true
The environment: prod line is doing the security work: GitHub environments carry required
reviewers, wait timers, and branch restrictions, and the federated credential is scoped to that
environment, so a workflow that skips the gate cannot obtain a prod token at all.
The <base /> lint in the validate job is worth its four lines. A policy file missing <base />
silently drops the parent scope's authentication — a validate-jwt at global scope that never runs
because an API-scope policy forgot one element. That is a genuine security regression that no Azure
tooling will warn you about.
Azure Pipelines equivalent: the same shape with an AzureCLI@2 task and a service connection
configured for workload identity federation (the "Workload Identity federation (automatic)"
option), plus an environment with approvals and checks. Do not use a service-principal-with-secret
connection for a new project.
Environments
APIM's economics make the environment question sharper than for most services, because a production-shaped instance is not cheap and dev instances are not free.
| Environment | Recommended shape | Why |
|---|---|---|
| dev | One shared Developer tier instance (or Consumption), public networking | Full feature set, lowest classic price, no SLA needed. Do not give each engineer their own — thirty-minute provisioning makes ephemeral instances impractical |
| staging | The same tier and networking as prod, fewer units | The whole point of staging is that its shape matches. A staging instance without VNet injection does not test the thing most likely to break |
| prod | Premium (or the appropriate v2 tier), VNet-integrated, multi-unit, zone-redundant, multi-region if the requirement is real | — |
Where the boundary lives. In Azure, the subscription is the natural blast-radius, quota and policy boundary, so "one subscription per environment" is the answer more often than "one account per environment" is in AWS. For APIM specifically it's also a quota boundary — the number of APIM instances per subscription per region is limited ⚠️ verify current limit — which quietly rules out "one instance per team per environment in one subscription."
Enforce the difference with Azure Policy at the management-group level rather than by convention:
- Deny
Microsoft.ApiManagement/serviceSKUs ofDeveloperin the production management group. - Deny creation without VNet integration in production.
- Require diagnostic settings routing to the central Log Analytics workspace (
DeployIfNotExists). - Require the
EnvironmentandCostCentretags. - Deny public network access on APIM in production ⚠️ verify the current alias for this property.
Environment-specific values belong in .tfvars and named values, never in policy XML:
# envs/prod.tfvars
name_prefix = "contoso"
environment = "prod"
sku_name = "Premium_2"
virtual_network_type = "Internal"
subnet_id = "/subscriptions/.../subnets/snet-apim-prod"
custom_domain = "api.contoso.com"
Rollback and blast radius
APIM has an unusually good rollback story for configuration and an unusually bad one for infrastructure. Know which situation you're in.
Configuration rollback — fast, and the one you'll use.
- Revisions are the mechanism. Every API change should land as a new revision, tested at its
;rev=NURL, then made current. Rolling back is making the previous revision current again — an atomic switch, seconds, no redeploy. This is APIM's answer to a deployment slot swap, and it is the single most valuable operational habit on this page. - Re-apply the previous commit. Because policies and API definitions are IaC,
git revertplus a pipeline run is a real rollback. Slower than a revision swap but complete. az apim restorefrom a backup restores the whole instance's configuration. Heavy-handed — it reverts everything, including other teams' APIs — so it's a disaster tool, not a rollback tool.
Infrastructure rollback — slow, and the one that hurts.
- Property changes that force replacement. In
azurerm, changing the instancename,location, orresource_group_nameforces a new resource; several networking and identity changes are in-place but slow ⚠️ verify the current force-replacement list withterraform planbefore believing any written list, including this one. Always read the plan for# forces replacementon this resource specifically — a replacement means tens of minutes of downtime and new outbound public IPs, which will break every backend firewall rule and partner allow-list written against the old ones. - Tier changes are online but not instant, and not always symmetric. Scaling up and scaling down between certain tiers has restrictions ⚠️ verify current constraints.
- Soft delete blocks re-creation. A destroyed instance keeps its globally unique name for a
retention period. Any "destroy and recreate" recovery plan must include
az apim deletedservice purge, and any accidental destroy is recoverable by restoring the soft-deleted instance rather than rebuilding it — which is a much better outcome, so do not purge reflexively. - Resource locks (
CanNotDelete,ReadOnly) on the APIM resource group are strongly advised in production, and they will make an apply fail in ways that look like a permissions bug. Know the lock exists before you spend an hour on RBAC. AReadOnlylock in particular blocks routine configuration changes, so it's usually the wrong lock for APIM —CanNotDeleteis the useful one. - Key Vault purge protection matters here indirectly: if your gateway certificate lives in a vault with purge protection and something destroys the vault, the certificate is recoverable — which is the point. Do not disable it to make a pipeline tidier.
Blast radius, stated plainly. One APIM instance fronting fifty APIs means a bad global policy is a fifty-API outage, and a failed instance-level change is a fifty-API outage. That is the honest argument for Premium workspaces, or for more than one instance, once the portfolio is large enough that no single change window suits everyone.
Drift detection
Drift is more likely here than almost anywhere else in Azure, because the portal is a genuinely good policy editor and someone will fix a production policy in it during an incident. That's not misbehaviour — it's the right call at 3 a.m. The job is to detect it and reconcile it afterwards.
- Scheduled
terraform planin CI. A nightly workflow that runsplan -detailed-exitcodeagainst every environment and opens an issue on a non-zero diff. Exit code2means drift. az deployment group what-iffor the Bicep path.- Azure Policy compliance state for the guardrails — tier, networking, diagnostics, tags.
- The activity log and Change Analysis answer who and when for the instance resource.
- APIM-specific: revision change logs record who made an API current and when; the developer portal has its own publication state; and gateway logs will show a policy behaving differently before your nightly plan runs. If you adopt the APIOps pattern, the extractor run is your drift detector — extract the live instance into git and look at the diff.
What to do about it: never blind-apply over drift in production. Read the diff, decide whether the portal change was correct (usually it was — someone was fixing something real), and if so port it into the repository so the next apply is a no-op. If it wasn't correct, apply and tell the person.
Teardown
terraform destroy -var-file=envs/dev.tfvars
# Bicep/CLI equivalent for the whole environment
az group delete -n rg-contoso-apim-dev --yes --no-wait
What destroy will not remove:
- The soft-deleted APIM instance, which keeps your globally unique name reserved until the
retention period expires or you purge it explicitly ⚠️ verify the current window. This is the one
that bites;
az apim deletedservice listthenpurge. - Anything behind a
CanNotDeleteresource lock — the destroy fails, often with a message that reads like an authorisation error. - Soft-deleted Key Vaults and certificates, and vaults with purge protection, which cannot be purged early by design.
- Role assignments and diagnostic settings created outside the state file, including ones a
policy
DeployIfNotExistscreated for you. - Custom domain DNS records in Azure DNS or elsewhere — they'll happily point at nothing.
- Backups in the storage account, which are just blobs and will keep billing.
- The user-assigned managed identity, if it was deliberately created outside this module to survive re-creation — which is exactly why it was.
Next: Integrations →
← Back to the Azure API Management overview · ← Previous: Getting Started