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+ theExchangeOnlineManagement3.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).
Topology
Section titled “Topology”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 container image
Section titled “The container image”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 10images 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-rootappuser (UID 1654) is built in (ref). - Linux shared module path is
/usr/local/share/powershell/Modules;Save-Module/Save-PSResourcedownloads 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.
The Container Apps resources (Bicep)
Section titled “The Container Apps resources (Bicep)”// 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(also2026-01-01); author againstMicrosoft.App/*, not the legacyMicrosoft.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). requestIdleTimeoutlives 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 highfailureThresholdkeeps 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.
Identity and access wiring
Section titled “Identity and access wiring”PRINCIPAL=$(az containerapp identity show -n exonaut -g $RG --query principalId -o tsv)
# (a) Image pullaz 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).
Deploy flow and CI/CD
Section titled “Deploy flow and CI/CD”az extension add --name containerapp --upgradeaz acr build --registry $ACR --image exonaut:$GIT_SHA --file Dockerfile . # server-side build, no local Dockeraz group create -n $RG -l $LOCATIONaz 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)
Caveats that matter for this app
Section titled “Caveats that matter for this app”- 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. - Purview/IPPS cannot use Managed Identity at all -
Connect-IPPSSessionhas no-ManagedIdentityparameter. It requires a certificate (loaded from Key Vault as anX509Certificate2at runtime - not-CertificateThumbprint, which is Windows-only). That’s why the Key Vault secret is in the design. - 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. - 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 resourcepattern) eventually; see Status. - Observability - wire OpenTelemetry / Application Insights (currently console-only) before production; the Bicep above sends container logs to Log Analytics, which is the baseline.
Indicative cost
Section titled “Indicative cost”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).
See also
Section titled “See also”- Operations for the runtime behaviour this design has to keep warm
- Architecture for the request pipeline
- Status for what is on hold and why

