Background
Sections
IntroductionFoundations1. Resource Hierarchy2. Resource Manager3. Identity and RBAC4. Regions and Availability5. Naming and TaggingVirtual Machines1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetVirtual Network1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetBlob Storage1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure SQL Database1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Kubernetes Service1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Container Registry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetMicrosoft Entra ID1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure RBAC1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Functions1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAPI Management1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure App Configuration1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Machine Learning1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure Monitor1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and CheatsheetAzure AI Foundry1. What and Why2. Core Concepts3. Architecture4. Getting Started5. Deployment6. Integrations7. Production8. Interview Questions9. Glossary and Cheatsheet

5. Deployment

24 min read

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.

The infrastructure and configuration deployment tracks

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 outruns azurerm: 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_api block.

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 select is 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/service SKUs of Developer in the production management group.
  • Deny creation without VNet integration in production.
  • Require diagnostic settings routing to the central Log Analytics workspace (DeployIfNotExists).
  • Require the Environment and CostCentre tags.
  • 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=N URL, 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 revert plus a pipeline run is a real rollback. Slower than a revision swap but complete.
  • az apim restore from 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 instance name, location, or resource_group_name forces a new resource; several networking and identity changes are in-place but slow ⚠️ verify the current force-replacement list with terraform plan before believing any written list, including this one. Always read the plan for # forces replacement on 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. A ReadOnly lock in particular blocks routine configuration changes, so it's usually the wrong lock for APIM — CanNotDelete is 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 plan in CI. A nightly workflow that runs plan -detailed-exitcode against every environment and opens an issue on a non-zero diff. Exit code 2 means drift.
  • az deployment group what-if for 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 list then purge.
  • Anything behind a CanNotDelete resource 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 DeployIfNotExists created 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