← Back to Guides

Deploying API Management via Azure DevOps

IntermediateAzure DevOps2026-03-14

APIM Deployment Strategy

Deploying API Management requires managing three layers:

Layer What How
Infrastructure APIM instance, networking, identity Bicep / Terraform
API Definitions APIs, operations, schemas OpenAPI specs + Bicep/ARM
Policies Security, transformation, caching XML policy files

Microsoft Reference: DevOps and CI/CD for APIM

Project Structure

apim/
├── infra/
│   ├── main.bicep                    # APIM infrastructure
│   ├── modules/
│   │   ├── apim-instance.bicep       # APIM service
│   │   ├── apim-logger.bicep         # Application Insights logger
│   │   ├── apim-products.bicep       # Products and groups
│   │   └── apim-diagnostics.bicep    # Diagnostic settings
│   └── parameters/
│       ├── dev.json
│       └── prod.json
├── apis/
│   ├── orders-api/
│   │   ├── v1/
│   │   │   ├── openapi.json          # OpenAPI spec
│   │   │   └── policy.xml            # API-level policy
│   │   └── v2/
│   │       ├── openapi.json
│   │       └── policy.xml
│   ├── customers-api/
│   │   ├── openapi.json
│   │   └── policy.xml
│   └── global-policy.xml             # Global (all APIs) policy
├── policies/
│   ├── fragments/
│   │   ├── rate-limiting.xml
│   │   ├── cors-standard.xml
│   │   └── jwt-validation.xml
│   └── products/
│       ├── internal-product.xml
│       └── external-product.xml
├── pipeline-infra.yml                # Infrastructure pipeline
└── pipeline-apis.yml                 # API deployment pipeline

Infrastructure Pipeline

Bicep Module for APIM Instance

// modules/apim-instance.bicep
param location string
param environment string
param publisherEmail string
param publisherName string
param subnetId string = ''

var skuName = environment == 'prod' ? 'Standard' : 'Developer'

resource apim 'Microsoft.ApiManagement/service@2023-05-01-preview' = {
  name: 'apim-enterprise-${environment}'
  location: location
  sku: {
    name: skuName
    capacity: 1
  }
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    publisherEmail: publisherEmail
    publisherName: publisherName
    virtualNetworkType: !empty(subnetId) ? 'External' : 'None'
    virtualNetworkConfiguration: !empty(subnetId) ? {
      subnetResourceId: subnetId
    } : null
    customProperties: {
      'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls10': 'False'
      'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls11': 'False'
      'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Tls10': 'False'
      'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Backend.Protocols.Tls11': 'False'
      'Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Ciphers.TripleDes168': 'False'
    }
  }
}

output apimId string = apim.id
output apimName string = apim.name
output gatewayUrl string = apim.properties.gatewayUrl
output managedIdentityPrincipalId string = apim.identity.principalId

Pipeline Definition

# pipeline-infra.yml
trigger:
  branches:
    include:
      - main
  paths:
    include:
      - apim/infra/*

parameters:
  - name: environment
    displayName: 'Target Environment'
    type: string
    default: 'dev'
    values: ['dev', 'prod']

variables:
  - group: apim-infra-settings

stages:
  - stage: Validate
    displayName: 'Validate Infrastructure'
    jobs:
      - job: ValidateBicep
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: AzureCLI@2
            displayName: 'Bicep What-If'
            inputs:
              azureSubscription: '${{ parameters.environment }}-service-connection'
              scriptType: 'bash'
              scriptLocation: 'inlineScript'
              inlineScript: |
                az deployment group what-if \
                  --resource-group rg-integration-${{ parameters.environment }} \
                  --template-file apim/infra/main.bicep \
                  --parameters @apim/infra/parameters/${{ parameters.environment }}.json

  - stage: Deploy
    displayName: 'Deploy Infrastructure'
    dependsOn: Validate
    jobs:
      - deployment: DeployAPIM
        environment: 'apim-infra-${{ parameters.environment }}'
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self

                - task: AzureCLI@2
                  displayName: 'Deploy APIM Infrastructure'
                  inputs:
                    azureSubscription: '${{ parameters.environment }}-service-connection'
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      az deployment group create \
                        --resource-group rg-integration-${{ parameters.environment }} \
                        --template-file apim/infra/main.bicep \
                        --parameters @apim/infra/parameters/${{ parameters.environment }}.json \
                        --verbose

API Deployment Pipeline

Import APIs from OpenAPI Specs

# pipeline-apis.yml
trigger:
  branches:
    include:
      - main
  paths:
    include:
      - apim/apis/*
      - apim/policies/*

variables:
  - group: apim-api-settings

stages:
  - stage: ValidateSpecs
    displayName: 'Validate API Specifications'
    jobs:
      - job: ValidateOpenAPI
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: UsePythonVersion@0
            inputs:
              versionSpec: '3.x'

          - script: |
              pip install openapi-spec-validator
              for spec in $(find apim/apis -name "openapi.json"); do
                echo "Validating: $spec"
                python -c "
              from openapi_spec_validator import validate
              import json
              with open('$spec') as f:
                  validate(json.load(f))
              print('Valid: $spec')
              "
              done
            displayName: 'Validate OpenAPI Specifications'

  - stage: DeployAPIs
    displayName: 'Deploy APIs'
    dependsOn: ValidateSpecs
    jobs:
      - deployment: DeployToAPIM
        environment: 'apim-apis-dev'
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self

                # Deploy global policy
                - task: AzureCLI@2
                  displayName: 'Apply global policy'
                  inputs:
                    azureSubscription: 'dev-service-connection'
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      az apim update \
                        --resource-group rg-integration-dev \
                        --name apim-enterprise-dev \
                        --set properties.customProperties."Microsoft.WindowsAzure.ApiManagement.Gateway.Security.Protocols.Tls10"="False"

                # Deploy Orders API v1
                - task: AzureCLI@2
                  displayName: 'Deploy Orders API v1'
                  inputs:
                    azureSubscription: 'dev-service-connection'
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      # Import API
                      az apim api import \
                        --resource-group rg-integration-dev \
                        --service-name apim-enterprise-dev \
                        --api-id orders-api-v1 \
                        --path orders/v1 \
                        --specification-format OpenApiJson \
                        --specification-path apim/apis/orders-api/v1/openapi.json \
                        --display-name "Orders API v1" \
                        --protocols https \
                        --subscription-required true

                      # Apply API policy
                      az apim api policy create-or-update \
                        --resource-group rg-integration-dev \
                        --service-name apim-enterprise-dev \
                        --api-id orders-api-v1 \
                        --xml-policy @apim/apis/orders-api/v1/policy.xml

                # Deploy Orders API v2
                - task: AzureCLI@2
                  displayName: 'Deploy Orders API v2'
                  inputs:
                    azureSubscription: 'dev-service-connection'
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      az apim api import \
                        --resource-group rg-integration-dev \
                        --service-name apim-enterprise-dev \
                        --api-id orders-api-v2 \
                        --path orders/v2 \
                        --specification-format OpenApiJson \
                        --specification-path apim/apis/orders-api/v2/openapi.json \
                        --display-name "Orders API v2" \
                        --protocols https \
                        --subscription-required true

                      az apim api policy create-or-update \
                        --resource-group rg-integration-dev \
                        --service-name apim-enterprise-dev \
                        --api-id orders-api-v2 \
                        --xml-policy @apim/apis/orders-api/v2/policy.xml

                # Link APIs to Products
                - task: AzureCLI@2
                  displayName: 'Configure Products'
                  inputs:
                    azureSubscription: 'dev-service-connection'
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      # Add APIs to products
                      az apim product api add \
                        --resource-group rg-integration-dev \
                        --service-name apim-enterprise-dev \
                        --product-id standard-product \
                        --api-id orders-api-v2

  - stage: TestAPIs
    displayName: 'Test APIs'
    dependsOn: DeployAPIs
    jobs:
      - job: RunAPITests
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: |
              # Get subscription key
              KEY=$(az apim subscription show \
                --resource-group rg-integration-dev \
                --service-name apim-enterprise-dev \
                --subscription-id test-subscription \
                --query primaryKey -o tsv)

              # Test Orders API v2
              STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
                "https://apim-enterprise-dev.azure-api.net/orders/v2/orders" \
                -H "Ocp-Apim-Subscription-Key: $KEY")

              if [ "$STATUS" -eq 200 ]; then
                echo "API test passed (HTTP $STATUS)"
              else
                echo "API test failed (HTTP $STATUS)"
                exit 1
              fi
            displayName: 'Run API smoke tests'

Bicep-Based API Deployment

For a fully declarative approach, define APIs in Bicep:

// modules/apim-apis.bicep
param apimName string
param environment string

resource apim 'Microsoft.ApiManagement/service@2023-05-01-preview' existing = {
  name: apimName
}

// Version Set
resource ordersVersionSet 'Microsoft.ApiManagement/service/apiVersionSets@2023-05-01-preview' = {
  parent: apim
  name: 'orders-api-version-set'
  properties: {
    displayName: 'Orders API'
    versioningScheme: 'Segment'
  }
}

// API v2
resource ordersApiV2 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' = {
  parent: apim
  name: 'orders-api-v2'
  properties: {
    displayName: 'Orders API v2'
    path: 'orders'
    apiVersion: 'v2'
    apiVersionSetId: ordersVersionSet.id
    protocols: ['https']
    subscriptionRequired: true
    format: 'openapi+json'
    value: loadTextContent('../../apis/orders-api/v2/openapi.json')
    serviceUrl: 'https://la-standard-${environment}.azurewebsites.net/api'
  }
}

// API Policy
resource ordersPolicy 'Microsoft.ApiManagement/service/apis/policies@2023-05-01-preview' = {
  parent: ordersApiV2
  name: 'policy'
  properties: {
    format: 'rawxml'
    value: loadTextContent('../../apis/orders-api/v2/policy.xml')
  }
}

// Product
resource standardProduct 'Microsoft.ApiManagement/service/products@2023-05-01-preview' = {
  parent: apim
  name: 'standard-product'
  properties: {
    displayName: 'Standard APIs'
    subscriptionRequired: true
    approvalRequired: environment == 'prod'
    state: 'published'
  }
}

// Link API to Product
resource productApi 'Microsoft.ApiManagement/service/products/apis@2023-05-01-preview' = {
  parent: standardProduct
  name: ordersApiV2.name
}

// Named Values
resource namedValue 'Microsoft.ApiManagement/service/namedValues@2023-05-01-preview' = {
  parent: apim
  name: 'backend-url'
  properties: {
    displayName: 'Backend URL'
    value: 'https://la-standard-${environment}.azurewebsites.net'
    secret: false
  }
}

// Key Vault Named Value
resource kvNamedValue 'Microsoft.ApiManagement/service/namedValues@2023-05-01-preview' = {
  parent: apim
  name: 'api-secret-key'
  properties: {
    displayName: 'API Secret Key'
    keyVault: {
      secretIdentifier: 'https://kv-integration-${environment}.vault.azure.net/secrets/ApiSecretKey'
    }
    secret: true
  }
}

Policy Management

Policy File Structure

<!-- apim/apis/orders-api/v2/policy.xml -->
<policies>
    <inbound>
        <base />
        <!-- Rate limiting -->
        <rate-limit-by-key calls="100" renewal-period="60"
            counter-key="@(context.Subscription?.Key ?? context.Request.IpAddress)" />

        <!-- JWT Validation -->
        <validate-jwt header-name="Authorization" require-scheme="Bearer"
                      failed-validation-httpcode="401">
            <openid-config url="{{aad-openid-config-url}}" />
            <audiences>
                <audience>{{api-audience}}</audience>
            </audiences>
        </validate-jwt>

        <!-- Correlation ID -->
        <set-header name="X-Correlation-Id" exists-action="skip">
            <value>@(context.RequestId.ToString())</value>
        </set-header>
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
        <set-header name="X-Powered-By" exists-action="delete" />
        <set-header name="X-AspNet-Version" exists-action="delete" />
    </outbound>
    <on-error>
        <base />
        <set-header name="Content-Type" exists-action="override">
            <value>application/json</value>
        </set-header>
        <set-body>@{
            return new JObject(
                new JProperty("error", new JObject(
                    new JProperty("code", context.Response.StatusCode),
                    new JProperty("message", context.LastError.Message),
                    new JProperty("requestId", context.RequestId)
                ))
            ).ToString();
        }</set-body>
    </on-error>
</policies>

Environment-Specific Named Values

Use named values (referenced with {{name}}) to avoid environment-specific policies:

// Dev environment
resource devBackendUrl 'Microsoft.ApiManagement/service/namedValues@2023-05-01-preview' = {
  parent: apim
  name: 'backend-base-url'
  properties: {
    displayName: 'Backend Base URL'
    value: 'https://la-standard-dev.azurewebsites.net'
  }
}

// The policy references {{backend-base-url}} — same policy works in all environments

APIOps (GitOps for APIM)

Using the APIOps Toolkit

Microsoft provides an APIOps toolkit for extracting and publishing APIM configurations:

# Extract current APIM configuration
- task: AzureCLI@2
  displayName: 'Extract APIM configuration'
  inputs:
    azureSubscription: 'dev-service-connection'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      # Using the APIOps extractor
      dotnet run --project tools/extractor -- \
        --sourceApimName apim-enterprise-dev \
        --resourceGroup rg-integration-dev \
        --fileFolder $(Build.ArtifactStagingDirectory)/apim-extract

Microsoft Reference: APIOps with Azure DevOps

Post-Deployment Verification

- stage: Verify
  displayName: 'Verify Deployment'
  dependsOn: DeployAPIs
  jobs:
    - job: VerifyAPIM
      pool:
        vmImage: 'ubuntu-latest'
      steps:
        - task: AzureCLI@2
          displayName: 'Verify APIM health'
          inputs:
            azureSubscription: 'dev-service-connection'
            scriptType: 'bash'
            scriptLocation: 'inlineScript'
            inlineScript: |
              # Check APIM is running
              GATEWAY_URL=$(az apim show \
                --resource-group rg-integration-dev \
                --name apim-enterprise-dev \
                --query gatewayUrl -o tsv)
              echo "Gateway URL: $GATEWAY_URL"

              # List deployed APIs
              az apim api list \
                --resource-group rg-integration-dev \
                --service-name apim-enterprise-dev \
                --output table

              # Check API connectivity
              STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
                "${GATEWAY_URL}/orders/v2/health" \
                -H "Ocp-Apim-Subscription-Key: $(testSubscriptionKey)")
              echo "Health check: HTTP $STATUS"

Best Practices

  1. Separate infrastructure and API deployments — APIM provisioning is slow (~30 min), API imports are fast
  2. Use Bicep for infrastructure and CLI/Bicep for API definitions
  3. Store OpenAPI specs in source control alongside policies
  4. Use Named Values for environment-specific configuration in policies
  5. Validate OpenAPI specs in the CI stage before deployment
  6. Test APIs after deployment with automated smoke tests
  7. Use policy fragments for reusable policy blocks
  8. Implement approval gates on production API deployments
  9. Version APIs using version sets for clean lifecycle management
  10. Back up APIM configuration regularly using the management API or APIOps extractor

Official Microsoft Resources