Azure Container Apps
Container Apps runs the chio sidecar and your app as two containers in one revision. The sidecar is the only ingress target, the app stays bound to localhost, and Bicep wires Key Vault secrets through a managed identity. Reference manifest is deploy/azure/container-app.bicep in the Arc repo. Prerequisites: a resource group, a managed environment with a Log Analytics workspace, a user-assigned managed identity granted get on the Key Vault secrets, and Key Vault entries for the signing key and capability authority URL.
Architecture
The container app exposes a single ingress on port 9090 (the sidecar); the app listens on 8080 over loopback. Two Key Vault-backed secrets resolve through the managed identity. The KEDA HTTP scaler drives replica count from concurrent request volume.
Manifest Walkthrough
The reference is a single Microsoft.App/containerApps@2024-03-01 resource. Each section below maps to one operational concern.
Parameters
The template takes nine parameters. Three are mandatory at deploy time: managedEnvironmentId, userAssignedIdentityId, and the two Key Vault URIs (chioSigningKeySecretUri, chioCapabilityAuthoritySecretUri). The rest default sensibly: location (resourceGroup().location), containerAppName (agent-tool-server), appImage, chioSidecarImage, chioPolicySource (a Blob Storage URL), and chioReceiptSink (cosmosdb://chio-receipts).
Identity and ingress
resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
name: containerAppName
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: { '${userAssignedIdentityId}': {} }
}
properties: {
managedEnvironmentId: managedEnvironmentId
configuration: {
activeRevisionsMode: 'Single'
ingress: {
external: true
targetPort: 9090
transport: 'auto'
allowInsecure: false
}The app runs as a user-assigned managed identity, which needs Key Vault Secrets User on the referenced secrets. User-assigned is preferred over system-assigned because it survives revision-set rotation and can be reused across apps in the same trust boundary. activeRevisionsMode: 'Single' replaces the previous revision on every deploy; switch to 'Multiple' for blue-green (covered below). external: true attaches a public FQDN; targetPort: 9090 binds ingress to the sidecar; allowInsecure: false forces HTTPS at the edge.
Secrets block (Key Vault references)
secrets: [
{
name: 'chio-signing-key'
keyVaultUrl: chioSigningKeySecretUri
identity: userAssignedIdentityId
}
{
name: 'chio-capability-authority-url'
keyVaultUrl: chioCapabilityAuthoritySecretUri
identity: userAssignedIdentityId
}
]
}Each entry declares a named secret backed by a Key Vault URI and the managed identity used to read it. Container Apps caches the resolved value at revision creation. Updating the Key Vault secret does not propagate to a running revision; you have to create a new revision (or set resyncSecrets via az CLI) for the new value to land.
Application container
template: {
containers: [
{
name: 'app'
image: appImage
resources: {
cpu: json('0.75')
memory: '1.5Gi'
}
env: [
{
name: 'CHIO_SIDECAR_URL'
value: 'http://localhost:9090'
}
{
name: 'CHIO_SIDECAR_HEALTH_URL'
value: 'http://localhost:9090/chio/health'
}
]
probes: [
{
type: 'Startup'
httpGet: {
path: '/healthz'
port: 8080
}
initialDelaySeconds: 2
periodSeconds: 2
failureThreshold: 30
}
{
type: 'Liveness'
httpGet: {
path: '/healthz'
port: 8080
}
periodSeconds: 10
failureThreshold: 3
}
]
}CPU is declared as a JSON number (json('0.75') for 0.75 cores); memory uses the Gi suffix. Total container CPU + memory must align with allowed workload profile combinations. The startup probe gives the app 60 seconds (30 attempts × 2s) before liveness takes over.
Sidecar container
{
name: 'chio-sidecar'
image: chioSidecarImage
args: [
'api'
'protect'
'--upstream'
'http://127.0.0.1:8080'
'--listen'
'0.0.0.0:9090'
]
resources: {
cpu: json('0.25')
memory: '0.5Gi'
}Only args is set so the image entrypoint (/sbin/tini -- /usr/local/bin/chio) is preserved. The default image CMD is --help, which would exit immediately; the override turns it into a long-running api protect reverse proxy.
Sidecar environment and secrets
env: [
{ name: 'CHIO_LISTEN_ADDR', value: '0.0.0.0:9090' }
{ name: 'CHIO_HEALTH_PATH', value: '/chio/health' }
{ name: 'CHIO_KERNEL_CONFIG_PATH', value: '/etc/chio/kernel.yaml' }
{ name: 'CHIO_POLICY_SOURCE', value: chioPolicySource }
{ name: 'CHIO_RECEIPT_SINK', value: chioReceiptSink }
{ name: 'CHIO_LOG_LEVEL', value: 'info' }
{ name: 'CHIO_SIGNING_KEY', secretRef: 'chio-signing-key' }
{ name: 'CHIO_CAPABILITY_AUTHORITY_URL', secretRef: 'chio-capability-authority-url' }
]Plain values use value; Key Vault-backed values use secretRef, which points at one of the entries declared in the secrets block.
Sidecar probes (verbatim)
probes: [
{
type: 'Startup'
httpGet: { path: '/chio/health', port: 9090 }
initialDelaySeconds: 1
periodSeconds: 1
failureThreshold: 30
}
{
type: 'Liveness'
httpGet: { path: '/chio/health', port: 9090 }
periodSeconds: 10
failureThreshold: 3
}
{
type: 'Readiness'
httpGet: { path: '/chio/health', port: 9090 }
periodSeconds: 5
failureThreshold: 3
}
]Three probes: startup polls every second for up to 30 seconds, liveness every 10 seconds with 3-failure tolerance, readiness every 5 seconds with 3-failure tolerance. Readiness gates ingress: if the kernel goes unhealthy, Container Apps removes the replica from the load-balancing pool before liveness recycles it.
Scale block
scale: {
minReplicas: 1
maxReplicas: 20
rules: [
{
name: 'http-scale'
http: {
metadata: {
concurrentRequests: '50'
}
}
}
]
}KEDA-driven HTTP scaler keeps each replica below 50 concurrent requests on average. Floor is 1 replica (warm), ceiling is 20. Drop minReplicas to 0 for scale-to-zero in dev; expect cold-start latency on the next request when the kernel reloads policy.
Outputs
output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn
output containerAppName string = containerApp.nameThe deployment exports the assigned FQDN and the resource name so downstream automation (Front Door routes, DNS records, smoke tests) can pick them up without a second az call.
Secrets
Create the Key Vault secrets and grant the managed identity get permission before deploying the Bicep template.
# Create the Key Vault.
$ az keyvault create --resource-group my-rg --name chio-prod-kv \
--location eastus --enable-rbac-authorization true
# Add the secrets.
$ az keyvault secret set --vault-name chio-prod-kv --name chio-signing-key \
--file ./signing-key.b64
$ az keyvault secret set --vault-name chio-prod-kv --name chio-capability-authority-url \
--value 'https://ctl-a.chio.internal:8940'
# Create the user-assigned managed identity.
$ az identity create --resource-group my-rg --name chio-prod-mi
# Grant the identity Key Vault Secrets User on the vault scope.
$ MI_PRINCIPAL_ID=$(az identity show --resource-group my-rg --name chio-prod-mi \
--query principalId -o tsv)
$ az role assignment create --role "Key Vault Secrets User" \
--assignee-object-id "$MI_PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
--scope $(az keyvault show --name chio-prod-kv --query id -o tsv)
# Capture URIs and IDs for the deploy parameters.
$ SIGN_URI=$(az keyvault secret show --vault-name chio-prod-kv \
--name chio-signing-key --query id -o tsv)
$ AUTH_URI=$(az keyvault secret show --vault-name chio-prod-kv \
--name chio-capability-authority-url --query id -o tsv)
$ MI_ID=$(az identity show --resource-group my-rg --name chio-prod-mi --query id -o tsv)Bump revisions to roll secrets
az containerapp secret set followed by a revision restart, or deploy a new revision. Pin Key Vault URIs to a versioned URL (.../secrets/chio-signing-key/abc123) if you need rollouts to be reproducible.Networking
External vs internal ingress
With ingress.external: true, the app gets a public FQDN under *.azurecontainerapps.io with managed TLS. Switch to false for an internal-only environment; the FQDN resolves only inside the VNet the managed environment is attached to. Front internal apps with Application Gateway or Front Door for WAF and custom domains.
Custom domains
# Bind a custom domain with a managed certificate.
$ az containerapp hostname add --resource-group my-rg \
--name agent-tool-server --hostname tools.example.com
$ az containerapp hostname bind --resource-group my-rg \
--name agent-tool-server --hostname tools.example.com \
--environment my-env --validation-method CNAMEVNet integration
For private capability authorities or VNet-peered receipt stores, the managed environment must be created with a delegated subnet; VNet-attached apps reach internal endpoints directly. mTLS to the authority terminates at the kernel via additional Key Vault-backed env vars (CHIO_CONTROL_TLS_CLIENT_CERT, CHIO_CONTROL_TLS_CLIENT_KEY).
Health Probes and Graceful Shutdown
Probe configuration above. On revision rollover or scale-in, Container Apps sends SIGTERM and respects the terminationGracePeriodSeconds on the template (defaults to 30). The sidecar handles SIGTERM by stopping ingress, draining in-flight evaluations, flushing receipts to the configured sink, and exiting. Container Apps removes the replica from the load-balancing pool as soon as the readiness probe starts failing, so drain is concurrent with traffic shed.
Scaling
| Setting | Bicep field | Default in manifest |
|---|---|---|
| Minimum replicas | scale.minReplicas | 1 |
| Maximum replicas | scale.maxReplicas | 20 |
| HTTP concurrency target | rules[].http.metadata.concurrentRequests | "50" |
The HTTP rule covers most tool servers. For heavier compute, add a CPU rule alongside it; for queue-driven workloads, switch to a custom KEDA scaler:
rules: [
{ name: 'http-scale', http: { metadata: { concurrentRequests: '50' } } }
{ name: 'cpu-scale', custom: { type: 'cpu', metadata: { type: 'Utilization', value: '70' } } }
]Observability
Stdout / stderr from both containers ships to the Log Analytics workspace bound to the managed environment. Sidecar log lines are structured JSON; query them in Log Analytics:
# Find recent denied receipts on the sidecar container
$ az monitor log-analytics query \
--workspace $LAW_ID \
--analytics-query '
ContainerAppConsoleLogs_CL
| where ContainerName_s == "chio-sidecar"
| where Log_s contains "\"event\":\"receipt\""
| where Log_s contains "\"verdict\":\"deny\""
| order by TimeGenerated desc
| take 50
'For metrics and tracing, attach a third sidecar container running the OTel collector (or use Azure Monitor Agent integration on the environment) and point the kernel at it via CHIO_OTEL_ENDPOINT=http://localhost:4317. See Observability for collector wiring.
Revisions and Blue-Green
The reference manifest uses activeRevisionsMode: 'Single', which replaces the previous revision on every deploy. To run blue-green, switch to 'Multiple' and pass a revision suffix per deploy:
configuration: {
activeRevisionsMode: 'Multiple'
...
}
template: {
revisionSuffix: 'v42' // bumped per deploy
...
}# Deploy a new revision, send 10% of traffic to it.
$ az containerapp ingress traffic set \
--resource-group my-rg \
--name agent-tool-server \
--revision-weight agent-tool-server--v42=10 agent-tool-server--v41=90
# Promote.
$ az containerapp ingress traffic set \
--resource-group my-rg \
--name agent-tool-server \
--revision-weight agent-tool-server--v42=100
# Roll back.
$ az containerapp ingress traffic set \
--resource-group my-rg \
--name agent-tool-server \
--revision-weight agent-tool-server--v41=100Cost Considerations
Container Apps bills on vCPU-seconds and memory-GiB-seconds, with a free per-month tier on the consumption profile. Three knobs dominate: minReplicas (every warm replica bills 24/7 on its full container allocation; set to 0 in dev for scale-to-zero), workload profile (consumption is cheapest; the dedicated profile is required for VNet integration with private endpoints), and Log Analytics retention (receipts live in Cosmos DB; cap workspace retention at 30 days unless you need longer for compliance).
Operations
Deploy is a single az deployment group create. Rollback in 'Single' mode is a redeploy of the previous template; in 'Multiple' mode, shift traffic weights to a prior revision (no redeploy).
# Deploy.
$ az deployment group create --resource-group my-rg \
--template-file deploy/azure/container-app.bicep \
--parameters location=eastus managedEnvironmentId=$ENV_ID \
userAssignedIdentityId=$MI_ID \
chioSigningKeySecretUri=$SIGN_URI \
chioCapabilityAuthoritySecretUri=$AUTH_URI \
appImage=ghcr.io/your-org/your-app:1.4.2
# Roll back via traffic split.
$ az containerapp ingress traffic set --resource-group my-rg \
--name agent-tool-server \
--revision-weight agent-tool-server--v41=100 agent-tool-server--v42=0
# Tail sidecar logs.
$ az containerapp logs show --resource-group my-rg --name agent-tool-server \
--container chio-sidecar --follow
# Open a shell on a running replica.
$ az containerapp exec --resource-group my-rg --name agent-tool-server \
--container chio-sidecar --command "/bin/sh"Worked Example
Full sequence from a clean resource group to a verified deploy:
# Create Log Analytics workspace and managed environment.
$ az monitor log-analytics workspace create --resource-group my-rg \
--workspace-name chio-law
$ LAW_ID=$(az monitor log-analytics workspace show --resource-group my-rg \
--workspace-name chio-law --query customerId -o tsv)
$ LAW_KEY=$(az monitor log-analytics workspace get-shared-keys \
--resource-group my-rg --workspace-name chio-law \
--query primarySharedKey -o tsv)
$ az containerapp env create --resource-group my-rg --name my-env \
--location eastus --logs-workspace-id "$LAW_ID" --logs-workspace-key "$LAW_KEY"
$ ENV_ID=$(az containerapp env show --resource-group my-rg --name my-env \
--query id -o tsv)
# Create Key Vault, secrets, and managed identity (see Secrets section).
# Then deploy.
$ az deployment group create --resource-group my-rg \
--template-file deploy/azure/container-app.bicep \
--parameters location=eastus managedEnvironmentId="$ENV_ID" \
userAssignedIdentityId="$MI_ID" \
chioSigningKeySecretUri="$SIGN_URI" \
chioCapabilityAuthoritySecretUri="$AUTH_URI"
# Capture the FQDN.
$ FQDN=$(az deployment group show --resource-group my-rg --name container-app \
--query 'properties.outputs.containerAppFqdn.value' -o tsv)
$ echo "$FQDN"
agent-tool-server.calmplant-d3a1b2c3.eastus.azurecontainerapps.io
# Verify the sidecar is the front door.
$ curl -fsS "https://$FQDN/chio/health" | jq
{ "ok": true, "kernel": "ready", "policy_loaded": true, "authority_generation": 7 }
# The app is unreachable except through the kernel.
$ curl -fsS "https://$FQDN/api/search" \
-H "Authorization: Bearer $CHIO_CAPABILITY_TOKEN" \
-H "Content-Type: application/json" -d '{"query":"hello"}'If the revision goes ProvisioningFailed
get on a referenced secret. Run az containerapp revision show and look at properties.provisioningError; Key Vault denials surface as SecretNotFoundException with the exact secret URI that failed.For other deployment shapes, see Cloud Run and ECS Fargate. For receipt querying and key rotation, see Trust Control Plane.