Deploy the GraphWarden proxy to Azure Container Apps

Azure Container Apps hosts the GraphWarden proxy as a managed container with auto-scaling, integrated Azure AD for Key Vault access, and Azure Monitor telemetry. This page covers ACA-specific deployment via Azure CLI and Bicep, ingress configuration, and scale rules tuned to your Graph request volume.

Azure Container Apps — managed container runtime

Prerequisites

- Azure subscription with the `Microsoft.App` resource provider registered - Azure CLI 2.40+ OR Bicep CLI - A container image published to Azure Container Registry (ACR) or Docker Hub (see [Docker deployment](./docker)) - A user-assigned managed identity with Key Vault `Secret — Get` permission - A Log Analytics workspace (for the Container Apps environment)

Generate an ACA Bicep module with an AI assistant

Generate an Azure Container Apps Bicep module for the GraphWarden proxy.

I need a Bicep template that provisions:
1. A Container Apps environment bound to an existing Log Analytics workspace.
2. A container app pulling the GraphWarden proxy image from Azure Container Registry.
3. External ingress on port 5000 with HTTPS.
4. A user-assigned managed identity for Key Vault access, with AZURE_CLIENT_ID set in env.
5. An HTTP scale rule keyed to concurrent request count.

Reference documentation: https://graphwarden.com/llms.txt

Ask me for my ACR name, Key Vault URI, Graph tenant id, and expected peak request rate.

Reference: llms.txt

Why ACA

Azure Container Apps removes the node-management overhead of Kubernetes while retaining the container runtime model. For the GraphWarden proxy, the relevant ACA capabilities are:

  • Auto-scaling on HTTP concurrency or CPU, including scale-to-zero for non-production environments.
  • Managed identity integration — attach a user-assigned identity to the container app and access Key Vault without storing service-principal secrets.
  • Azure Monitor Log Analytics — container stdout/stderr streams land in a workspace with no extra agent.
  • Revision-based rollouts — traffic split between revisions for canary releases.

ACA is the default recommended deployment target for new proxy installations on Azure. On-premise Windows (see Windows Service install) remains the path for air-gapped or customer-managed Windows environments.

Azure CLI deployment

The snippet below provisions the proxy against an existing Container Apps environment and ACR. Replace the marker values (<resource-group>, <environment-name>, <acr-name>, <identity-resource-id>, <vault-host>, <tenant-id>, <graph-client-id>) with your values.

# Create the container app.
az containerapp create \
  --name graphwarden-proxy \
  --resource-group <resource-group> \
  --environment <environment-name> \
  --image <acr-name>.azurecr.io/graphwarden-proxy:0.1.0 \
  --target-port 5000 \
  --ingress external \
  --user-assigned <identity-resource-id> \
  --min-replicas 1 \
  --max-replicas 10 \
  --cpu 1.0 \
  --memory 2.0Gi \
  --env-vars \
    GraphClient__TenantId=<tenant-id> \
    GraphClient__ClientId=<graph-client-id> \
    KeyVault__VaultUri=https://<vault-host>/ \
    KeyVault__UseManagedIdentity=true \
    AZURE_CLIENT_ID=<identity-client-id> \
    Audit__Enabled=true \
    Audit__DeploymentType=hosted \
  --registry-server <acr-name>.azurecr.io \
  --registry-identity <identity-resource-id>

The --registry-identity flag tells ACA to authenticate to ACR with the attached user-assigned identity (grant the identity AcrPull on the registry first). This eliminates the need for admin credentials on the registry.

The --env-vars values above are the minimum for a hosted deployment. Any setting listed on the environment variables reference can be added to the same flag with an extra key=value entry.

Bicep template

The Bicep snippet below provisions the container app in the same shape as the CLI command. Keep it in source control under an infra/ folder.

param containerAppName string = 'graphwarden-proxy'
param environmentResourceId string
param containerImage string
param identityResourceId string
param identityClientId string
param vaultUri string
param graphTenantId string
param graphClientId string

resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
  name: containerAppName
  location: resourceGroup().location
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${identityResourceId}': {}
    }
  }
  properties: {
    managedEnvironmentId: environmentResourceId
    configuration: {
      ingress: {
        external: true
        targetPort: 5000
        transport: 'auto'
        allowInsecure: false
      }
      registries: [
        {
          server: split(containerImage, '/')[0]
          identity: identityResourceId
        }
      ]
    }
    template: {
      containers: [
        {
          name: 'proxy'
          image: containerImage
          resources: {
            cpu: json('1.0')
            memory: '2.0Gi'
          }
          env: [
            { name: 'GraphClient__TenantId', value: graphTenantId }
            { name: 'GraphClient__ClientId', value: graphClientId }
            { name: 'KeyVault__VaultUri', value: vaultUri }
            { name: 'KeyVault__UseManagedIdentity', value: 'true' }
            { name: 'AZURE_CLIENT_ID', value: identityClientId }
            { name: 'Audit__Enabled', value: 'true' }
            { name: 'Audit__DeploymentType', value: 'hosted' }
          ]
          probes: [
            {
              type: 'Liveness'
              httpGet: { path: '/health', port: 5000 }
              periodSeconds: 30
              timeoutSeconds: 5
              failureThreshold: 3
            }
          ]
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 10
        rules: [
          {
            name: 'http-concurrency'
            http: {
              metadata: {
                concurrentRequests: '50'
              }
            }
          }
        ]
      }
    }
  }
}

output proxyFqdn string = containerApp.properties.configuration.ingress.fqdn

Deploy with az deployment group create --resource-group <rg> --template-file main.bicep --parameters environmentResourceId=... containerImage=.... Output proxyFqdn is the external URL your client applications rewrite Graph calls to.

Ingress and routing

Set ingress.external: true to publish the proxy at a platform-assigned FQDN on port 443. ACA terminates TLS at the ingress and forwards plaintext to the container on targetPort. The proxy does not need to terminate TLS itself — leave ASPNETCORE_URLS at http://+:5000 inside the container.

For a custom domain (for example proxy.contoso.com):

  1. Add the hostname to the container app with az containerapp hostname add.
  2. Create a TXT and CNAME record at your DNS provider pointing to the ACA FQDN.
  3. Bind a certificate — either an ACA-managed certificate (free, auto-renew) or a certificate uploaded to the Container Apps environment.

The proxy accepts identical Graph paths, so client applications rewrite the Graph base URL from https://graph.microsoft.com to https://proxy.contoso.com and send unchanged paths and query strings.

Scale rules

ACA scale rules are based on metrics or HTTP concurrency. For the proxy, the HTTP concurrency rule is the most predictable: each replica handles a configured number of concurrent requests before ACA adds a replica.

A conservative starting configuration:

  • minReplicas: 1 for production (always-on), minReplicas: 0 for non-production.
  • maxReplicas: 10 — tune upward after observing traffic.
  • concurrentRequests: 50 per replica — see sizing for the reasoning behind this threshold.

For Graph request volume that bursts sharply (scheduled reporting jobs, for example), set maxReplicas high enough that the scale-up does not throttle the burst. ACA scale-up takes 15-60 seconds per replica; plan for the cold-start latency if you run at minReplicas: 0.

Managed identity and Key Vault

Attach a user-assigned managed identity to the container app (see the identity block in the Bicep template). Grant the identity Secret — Get on the Key Vault holding the proxy's secrets. The proxy reads the Graph client secret, the ProxyJwt:SigningKey, and the RuleSync:HmacSecret (if sync is enabled) on startup.

Set AZURE_CLIENT_ID to the identity's client id (not the resource id). The Azure.Identity client inside the proxy uses this value to pick the correct identity when multiple are attached. Omitting it when the container has a single user-assigned identity usually works, but explicit is safer.

For multi-vault deployments (one vault per environment, for example), set KeyVault__VaultUri per revision or per environment rather than per image. Image rebuilds are not required when vault URIs change.

Observability

ACA streams container stdout/stderr to the environment's Log Analytics workspace. The proxy emits Serilog JSON events; each line is a structured log with Timestamp, Level, MessageTemplate, and request-specific properties (RequestId, ClientId, GraphPath, Decision).

A Kusto query to find ruleset denials in the last hour:

ContainerAppConsoleLogs_CL
| where TimeGenerated > ago(1h)
| where ContainerAppName_s == "graphwarden-proxy"
| where Log_s contains "\"Decision\":\"deny\""
| project TimeGenerated, Log_s
| order by TimeGenerated desc

For dashboards, extend the Log Analytics workspace with an Azure Monitor workbook or pipe events into Azure Data Explorer for long-term retention. The proxy emits the same events on-prem and in ACA — the sink differs, the schema does not.

Troubleshooting

Container app stuck in Provisioning for more than 5 minutes

The user-assigned managed identity does not have AcrPull on the container registry, or the image tag does not exist. Check az containerapp logs show --name graphwarden-proxy for image-pull errors. Assign AcrPull on the ACR with az role assignment create --role AcrPull --assignee <identity-principal-id> --scope <acr-resource-id> and redeploy.

Ingress FQDN returns 502 Bad Gateway

The container is running but the liveness probe is failing. Inspect logs — the usual cause is KeyVault: access denied. Confirm the managed identity has Secret — Get on the vault and that AZURE_CLIENT_ID matches the identity's client id (not its principal id or resource id).

Proxy issues tokens but Graph calls return 401

The Graph client secret in Key Vault is stale. Rotate the Azure AD app secret, write the new value to Key Vault under the same secret name, and restart the container app revision (az containerapp revision restart). The proxy re-reads Key Vault on startup.

Scale never exceeds minReplicas

The HTTP scale rule is not activating because request rate is below the concurrentRequests threshold, OR the scale rule is misconfigured. Check the ACA portal "Scale" blade; the active rule should show the current concurrent requests per replica. If the value is climbing but replica count is not, verify maxReplicas is higher than minReplicas and that the environment is not at its quota.

Cold start takes longer than 30 seconds

Running at minReplicas: 0 with a large image (Windows containers are typical offenders) produces long cold starts. Options: switch to the Linux variant of the Docker image (see Docker "Running on Linux vs Windows"), or set minReplicas: 1 to keep a warm replica. ACA charges per replica-second, so the tradeoff is cost vs latency.

Last reviewed:

Last reviewed: .