Deploying API Management via Azure DevOps
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
- Separate infrastructure and API deployments — APIM provisioning is slow (~30 min), API imports are fast
- Use Bicep for infrastructure and CLI/Bicep for API definitions
- Store OpenAPI specs in source control alongside policies
- Use Named Values for environment-specific configuration in policies
- Validate OpenAPI specs in the CI stage before deployment
- Test APIs after deployment with automated smoke tests
- Use policy fragments for reusable policy blocks
- Implement approval gates on production API deployments
- Version APIs using version sets for clean lifecycle management
- Back up APIM configuration regularly using the management API or APIOps extractor