Azure Container Apps
Deploy a governed HTTP service on Container Apps with the sidecar on ingress, a Key Vault signing seed, and one replica.
On this page
Public traffic for the container app terminates at the Chio sidecar on port 9090. Your application listens on loopback inside the same replica and receives only the calls the kernel admits, each with a signed receipt.
A user-assigned managed identity resolves the authority seed from Key Vault, and a Secret volume projects it into the sidecar as a file beside the Azure Files spec share. Container Apps starts both containers together, so the app gates itself on the sidecar's health route. One replica per revision holds the receipt log in memory.
Startrcontainerapp ingressKchio-sidecar :9090dapp :8080draingrace
Both containers start together; the app gates itself
Entries in the containers array start concurrently, with no dependsOn between them. The sidecar's Startup probe polls /chio/health every second, and the app blocks on $CHIO_SIDECAR_URL/chio/health returning 200 before its first governed call. The ordering contract.
1. Create the environment and register the spec share
The managed environment sends both containers' logs to a Log Analytics workspace and mounts the spec share read-only.
Before you begin
Install the Azure CLI and authenticate with az login. Provision an Azure Files share named chio-spec with openapi.yaml at its root, and set STORAGE_ACCOUNT and STORAGE_KEY before registering it. Skip this step if an environment exists; set ENV_ID and SPEC_STORAGE to its values.
# Create the resource group first. Change globally unique storage and vault names.
az group create --name larkfield-prod --location eastus
# Create Log Analytics workspace and managed environment.
az monitor log-analytics workspace create --resource-group larkfield-prod \
--workspace-name chio-law
LAW_ID=$(az monitor log-analytics workspace show --resource-group larkfield-prod \
--workspace-name chio-law --query customerId -o tsv)
LAW_KEY=$(az monitor log-analytics workspace get-shared-keys \
--resource-group larkfield-prod --workspace-name chio-law \
--query primarySharedKey -o tsv)
az containerapp env create --resource-group larkfield-prod --name larkfield-env \
--location eastus --logs-workspace-id "$LAW_ID" --logs-workspace-key "$LAW_KEY"
ENV_ID=$(az containerapp env show --resource-group larkfield-prod --name larkfield-env \
--query id -o tsv)
# Register the read-only OpenAPI spec share on the environment.
az containerapp env storage set --resource-group larkfield-prod --name larkfield-env \
--storage-name chio-spec --azure-file-account-name $STORAGE_ACCOUNT \
--azure-file-account-key "$STORAGE_KEY" --azure-file-share-name chio-spec \
--access-mode ReadOnly
SPEC_STORAGE=chio-spec
echo "$ENV_ID"/subscriptions/…/resourceGroups/larkfield-prod/providers/Microsoft.App/managedEnvironments/larkfield-env
You should see the environment's resource id, which step 3 passes as managedEnvironmentId.
2. Store the signing seed in Key Vault and grant the identity
# Create the Key Vault.
az keyvault create --resource-group larkfield-prod --name chio-prod-kv \
--location eastus --enable-rbac-authorization true
# Add an existing authority seed: 64 hexadecimal characters encoding 32 bytes.
# Keep the source file private and make the mounted secret readable at startup.
az keyvault secret set --vault-name chio-prod-kv --name chio-authority-seed \
--file ./authority.seed
# Create the user-assigned managed identity.
az identity create --resource-group larkfield-prod --name chio-prod-mi
# Grant the identity Key Vault Secrets User on the vault scope.
MI_PRINCIPAL_ID=$(az identity show --resource-group larkfield-prod --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 the secret URI and identity ID for the deploy parameters.
SEED_URI=$(az keyvault secret show --vault-name chio-prod-kv \
--name chio-authority-seed --query id -o tsv)
MI_ID=$(az identity show --resource-group larkfield-prod --name chio-prod-mi --query id -o tsv)
echo "$SEED_URI"https://chio-prod-kv.vault.azure.net/secrets/chio-authority-seed/7c41…0e9d
You should see a versioned secret URI. The identity holds Key Vault Secrets User on the vault, which lets the revision read the seed at creation; SEED_URI and MI_ID feed step 3.
3. Deploy the template and read its health
Set digest-pinned APP_IMAGE and CHIO_SIDECAR_IMAGE that the deployment can pull. The sed line names the log variable RUST_LOG, which the sidecar reads.
curl -fsS https://raw.githubusercontent.com/backbay-labs/chio/fe5657020cb669d7f9cce5efcae17ff3bdcde3bb/deploy/azure/container-app.bicep -o container-app.source.bicep
sed 's/CHIO_LOG_LEVEL/RUST_LOG/g' container-app.source.bicep > container-app.bicep
az deployment group create --name agent-tool-server --resource-group larkfield-prod \
--template-file container-app.bicep \
--parameters location=eastus managedEnvironmentId="$ENV_ID" \
userAssignedIdentityId="$MI_ID" chioAuthoritySeedSecretUri="$SEED_URI" \
specStorageName="$SPEC_STORAGE" appImage="$APP_IMAGE" \
chioSidecarImage="$CHIO_SIDECAR_IMAGE" \
--query properties.provisioningState -o tsv
FQDN=$(az deployment group show --name agent-tool-server --resource-group larkfield-prod \
--query 'properties.outputs.containerAppFqdn.value' -o tsv)
curl -fsS "https://$FQDN/chio/health" | jq '{status, receipt_backend}'Succeeded
{
"status": "healthy",
"receipt_backend": "ephemeral"
}You should see the deployment succeed and the sidecar report healthy with an ephemeral receipt backend, the value --allow-ephemeral-receipts selects.
4. Confirm a refusal and an admitted call
With the pricing-service image as APP_IMAGE, the commands send a quote for sku-118 to POST /v1/quote, a capability-protected route in its OpenAPI document. Set CHIO_CAPABILITY_TOKEN to the outside buyer's quote grant from Quickstart part 2.
curl -sS -D - -o /dev/null "https://$FQDN/v1/quote" \
-H "Content-Type: application/json" -d '{"sku":"sku-118","percent":10}'
curl -sS -D - -o /dev/null "https://$FQDN/v1/quote" \
-H "X-Chio-Capability: $CHIO_CAPABILITY_TOKEN" \
-H "Content-Type: application/json" -d '{"sku":"sku-118","percent":10}'HTTP/2 403 content-type: application/json x-chio-receipt-id: 81c5…f2a0 HTTP/2 200 content-type: application/json x-chio-receipt-id: 46de…0b37
You should see a 403 and a 200, each with an x-chio-receipt-id. Confirm the application's side effect as well, and keep the receipt ids: the log lives only as long as the replica.
Both containers start together, so the app gates itself
Entries in the containers array start concurrently, with no dependsOn between them. initContainers run to completion before any app container starts and support no probes, so an init container cannot wait on a sidecar that has not started.
The sidecar's Startup probe polls /chio/health every second, and its Readiness probe on the same route, every 5 seconds, keeps external requests off the replica until the kernel is ready; Liveness polls /chio/live every 10 seconds. That narrows the exposure to the app's own startup work. Close it in the app: block on $CHIO_SIDECAR_URL/chio/health returning 200 before the first governed call, and treat any other status as fatal.
Read more in Sidecar health routes.
The 30-second default grace holds a 25-second drain
On revision rollover or scale-in, Container Apps sends SIGTERM and waits terminationGracePeriodSeconds. The sidecar stops ingress and lets in-flight hops finish, each writing its receipt synchronously in its handler. It runs no generic request timeout: the drain is the upstream hop ceiling plus 5 seconds, 25 at the default --upstream-timeout-secs 20.
The 30-second grace is the platform default. The template sets none; its template block opens straight onto containers:
template: {
containers: [
{
name: 'app'A hop ceiling above 25 seconds needs the grace stated in the same revision, at least 5 seconds above --upstream-timeout-secs:
// A key to add to the template block. Hold it at least five seconds
// above --upstream-timeout-secs, which is what the sidecar adds to derive
// its own drain window.
template: {
terminationGracePeriodSeconds: 60
containers: [ ... ]
}Container Apps pulls the replica from the load-balancing pool as soon as the Readiness probe fails, so traffic shed overlaps the drain, where ECS runs deregistration first. That overlap helps only while the grace outlasts the hop the kernel is still waiting on.
Read more in Sidecar shutdown.
One replica holds the receipt log in memory
AzureFile (SMB) and NfsAzureFile are network filesystems, and SQLite keeps its WAL index in shared memory that processes on separate hosts cannot share. storageType: 'EmptyDir' is replica-local and survives a container restart, but its files last only as long as the replica. Its ceiling follows the replica's vCPU, from 1 GiB at 0.25 vCPU or lower to 8 GiB above 1 vCPU.
The template therefore runs --allow-ephemeral-receipts with minReplicas and maxReplicas at 1 and no scale.rules entry. A second replica keeps its own in-memory stream, as overlapping revisions already do, and scale rules change replica count while retaining nothing. Aggregate receipt evidence in a shared service; --receipt-store takes a filesystem path and configures no remote store.
Read more in ECS Fargate, which keeps a durable store on a per-task volume.
A new seed version reaches the signer on the next revision
Container Apps caches the resolved Key Vault value at revision creation, and Chio loads its signer at startup. Publish a new secret version, update chioAuthoritySeedSecretUri to its versioned URI, and create a new revision, or set resyncSecrets through the az CLI. An unversioned URI tracks the latest version within 30 minutes, and Azure restarts revisions automatically only when the secret feeds an environment variable; this recipe mounts a file.
Coordinate the restart with trusted-key history and capability reissuance. See Azure secret version behavior.
Read more in Secrets & Signing Keys.
Multiple revision mode splits traffic for a blue-green rollout
activeRevisionsMode: 'Single' replaces the previous revision on every deploy, and rollback is a redeploy of the previous template. For blue-green, switch to 'Multiple' and bump a revision suffix per deploy:
// Two edits to the reference template, which ships neither key. The
// template declares activeRevisionsMode: 'Single' and no revisionSuffix.
configuration: {
activeRevisionsMode: 'Multiple'
...
}
template: {
revisionSuffix: 'v42' // bumped per deploy
...
}Then shift weights between revisions with no redeploy:
# Deploy a new revision, send 10% of traffic to it.
az containerapp ingress traffic set \
--resource-group larkfield-prod \
--name agent-tool-server \
--revision-weight agent-tool-server--v42=10 agent-tool-server--v41=90
# Promote.
az containerapp ingress traffic set \
--resource-group larkfield-prod \
--name agent-tool-server \
--revision-weight agent-tool-server--v42=100
# Roll back.
az containerapp ingress traffic set \
--resource-group larkfield-prod \
--name agent-tool-server \
--revision-weight agent-tool-server--v41=100
# Tail sidecar logs.
az containerapp logs show --resource-group larkfield-prod --name agent-tool-server \
--container chio-sidecar --followIngress is public by default and private with one flag
With ingress.external: true the app gets a public FQDN under *.azurecontainerapps.io with managed TLS. Set it to false for an internal-only environment, where the FQDN resolves only inside the environment's VNet, and front the app with Application Gateway or Front Door for WAF and custom domains.
# Bind a custom domain with a managed certificate.
az containerapp hostname add --resource-group larkfield-prod \
--name agent-tool-server --hostname pricing.larkfield.example
az containerapp hostname bind --resource-group larkfield-prod \
--name agent-tool-server --hostname pricing.larkfield.example \
--environment larkfield-env --validation-method CNAMETo reach a private capability authority or a VNet-peered receipt store, create the managed environment with a delegated subnet; VNet-attached apps reach internal endpoints directly.
Operational logs land in Log Analytics; receipts stay in their own path
Stdout and stderr from both containers reach the environment's workspace as redacted text events. Query signed receipts through their store or export path.
# Find recent sidecar operational messages
az monitor log-analytics query \
--workspace $LAW_ID \
--analytics-query '
ContainerAppConsoleLogs_CL
| where ContainerName_s == "chio-sidecar"
| order by TimeGenerated desc
| take 50
'/metrics answers a loopback collector or an authenticated remote scraper. Trace export needs an instrumented application and a configured collector; an OTLP environment variable alone enables no CLI trace exporter.
For cost, compare replica count, CPU and memory, network charges, and log retention, decided separately from receipt retention; Consumption replicas can qualify for a reduced idle rate, and private endpoints work with Consumption and Dedicated profiles. See Container Apps billing and private endpoint requirements.
Startup, rotation, and rollover each fail in a recognizable way
The app's first governed call fails right after a deploy
The app called the sidecar before its health route answered 200. Add the startup gate on $CHIO_SIDECAR_URL/chio/health.
A rotated seed has no effect
The running revision holds the cached value. Create a new revision with the new versioned URI.
Calls end without receipts on rollover
The hop ceiling plus 5 seconds exceeds the grace. Set terminationGracePeriodSeconds in the same revision as the hop ceiling.
Template reference
Try specStorageName, Readiness, storageType, or containerAppFqdn.
Parameters
The template deploys one Microsoft.App/containerApps@2024-03-01 resource and takes eight parameters. Four are required: managedEnvironmentId, userAssignedIdentityId, chioAuthoritySeedSecretUri (the Key Vault URI of the signing seed), and specStorageName (the environment storage name backing the read-only spec share). The rest default: location to resourceGroup().location, containerAppName to agent-tool-server, appImage to a stand-in image that the deploy overrides, and chioSidecarImage to ghcr.io/backbay-labs/chio-sidecar:latest.
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 with Key Vault Secrets User on the referenced secrets; a user-assigned identity survives revision-set rotation and can serve several apps in one trust boundary, where a system-assigned one cannot be reused. external: true attaches a public FQDN, targetPort: 9090 binds ingress to the sidecar, and allowInsecure: false forces HTTPS at the edge.
Secrets block
secrets: [
{
name: 'chio-authority-seed'
keyVaultUrl: chioAuthoritySeedSecretUri
identity: userAssignedIdentityId
}
]
}One entry declares the seed, backed by a Key Vault URI and the identity that reads it. The volumes block delivers it as a mounted file; it never reaches the sidecar through env[].secretRef.
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'
}
]
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 a JSON number (json('0.75') for 0.75 cores) and memory takes the Gi suffix; the containers' totals must match an allowed workload profile combination. The startup probe allows 60 seconds (30 attempts at 2 seconds) before liveness takes over.
Sidecar container and arguments
{
name: 'chio-sidecar'
image: chioSidecarImageargs: [
'api'
'protect'
'--upstream'
'http://127.0.0.1:8080'
'--spec'
'/etc/chio/spec/openapi.yaml'
'--listen'
'0.0.0.0:9090'
// Container Apps has no per-replica persistent disk, so the audit log
// cannot be durable here. Opt into ephemeral in-memory receipts
// explicitly instead of pointing --receipt-store at scratch storage,
// which would boot reporting a durable backend yet lose every receipt
// on revision recycle. For a durable audit trail, front a client-server
// store or run on a per-instance-disk platform.
'--allow-ephemeral-receipts'
'--authority-seed-file'
'/etc/chio/seed/authority.seed'
] resources: {
cpu: json('0.25')
memory: '0.5Gi'
}
volumeMounts: [
{
volumeName: 'chio-openapi-spec'
mountPath: '/etc/chio/spec'
}
{
volumeName: 'chio-authority-seed'
mountPath: '/etc/chio/seed'
}
]args carries the entire kernel configuration, and no config file is read. It runs api protect and preserves the entrypoint of your pinned image, so check that entrypoint and the probe tools in the image you deploy. The spec at /etc/chio/spec/openapi.yaml defines the route and scope table, --allow-ephemeral-receipts selects in-memory receipts, and --authority-seed-file loads the mounted seed. The hop ceiling, the drain derived from it, and both health routes belong to the chio-api-protect crate behind the subcommand.
Sidecar environment
env: [
{
name: 'RUST_LOG'
value: 'info'
}
]The sidecar takes one plain variable, RUST_LOG. The spec and the seed arrive as mounted files; no signing-key, capability-authority, kernel-config, policy-source, or receipt-sink variable exists.
Volumes
volumes: [
{
name: 'chio-openapi-spec'
storageType: 'AzureFile'
storageName: specStorageName
}
{
name: 'chio-authority-seed'
storageType: 'Secret'
secrets: [
{
secretRef: 'chio-authority-seed'
path: 'authority.seed'
}
]
}
]chio-openapi-spec is the read-only Azure Files share named by specStorageName; chio-authority-seed is a Secret volume that projects the Key Vault secret to /etc/chio/seed/authority.seed. No receipt-store volume exists: Container Apps offers no per-replica persistent disk, and the durable log is a single-writer WAL-mode SQLite database that needs a local filesystem.
Sidecar probes
probes: [
{
type: 'Startup'
httpGet: {
path: '/chio/health'
port: 9090
}
initialDelaySeconds: 1
periodSeconds: 1
failureThreshold: 30
}
{
// Process-only liveness: a dependency blip must not restart a
// container that is still serving. Readiness gates on /chio/health.
type: 'Liveness'
httpGet: {
path: '/chio/live'
port: 9090
}
periodSeconds: 10
failureThreshold: 3
}
{
type: 'Readiness'
httpGet: {
path: '/chio/health'
port: 9090
}
periodSeconds: 5
failureThreshold: 3
}
]Startup and Readiness poll the dependency-aware /chio/health, which returns 503 when the receipt store can no longer persist. Liveness polls the process-only /chio/live, which holds steady through a dependency blip. When the kernel goes unhealthy, Readiness pulls the replica from the pool before liveness would recycle it.
Scale block
scale: {
minReplicas: 1
maxReplicas: 1
}One replica per revision, each with its own in-memory log. Export and aggregate receipts for retention across restart or deployment. A per-instance block volume on another platform still holds an independent log with its own lifecycle.
Outputs
output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn
output containerAppName string = containerApp.nameThe deployment exports the FQDN and the resource name, so Front Door routes, DNS records, and smoke tests read them without a second az call.