Skip to content

Azure Container Apps

Why Container Apps, and the constraints that shape it

Section titled “Why Container Apps, and the constraints that shape it”

This app is not a generic stateless web service, and that drives every hosting decision:

  • It hosts PowerShell in-process (Microsoft.PowerShell.SDK + the ExchangeOnlineManagement 3.9.x module) and keeps warm, long-lived runspace pools → it must not scale to zero, and it’s memory-heavy.
  • It authenticates to Exchange Online with a system-assigned Managed Identity, and to Purview/IPPS with a certificate (IPPS has no MI path, see the caveats below).
  • It’s stateless between requests (pools are per-instance) → horizontal scale needs no session affinity.

Container Apps fits because it’s container-native, gives native Managed Identity, and - unlike App Service’s fixed ~240 s request ceiling - lets you raise the ingress idle timeout to 30 minutes (requestIdleTimeout), which buys headroom for moderately-long operations until the async-ops model ships. The catch is you must set minReplicas ≥ 1 so the warm pools survive (the default HTTP scale rule is 0→10).

Resources: 1× ACR · 1× managed environment (+ Log Analytics) · 1× container app · 1× Key Vault (IPPS cert) · the app’s system-assigned MI. Optionally Azure Front Door / APIM in front for WAF/TLS/CORS at the edge.

The unusual part: PowerShell hosts in-process (the Microsoft.PowerShell.SDK NuGet travels with dotnet publish), but the ExchangeOnlineManagement module is a PowerShell module, not a NuGet - bake it in at build, fetched from a stage that has pwsh (the .NET images don’t ship it).

The Dockerfile lives at the repository root. It is a three-stage build (fetch the pinned ExchangeOnlineManagement module, restore and publish the web project, runtime), and it bakes in the examples and the cmdlet documentation subtree that the Explorer’s Reference mode needs at runtime. You do not need to build it yourself: the published image is pulled from GHCR (ghcr.io/codewithlaszlo/exonaut:<version>), which is the release flow; the Bicep and deploy flow below describe an earlier, ACR-based alternative that has not yet been reconciled with the GHCR release flow.

Grounded notes (Microsoft Learn):

  • .NET 10 images default to Ubuntu 24.04 Noble; Debian images are no longer shipped (ref).
  • ASP.NET Core containers listen on 8080 since .NET 8 (set via ASPNETCORE_HTTP_PORTS) (ref); the non-root app user (UID 1654) is built in (ref).
  • Linux shared module path is /usr/local/share/powershell/Modules; Save-Module/Save-PSResource downloads without installing (ref).
  • Pin images by @sha256:<digest> for reproducibility/supply-chain safety.

Validate live, item 1: the in-process module path. /opt/psmodules on PSModulePath is the straightforward path, but the in-process SDK runspace constructs its own module path and may not probe it. If Import-Module ExchangeOnlineManagement fails at startup, point the app’s config at the baked dir (the ExoModuleBasePath option) instead of relying on PSModulePath. Stay on the standard Ubuntu aspnet image - chiseled/distroless drops the shell + ICU/tzdata that EXO needs. Never bake secrets or the IPPS cert into a layer.

// Environment - Workload Profiles v2 is required for the premium-ingress idle timeout.
resource env 'Microsoft.App/managedEnvironments@2025-07-01' = {
name: 'exonaut-env'
location: location
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: { customerId: la.properties.customerId, sharedKey: la.listKeys().primarySharedKey }
}
workloadProfiles: [ { name: 'Consumption', workloadProfileType: 'Consumption' } ]
ingressConfiguration: { requestIdleTimeout: 30 } // 4-30 min; default 4 (vs App Service ~240s)
}
}
resource api 'Microsoft.App/containerApps@2025-07-01' = {
name: 'exonaut'
location: location
identity: { type: 'SystemAssigned' } // ← the MI Connect-ExchangeOnline uses
properties: {
environmentId: env.id
configuration: {
activeRevisionsMode: 'Single'
ingress: { external: true, targetPort: 8080, transport: 'http', allowInsecure: false }
registries: [ { server: '${acrName}.azurecr.io', identity: 'system' } ] // AcrPull via MI
secrets: [
{ name: 'ipps-cert', keyVaultUrl: 'https://${kv}.vault.azure.net/secrets/ipps-cert', identity: 'system' }
{ name: 'license-key', keyVaultUrl: 'https://${kv}.vault.azure.net/secrets/exonaut-license', identity: 'system' }
]
}
template: {
scale: {
minReplicas: 2 // ← NEVER 0: keeps warm pools alive (HA = 2)
maxReplicas: 6
rules: [ { name: 'http', http: { metadata: { concurrentRequests: '20' } } } ]
}
containers: [ {
name: 'exonaut'
image: '${acrName}.azurecr.io/exonaut:${imageTag}'
resources: { cpu: json('2.0'), memory: '4.0Gi' } // memory-heavy: PowerShell + EOM + pools
env: [
{ name: 'ExchangeOnline__ConnectionMode', value: 'SystemAssignedManagedIdentity' }
{ name: 'ExchangeOnline__Organization', value: '${tenant}.onmicrosoft.com' }
{ name: 'AzureAd__TenantId', value: tenantId }
{ name: 'AzureAd__ClientId', value: apiAppId }
{ name: 'AzureAd__Audience', value: 'api://${apiAppId}' }
{ name: 'Ipps__CertificatePfx', secretRef: 'ipps-cert' } // cert from Key Vault
{ name: 'License__Key', secretRef: 'license-key' } // license key from Key Vault
]
probes: [
{ type: 'Liveness', httpGet: { path: '/healthz', port: 8080 } }
{ type: 'Readiness', httpGet: { path: '/readyz', port: 8080 }, periodSeconds: 10 }
{ type: 'Startup', httpGet: { path: '/healthz', port: 8080 }, failureThreshold: 30, periodSeconds: 5 } // slow EXO warm-up
]
} ]
}
}
}

Grounded specifics (all from Microsoft.App/.../2025-07-01 templates):

  • Latest stable apiVersion 2025-07-01 (also 2026-01-01); author against Microsoft.App/*, not the legacy Microsoft.Web/*.
  • CPU/memory must be one of the fixed 2:1 combos (0.25/0.5Gi … 4.0/8.0Gi). Consumption caps at 2 cores / 4 Gi; >2/4 Gi needs a dedicated Workload Profile (ref).
  • requestIdleTimeout lives on the environment (ingressConfiguration), needs premium ingress (Workload Profiles v2) - it is not an app-ingress field.
  • Probes map liveness → /healthz, readiness → /readyz (the real pool probe); a startup probe with a high failureThreshold keeps the slow PowerShell + EOM warm-up from being killed.
  • Key Vault secret ref = keyVaultUrl + identity; ACR MI pull = registries[].identity. Key Vault references are not supported via the Az PowerShell create path; use CLI, ARM or Bicep.
Terminal window
PRINCIPAL=$(az containerapp identity show -n exonaut -g $RG --query principalId -o tsv)
# (a) Image pull
az role assignment create --assignee $PRINCIPAL --role AcrPull \
--scope $(az acr show -n $ACR --query id -o tsv)
az acr config authentication-as-arm update -r $ACR --status enabled # ARM-audience tokens for MI pull
# (b) MI → Exchange Online:
# 1. assign the O365 Exchange Online app role Exchange.ManageAsApp
# (app role dc50a0fb-09a3-484d-be87-e023b12c6440 on resource appId 00000002-0000-0ff1-ce00-000000000000)
# to $PRINCIPAL via New-MgServicePrincipalAppRoleAssignment;
# 2. assign a Microsoft Entra directory role to $PRINCIPAL at directory scope:
# "Global Reader" (read-only) or "Exchange Administrator" (write). Do NOT use Global Administrator.
# (c) Key Vault (IPPS cert + license key)
az role assignment create --assignee $PRINCIPAL --role "Key Vault Secrets User" \
--scope $(az keyvault show -n $KV --query id -o tsv)
# (d) Store the license key (from the vendor, see the license key page) as a Key Vault secret;
# the app reads it via the "license-key" secretRef -> License__Key env var above.
az keyvault secret set --vault-name $KV --name exonaut-license --value "$LICENSE_KEY"

Changing the license key later is a secret change, not a code change: update the Key Vault secret with the same az keyvault secret set command, then roll a new revision so the running containers pick it up, same as any other env-var/secret change (see caveat 3 in the caveats below).

Grounded: Connect-ExchangeOnline -ManagedIdentity setup (ref), AcrPull MI pull (ref), Key Vault secret reference (ref).

Terminal window
az extension add --name containerapp --upgrade
az acr build --registry $ACR --image exonaut:$GIT_SHA --file Dockerfile . # server-side build, no local Docker
az group create -n $RG -l $LOCATION
az deployment group create -g $RG --template-file infra/main.bicep \
--parameters imageTag=$GIT_SHA acrName=$ACR tenant=$TENANT tenantId=$TENANT_ID apiAppId=$API_APP_ID
# then the §5 role grants, then redeploy so the MI-backed pull + connect take effect.

CI/CD: azure/login@v1 (prefer OIDC federated credentials over an AZURE_CREDENTIALS client secret) + azure/container-apps-deploy-action@v1, image tag = ${{ github.sha }} (never latest). (ref)

  1. Validate live: MI → EXO from Container Apps is not in the docs. Microsoft’s “managed identities to connect to EXO” page lists only Azure Automation and Azure VMs as supported hosts. The Azure IMDS token flow is identical, so it should work on ACA - but confirm before a production cutover. Fallback if it doesn’t: certificate app-only auth (Connect-ExchangeOnline -Certificate), which the app already supports.
  2. Purview/IPPS cannot use Managed Identity at all - Connect-IPPSSession has no -ManagedIdentity parameter. It requires a certificate (loaded from Key Vault as an X509Certificate2 at runtime - not -CertificateThumbprint, which is Windows-only). That’s why the Key Vault secret is in the design.
  3. Every redeploy = cold pools per instance. Env-var/secret changes apply only on a new revision, and each new replica re-runs Connect-ExchangeOnline (~15 s+). minReplicas ≥ 2 + rolling revisions keep warm replicas serving during a deploy.
  4. The 30-min ingress timeout still caps long operations. Better than App Service’s ~240 s, but mailbox-move execution / large audit searches / wide reports still need the async-ops model (the 202 + status resource pattern) eventually; see Status.
  5. Observability - wire OpenTelemetry / Application Insights (currently console-only) before production; the Bicep above sends container logs to Log Analytics, which is the baseline.

Low-volume admin traffic but must stay warm, so the cost is one or two small always-on replicas plus the edge, not per request. ACA consumption has a monthly free grant (180k vCPU-s + 360k GiB-s

  • 2M requests) and a reduced idle rate for quiet always-on replicas. A lean pilot runs one replica with ACR Basic, Key Vault and Application Insights; production HA runs two replicas behind Front Door Standard. Price either shape for your region with the Azure Pricing Calculator. Levers: reserved or savings-plan pricing on the always-on compute, right-sized memory (2-4 GiB is likely enough), and skip APIM unless you need it (the app self-handles JWT auth + per-principal rate limiting; Front Door’s WAF covers the edge).
  • Operations for the runtime behaviour this design has to keep warm
  • Architecture for the request pipeline
  • Status for what is on hold and why