📦

Container Apps Setup

Container Apps Environment — Setup Reference

Last verified: 2026-03-16

This document describes the current Azure Container Apps (CAE) setup used to run the Audissey worker across all environments. It serves as the single source of truth for infrastructure references, networking, identity, DNS, and environment variables.


Architecture Overview

All worker instances run on a single shared CAE in North Europe:

Resource Value
CAE name audissey-cae-northeu
Resource Group audissey-staging-rg
Region North Europe
Plan Consumption (serverless)
Internal (VNet-only) Yes (publicNetworkAccess=Disabled)
Default domain salmonglacier-eb702166.northeurope.azurecontainerapps.io
Static IP (ILB) 10.252.1.150
Log Analytics audissey-law-workers-ne (customerId: 719e6bd2-1533-4e63-923f-627218382a7a)

Why North Europe?

West Europe AKS free-tier capacity is exhausted — Azure returns AKSCapacityHeavyUsage when trying to create new CAEs there. The migration to NE was performed on 2026-03-09. Prod should eventually move to a dedicated WE CAE when capacity frees up (see Future: Prod Isolation).


Container Apps per Environment

Dev

Field Value
Container App audissey-worker-dev
Resource Group audissey-dev-rg
FQDN audissey-worker-dev.salmonglacier-eb702166.northeurope.azurecontainerapps.io
Image audissey.azurecr.io/audissey-worker:<tag>
Replicas min: 0, max: 2 (KEDA scale-to-zero)
Managed Identity (system) 59ab773d-7c0f-4499-b4d0-1068b87231df
RBAC AcrPull on audissey.azurecr.io
API calling it audissey-api-dev (VNet: audissey-vnet-workers-we/appsvc-integration)

Environment variables:

NODE_ENV, LOG_LEVEL, COSMOS_DB_NAME, MONGODB_DB_NAME, MONGO_URI,
REDIS_CONNECTION_STRING, WORKER_API_KEY, API_URL, ML_SERVICE_URL,
APPLICATIONINSIGHTS_CONNECTION_STRING, PODCAST_INDEX_API_KEY,
PODCAST_INDEX_API_SECRET

Staging

Field Value
Container App audissey-worker-staging
Resource Group audissey-staging-rg
FQDN audissey-worker-staging.salmonglacier-eb702166.northeurope.azurecontainerapps.io
Image audissey.azurecr.io/audissey-worker:<tag>
Replicas min: 0, max: 2 (KEDA scale-to-zero)
Managed Identity (system) 27ff0af7-806b-4146-8c1f-97c6b110b7ce
RBAC AcrPull on audissey.azurecr.io + Key Vault Secrets User on audissey-kv-staging
API calling it audissey-api-staging (VNet: audissey-vnet-staging/appsvc-integration)

Environment variables:

NODE_ENV, LOG_LEVEL, COSMOS_DB_NAME, MONGODB_DB_NAME, MONGO_URI,
REDIS_CONNECTION_STRING, WORKER_API_KEY, API_URL, ML_SERVICE_URL,
APPLICATIONINSIGHTS_CONNECTION_STRING

Production

Field Value
Container App audissey-worker
Resource Group audissey-prod-rg
FQDN audissey-worker.salmonglacier-eb702166.northeurope.azurecontainerapps.io
Image audissey.azurecr.io/audissey-worker:<tag>
Replicas min: 0, max: 3 (KEDA scale-to-zero)
Managed Identity (system) 172dcef0-1c87-4524-ac0a-e05702d8f3d9
RBAC AcrPull on audissey.azurecr.io + Key Vault Secrets User on audissey-kv-production
API calling it audissey-api (VNet: audissey-vnet-prod/appsvc-integration)

Environment variables:

NODE_ENV, LOG_LEVEL, COSMOS_DB_NAME, MONGODB_DB_NAME, REDIS_CONNECTION_STRING,
MONGO_URI, REDIS_HOST, REDIS_PORT, REDIS_TLS, REDIS_PASSWORD,
WORKER_API_KEY, API_URL, ML_SERVICE_URL, APPLICATIONINSIGHTS_CONNECTION_STRING,
PODCAST_INDEX_API_KEY, PODCAST_INDEX_API_SECRET

Note: Prod has extra REDIS_HOST/PORT/TLS/PASSWORD env vars in addition to REDIS_CONNECTION_STRING. These are legacy — consolidate to REDIS_CONNECTION_STRING only when convenient.


Networking

VNets

VNet Address Space Region Resource Group Purpose
audissey-vnet-workers-ne 10.252.0.0/16 North Europe audissey-staging-rg CAE infrastructure
audissey-vnet-workers-we 10.251.0.0/16 West Europe audissey-staging-rg Private Endpoints (Redis), dev API integration
audissey-vnet-dev 10.10.0.0/16 West Europe audissey-dev-rg Dev App Services
audissey-vnet-staging 10.20.0.0/16 West Europe audissey-staging-rg Staging App Services
audissey-vnet-prod 10.30.0.0/16 West Europe audissey-prod-rg Prod App Services + future prod CAE

Subnets — `audissey-vnet-workers-ne`

Subnet Prefix Delegation Usage
containerapps-infra 10.252.0.0/23 Microsoft.App/environments CAE audissey-cae-northeu

Subnets — `audissey-vnet-workers-we` (still active)

Subnet Prefix Delegation Usage
containerapps-infra 10.251.0.0/23 Microsoft.App/environments Unused (old CAE deleted)
private-endpoints 10.251.2.0/24 — Redis PE audissey-redis-pe-workers (10.251.0.6)
appsvc-integration 10.251.3.0/24 Microsoft.Web/serverFarms Dev API VNet integration

Subnets — `audissey-vnet-prod` (future prod CAE ready)

Subnet Prefix Delegation Usage
appsvc-integration 10.30.1.0/24 Microsoft.Web/serverFarms Prod API VNet integration
private-endpoints 10.30.2.0/24 — Key Vault PE
containerapps-infra 10.30.4.0/23 Microsoft.App/environments Pre-provisioned for future prod CAE

VNet Peerings (from `audissey-vnet-workers-ne`)

All bidirectional, status: Connected.

Peering Name Remote VNet
ne-workers-to-we-workers audissey-vnet-workers-we
ne-workers-to-staging audissey-vnet-staging
ne-workers-to-prod audissey-vnet-prod
ne-workers-to-dev audissey-vnet-dev

Legacy peerings (WE workers VNet → env VNets) also remain active.

API → Worker connectivity path

API Integrated VNet/Subnet Route to NE CAE
audissey-api-dev audissey-vnet-workers-we/appsvc-integration Peering: WE-workers ↔ NE-workers (same VNet family)
audissey-api-staging audissey-vnet-staging/appsvc-integration Peering: staging ↔ NE-workers
audissey-api (prod) audissey-vnet-prod/appsvc-integration Peering: prod ↔ NE-workers

DNS

CAE Private DNS Zone

Field Value
Zone salmonglacier-eb702166.northeurope.azurecontainerapps.io
Resource Group audissey-staging-rg
Record * (wildcard A) → 10.252.1.150

VNet links:

Link Name VNet
link-workers-ne audissey-vnet-workers-ne
link-workers-we audissey-vnet-workers-we
link-staging audissey-vnet-staging
link-prod audissey-vnet-prod
link-dev audissey-vnet-dev

All 5 VNets can resolve *.salmonglacier-eb702166.northeurope.azurecontainerapps.io → 10.252.1.150.

Private Endpoint DNS Zones (in `audissey-staging-rg`, linked to NE VNet)

These enable the NE workers to reach Cosmos, Redis, and Key Vault via Private Endpoints:

Zone Link for NE VNet
privatelink.mongo.cosmos.azure.com link-ne-workers-cosmos
privatelink.redis.cache.windows.net link-ne-workers-redis
privatelink.vaultcore.azure.net link-ne-workers-kv

API App Settings Reference

The API App Services must have WORKER_URL and WORKER_API_URL pointing to the correct worker FQDN. These are managed in:

Current values:

Environment WORKER_URL / WORKER_API_URL
Dev https://audissey-worker-dev.salmonglacier-eb702166.northeurope.azurecontainerapps.io/api
Staging https://audissey-worker-staging.salmonglacier-eb702166.northeurope.azurecontainerapps.io/api
Prod https://audissey-worker.salmonglacier-eb702166.northeurope.azurecontainerapps.io/api

These are applied to App Services via the ensure-appsettings.js script in the release pipeline.


Deployment Pipeline

The worker release pipeline (azure-pipelines-worker-release.yml) deploys via:

az containerapp update --name <APP_NAME> --resource-group <RG> --image <IMAGE>

It does not reference the CAE name — only the container app name and its RG. This makes it migration-safe.

Stage Container App Resource Group
Dev audissey-worker-dev audissey-dev-rg
Staging audissey-worker-staging audissey-staging-rg
Prod audissey-worker audissey-prod-rg

Cross-RG RBAC for Shared CAE

Because the CAE lives in audissey-staging-rg but dev/prod Container Apps are in their own RGs, the dev and prod service principals need Contributor scoped to the CAE resource. Without this, az containerapp secret set and az containerapp update fail with LinkedAuthorizationFailed (missing Microsoft.App/managedEnvironments/join/action).

Service Principal Object ID Home RG CAE RBAC
Dev SP (azure-dev-connection) 95fabf15-2d2f-4ae7-8d4f-0a41e5fee3cf audissey-dev-rg Contributor on CAE
Prod SP (azure-prod-connection) 27fa4864-df77-47e6-839f-e4d8a60feb37 audissey-prod-rg Contributor on CAE
Staging SP (azure-staging-connection) e1952205-5824-422e-b08f-3746c6398203 audissey-staging-rg Already Contributor on entire RG

These are documented in deploy/config/rbac/staging.json under resourceScopedRoles.

To re-apply if needed:

CAE_SCOPE="/subscriptions/7141477e-818d-4a88-be77-1ba6c04d42a0/resourceGroups/audissey-staging-rg/providers/Microsoft.App/managedEnvironments/audissey-cae-northeu"

# Dev SP
az role assignment create --assignee "95fabf15-2d2f-4ae7-8d4f-0a41e5fee3cf" --role "Contributor" --scope "$CAE_SCOPE"

# Prod SP
az role assignment create --assignee "27fa4864-df77-47e6-839f-e4d8a60feb37" --role "Contributor" --scope "$CAE_SCOPE"

Container Registry

Field Value
Registry audissey.azurecr.io
Resource Group audissey-shared-rg
Image audissey-worker
Pull method Managed Identity (acrUseManagedIdentityCreds: true)

All 3 worker identities have AcrPull role on the registry.


Log Analytics

Workspace Region Resource Group customerId Used by
audissey-law-workers-ne North Europe audissey-staging-rg 719e6bd2-1533-4e63-923f-627218382a7a audissey-cae-northeu (active)
audissey-law-workers-prod West Europe audissey-prod-rg 52a2573a-6a80-4373-aa85-96a1c12ea80f Reserved for future WE prod CAE

To query worker logs:

# Dev worker logs (last 1h)
az monitor log-analytics query \
  -w 719e6bd2-1533-4e63-923f-627218382a7a \
  --analytics-query "ContainerAppConsoleLogs_CL | where ContainerAppName_s == 'audissey-worker-dev' | where TimeGenerated > ago(1h) | order by TimeGenerated desc | take 50" \
  -o table

# Replace ContainerAppName_s with 'audissey-worker-staging' or 'audissey-worker' for other envs.

Future: Prod Isolation (WE CAE)

When West Europe AKS capacity frees up, prod should be moved to a dedicated CAE for full environment isolation:

Pre-provisioned resources (ready to use):

Steps to migrate:

  1. Create CAE in WE:
    az containerapp env create \
      --name audissey-cae-prod \
      --resource-group audissey-prod-rg \
      --location westeurope \
      --infrastructure-subnet-resource-id "/subscriptions/7141477e-818d-4a88-be77-1ba6c04d42a0/resourceGroups/audissey-prod-rg/providers/Microsoft.Network/virtualNetworks/audissey-vnet-prod/subnets/containerapps-infra" \
      --internal-only true \
      --logs-workspace-id 52a2573a-6a80-4373-aa85-96a1c12ea80f \
      --logs-workspace-key <KEY>
    
  2. Note the new CAE domain and static IP.
  3. Create a new private DNS zone for the prod CAE domain.
  4. Add wildcard A record pointing to the static IP.
  5. Link the DNS zone to audissey-vnet-prod (and any other VNets that need to resolve it).
  6. Recreate audissey-worker on the new prod CAE with the same env vars and identity.
  7. Re-assign RBAC (AcrPull + Key Vault Secrets User) to the new identity.
  8. Update WORKER_URL/WORKER_API_URL in deploy/config/appsettings/api/prod.json.
  9. Apply settings to audissey-api App Service.
  10. Delete the old audissey-worker from audissey-cae-northeu.

How to check if WE capacity is available:

az containerapp env create \
  --name test-cae-we-capacity \
  --resource-group audissey-prod-rg \
  --location westeurope \
  --internal-only true \
  --logs-destination none
# If it succeeds, delete it and proceed with the real creation.
# If AKSCapacityHeavyUsage → still blocked.

KEDA Scale-to-Zero Configuration

Last updated: 2026-03-17

All worker Container Apps use KEDA Redis List scalers to scale from 0 to N replicas when BullMQ queues have pending jobs.

Scale Settings

Environment minReplicas maxReplicas pollingInterval cooldownPeriod Total rules
Dev 0 2 30s 300s 24
Staging 0 2 30s 300s 24
Prod 0 3 30s 300s 24

Dual-Rule Pattern (`:wait` + `:active`)

Each BullMQ queue gets two KEDA scale rules:

  1. :wait rule — triggers scale-up when new jobs arrive in the wait list
  2. :active rule — keeps the replica alive while a job is being processed

Without the :active rule, KEDA sees an empty :wait list during processing and scales to 0, killing the container mid-job. This was observed with snapshot-ml-training which runs for >2 hours on Cosmos DB (validated on dev: 2h21min continuous processing survived).

Monitored Queues

BullMQ Queue :wait Rule :active Rule
run-schedules keda-redis-run-schedules keda-redis-run-schedules-active
stream-health-check keda-redis-stream-health-check keda-redis-stream-health-check-active
sync-radios keda-redis-sync-radios keda-redis-sync-radios-active
sync-programs keda-redis-sync-programs keda-redis-sync-programs-active
sync-podcasts keda-redis-sync-podcasts keda-redis-sync-podcasts-active
sync-episodes keda-redis-sync-episodes keda-redis-sync-episodes-active
enrich-radios keda-redis-enrich-radios keda-redis-enrich-radios-active
scraping-queue keda-redis-scraping-queue keda-redis-scraping-queue-active
content-freshness keda-redis-content-freshness keda-redis-content-freshness-active
aggregate-content-popularity keda-redis-aggregate-content-popularity keda-redis-aggregate-content-popularity-active
aggregate-user-features keda-redis-aggregate-user-features keda-redis-aggregate-user-features-active
snapshot-ml-training keda-redis-snapshot-ml-training keda-redis-snapshot-ml-training-active

Queues WITHOUT KEDA Scalers (by design)

Queue Reason
user-data-deletion Event-driven (DELETE /users/me) — fires rarely; worker is usually up for other jobs
feed-health-score Intentionally disabled (not in CronConfig defaults)
backfill-episode-features Manual API endpoint only
backfill-radios-dedupe-fields Manual API endpoint only
sync-myradioonline Low-frequency manual trigger

Source of Truth

The scripts/ensure-worker-keda.sh script is the pipeline source of truth for KEDA configuration. It reads the QUEUES array and generates the scale rules YAML that is applied during each worker release.

CRITICAL: The KEDA rule names and listName values must match the BullMQ @Processor('name') decorator in the worker code. A mismatch means KEDA watches a non-existent Redis key and the worker never scales up for that queue.

Verification

# Show KEDA rules for a worker
az containerapp show -g <RG> -n <APP> \
  --query 'properties.template.scale.rules[].{name:name, listName:custom.metadata.listName}' \
  -o table

Quick Reference Commands

# List all workers
az containerapp list \
  --query "[].{name:name, rg:resourceGroup, fqdn:properties.configuration.ingress.fqdn, state:properties.provisioningState}" \
  -o table

# Show worker logs (replace NAME and WORKSPACE_ID)
az containerapp logs show -n <NAME> -g <RG> --type console --tail 100

# Check worker identity RBAC
az role assignment list --assignee <PRINCIPAL_ID> --all \
  --query "[].{role:roleDefinitionName, scope:scope}" -o table

# Verify API app setting
az webapp config appsettings list -n <API_APP> -g <RG> \
  --query "[?name=='WORKER_URL'].value" -o tsv

# Check DNS resolution (from a VM in the VNet, or via nslookup)
nslookup audissey-worker-dev.salmonglacier-eb702166.northeurope.azurecontainerapps.io 168.63.129.16