Container Apps: Scaling, Jobs and Dapr

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Examples on this page target Azure CLI 2.90.0 with the containerapp extension 1.3.0b5 (still required for several of the preview flags used below — run az extension add --name containerapp --upgrade first). Azure Container Apps scales container apps and event-driven jobs internally using KEDA (Kubernetes Event-driven Autoscaling); the service does not publish a single fixed KEDA version reader-visible in the CLI or portal the way an AKS cluster does (AKS’s own managed KEDA add-on, by contrast, ships 2.19 on AKS 1.36 and 2.20 on AKS 1.37 — see Deploying Microservices on AKS), so treat the scale-rule behaviour described below (polling interval, cooldown, stabilization windows, scale-up steps) as the platform’s current documented contract rather than a version-pinned one, and re-verify it against the References before relying on exact numbers in production. This page assumes the Container Apps environment, the catalog-api and orders-api apps, and revisions are already in place from Container Apps — that page covers the environment, app and revision basics and is not repeated here.

The Bookshelf scaling and jobs scenario

Bookshelf adds one more moving part on top of cae-bookshelf, catalog-api and orders-api: a notifier worker that sends order-confirmation emails and push notifications. Rather than run notifier as an always-on container app polling a queue, Bookshelf runs it as an event-driven job — a com.example.bookshelf.notifier Java process, packaged the same way as catalog-api and orders-api (see Azure Container Registry for how its image reaches acrbookshelf), that Container Apps starts on demand, once per queued message, and tears down again once the message is processed. catalog-api and orders-api keep scaling on HTTP concurrency as covered in Container Apps; this page adds a Service Bus-based custom scale rule for notifier and, separately, a Dapr pub/sub component so orders-api can publish an order-placed event without knowing notifier exists.

Shell variables used throughout (matching the rest of this section):

export RG=rg-bookshelf
export LOCATION=eastus2
export ENV=cae-bookshelf
export APP=catalog-api

Scaling: KEDA-based rules

Scale definition: limits, rules and behavior

Every container app’s scaling is the combination of three things: limits (--min-replicas / --max-replicas, 0-1000, defaulting to 0 minimum and 10 maximum), rules (HTTP, TCP or custom, evaluated with OR semantics — the app scales once any rule’s condition is met), and behavior (the fixed platform values below that turn a rule’s measurement into an actual replica count change):

Behavior Documented default

Polling interval

30 seconds — how often KEDA queries an event source; does not apply to HTTP/TCP rules, which are measured continuously from ingress traffic instead.

Cooldown period

300 seconds — how long after the last event KEDA waits before scaling down to the minimum replica count. It only governs the final step down to the configured minimum, not every intermediate scale-in step.

Scale-up stabilization window

0 seconds — KEDA acts on a scale-up decision immediately.

Scale-down stabilization window

300 seconds — a scale-down condition must hold for this long before KEDA acts on it.

Scale-up step

1, then 4, 8, 16, 32, …​ up to the configured maximum — replicas are not added one at a time once demand is sustained; the step itself doubles each cycle.

Scale-down step

100% of the replicas KEDA has decided are no longer needed, removed in one step.

Scaling algorithm

desiredReplicas = ceil(currentMetricValue / targetMetricValue), then clamped to min(maxReplicas, desiredReplicas, max(4, 2 * currentReplicas)).

HTTP concurrency

An HTTP scale rule adds a replica once the number of concurrent requests (measured as requests over the last 15 seconds, divided by 15) exceeds a threshold, up to --max-replicas. catalog-api and orders-api already use this rule as their default — see Container Apps — and it is the platform’s implicit default (threshold 10, min 0, max 10) if no scale rule is defined at all.

In the Azure portal

  1. Open the container app → Scale → Edit and deploy.

  2. On the Scale tab, set the minimum and maximum replica range.

  3. Select Add, name the rule, choose HTTP Scaling from the Type dropdown, and enter the concurrent request threshold.

With the Azure CLI

az containerapp create \
  --name $APP --resource-group $RG --environment $ENV \
  --image acrbookshelf.azurecr.io/catalog-api:1.0.0 \
  --min-replicas 0 --max-replicas 5 \
  --scale-rule-name http-rule \
  --scale-rule-type http \
  --scale-rule-http-concurrency 100

TCP concurrency

A TCP rule works the same way for raw TCP ingress traffic (concurrent connections over the last 15 seconds, divided by 15). It requires --ingress and --transport tcp to be set, and has no Azure portal equivalent.

With the Azure CLI

az containerapp create \
  --name orders-tcp-listener --resource-group $RG --environment $ENV \
  --image acrbookshelf.azurecr.io/orders-api:1.0.0 \
  --min-replicas 0 --max-replicas 5 \
  --transport tcp --ingress external --target-port 5005 \
  --scale-rule-name tcp-rule \
  --scale-rule-type tcp \
  --scale-rule-tcp-concurrency 100

Custom scalers (Service Bus, Event Hubs, Storage Queue, Cron)

A custom scale rule wraps any KEDA ScaledObject scaler — Azure Service Bus, Azure Event Hubs, Azure Queue Storage, Kafka, Redis, a cron schedule, and many more. --scale-rule-type takes the KEDA scaler’s own type value (for example azure-servicebus, azure-eventhub, azure-queue, or cron), and --scale-rule-metadata takes that scaler’s metadata block as space-separated key=value pairs.

Bookshelf’s notifier job (built out fully under Jobs: manual, scheduled and event-driven below) scales on the depth of a Service Bus queue named orders-notify. As a container-app-style custom rule (the same rule shape also applies to an always-on worker, if Bookshelf ever needed one instead of a job):

In the Azure portal

  1. Open the app or job → Scale → Edit and deploy → Scale tab → Add.

  2. From the Type dropdown, select Custom, and enter the KEDA scaler’s type value (e.g. azure-servicebus) in the Custom rule type box.

  3. Under Metadata, add one entry per key in the scaler’s metadata block.

  4. Managed-identity authentication for a custom scale rule is not configurable from the portal — use the CLI or an ARM/Bicep template instead.

With the Azure CLI

az containerapp create \
  --name notifier-worker --resource-group $RG --environment $ENV \
  --image acrbookshelf.azurecr.io/notifier:1.0.0 \
  --user-assigned "$(az identity show --name id-notifier --resource-group $RG --query id -o tsv)" \
  --min-replicas 0 --max-replicas 10 \
  --scale-rule-name servicebus-orders-notify \
  --scale-rule-type azure-servicebus \
  --scale-rule-metadata "queueName=orders-notify" "namespace=sb-bookshelf" "messageCount=5" \
  --scale-rule-identity "$(az identity show --name id-notifier --resource-group $RG --query id -o tsv)"

A cron custom scale rule (time-window scaling, distinct from a scheduled job’s cron trigger under Jobs: manual, scheduled and event-driven) takes start, end, timezone and desiredReplicas metadata instead of a queue depth:

az containerapp update \
  --name catalog-api --resource-group $RG \
  --scale-rule-name business-hours \
  --scale-rule-type cron \
  --scale-rule-metadata "timezone=Europe/Madrid" "start=0 8 * * 1-5" "end=0 20 * * 1-5" "desiredReplicas=3"

Managed-identity scaler authentication

Custom scale rules for Azure resources (Service Bus, Storage Queue, Event Hubs) accept either a Container Apps secret referenced through --scale-rule-auth (a connection string) or a managed identity referenced through --scale-rule-identity. Prefer the managed-identity form: it removes a Service Bus connection string from the app’s configuration entirely, matching the managed-identity-first approach in Identity and Access. Grant the identity the data-plane role the scaler needs (for Service Bus, Azure Service Bus Data Receiver scoped to the namespace or queue) before attaching it:

az role assignment create \
  --assignee-object-id "$(az identity show --name id-notifier --resource-group $RG --query principalId -o tsv)" \
  --assignee-principal-type ServicePrincipal \
  --role "Azure Service Bus Data Receiver" \
  --scope "$(az servicebus namespace show --name sb-bookshelf --resource-group $RG --query id -o tsv)"

--scale-rule-identity takes either a user-assigned identity’s full resource ID (as used for notifier above) or the literal value system to use the app’s system-assigned identity instead. Secrets/environment variables consumed by the running container itself — as opposed to the scaler’s own authentication — are covered on Key Vault and Secrets and Runtime Configuration; this page only covers how the scaler authenticates to read the queue’s depth.

The KEDA scale loop

flowchart TD subgraph Loop["Every polling interval (default 30s)"] A["KEDA operator polls external scaler\n(Service Bus queue 'orders-notify' depth)"] --> B{"Queue length > 0?"} B -- "No" --> A B -- "Yes" --> C["Compute desiredReplicas =\nceil(queueLength / messageCount)"] C --> D["Clamp to min(maxReplicas, desiredReplicas,\nmax(4, 2 * currentReplicas))"] D --> E["Container Apps platform scales\nreplicas/executions 0 to N"] E --> F{"Queue length == 0\nfor cooldown period (300s)?"} F -- "No" --> A F -- "Yes" --> G["Scale in to minReplicas (often 0)"] G --> A end H["Pitfall: ingress disabled + minReplicas 0\nand no custom scale rule --\nnothing ever wakes the app"] -.-> E

The one pitfall worth calling out on its own: a container app with no ingress and a minimum of zero replicas, and no scale rule defined, has no trigger of any kind to wake it back up — HTTP/TCP scaling needs ingress traffic to measure, and with no custom rule there is nothing else polling an event source either. The platform’s own guidance is blunt about this: define a scale rule, or set --min-replicas 1 or higher, on any app that has ingress disabled. notifier avoids the trap because it is a job, not an app — its event-driven trigger is precisely the Service Bus custom scale rule, which is what wakes it from zero in the first place, and jobs do not carry ingress at all.

Jobs: manual, scheduled and event-driven

A Container Apps job is a task that starts, runs for a finite duration, and stops — unlike an app, a failed job replica is not automatically restarted into a long-running process; it is retried up to a limit and then the execution is marked failed. Jobs and apps share the same environment (cae-bookshelf), networking and logging, but jobs do not support ingress, custom domains/SSL, or Dapr — three restrictions worth remembering before reaching for a job where an always-on app (or a Dapr-enabled one) is really what is needed.

Three trigger types cover Bookshelf’s needs:

Trigger type Bookshelf use

Manual

An on-demand backfill or data-migration task, started from the CLI, portal, or an ARM/REST call.

Schedule

A nightly catalog-report job that regenerates a sales summary, driven by a cron expression evaluated in UTC.

Event

notifier: one execution per message queued on orders-notify, scaling executions (not container-app replicas) between a minimum and maximum via the same KEDA custom-scaler mechanism as Jobs: manual, scheduled and event-driven above.

In the Azure portal

  1. Search Container App Jobs → Create.

  2. On the Basics tab, choose the trigger type: Manual, Schedule (enter a cron expression, evaluated in UTC), or Event-driven (configure a scale rule the same way as a custom app scale rule).

  3. On the Container tab, set the image, command, CPU/memory, and environment variables.

  4. Under Settings → Configuration (after creation), adjust replica timeout, retry limit, parallelism and replica completion count.

  5. To run a job on demand regardless of its trigger type, open the job’s overview page and select Run now.

  6. To inspect past runs, open Monitoring → Execution history (the platform retains the most recent 100 successful and 100 failed executions; older executions must be queried from the environment’s log destination).

With the Azure CLI

Manual job (an ad hoc catalog backfill):

az containerapp job create \
  --name catalog-backfill --resource-group $RG --environment $ENV \
  --trigger-type Manual \
  --replica-timeout 1800 --replica-retry-limit 0 \
  --replica-completion-count 1 --parallelism 1 \
  --image acrbookshelf.azurecr.io/catalog-backfill:1.0.0 \
  --cpu 0.5 --memory 1.0Gi

Scheduled job (catalog-report, nightly at 02:00 UTC — note that a cron expression on this page is always UTC, not the reader’s local timezone):

az containerapp job create \
  --name catalog-report --resource-group $RG --environment $ENV \
  --trigger-type Schedule \
  --cron-expression "0 2 * * *" \
  --replica-timeout 900 --replica-retry-limit 1 \
  --replica-completion-count 1 --parallelism 1 \
  --image acrbookshelf.azurecr.io/catalog-report:1.0.0 \
  --cpu 0.5 --memory 1.0Gi

Event-driven job (notifier, one execution per queued message, scaling to zero when idle — this is the job this page centers its scaling story on):

az containerapp job create \
  --name notifier --resource-group $RG --environment $ENV \
  --trigger-type Event \
  --replica-timeout 300 --replica-retry-limit 3 \
  --parallelism 1 \
  --image acrbookshelf.azurecr.io/notifier:1.0.0 \
  --cpu 0.25 --memory 0.5Gi \
  --user-assigned "$(az identity show --name id-notifier --resource-group $RG --query id -o tsv)" \
  --min-executions 0 --max-executions 10 --polling-interval 30 \
  --scale-rule-name servicebus-orders-notify \
  --scale-rule-type azure-servicebus \
  --scale-rule-metadata "queueName=orders-notify" "namespace=sb-bookshelf" "messageCount=1" \
  --scale-rule-identity "$(az identity show --name id-notifier --resource-group $RG --query id -o tsv)"

Start any job execution on demand, inspect history, and check a single execution’s status:

az containerapp job start --name notifier --resource-group $RG

az containerapp job execution list --name notifier --resource-group $RG --output table

az containerapp job execution list \
  --name notifier --resource-group $RG \
  --query "[0].{status:properties.status, startTime:properties.startTime}" --output table

az containerapp job start also accepts --yaml <file> to override the container image, command, environment variables or arguments for a single execution without changing the job’s stored configuration — useful for running catalog-backfill once against a different target table without redefining the job. Because starting a job can expose any secret or managed identity available to its containers through that override template, restrict Microsoft.App/jobs/start/action (bundled into the built-in Container Apps Jobs Operator and Container Apps Jobs Contributor roles) to identities Bookshelf actually trusts with those secrets, per Identity and Access.

The settings that shape every job execution, regardless of trigger type:

Setting Meaning

--replica-timeout

Maximum seconds a replica may run before it is forcibly stopped and treated as failed.

--replica-retry-limit

How many times a failed replica is retried before the execution is marked failed; 0 means fail fast with no retry. Because platform maintenance can interrupt a long-running replica, set this to at least 1 and make the workload safe to run more than once (at-least-once processing) rather than assuming exactly-once delivery.

--parallelism

How many replicas run concurrently per execution; 1 for most jobs, including notifier and catalog-report.

--replica-completion-count

How many of those parallel replicas must succeed for the execution as a whole to succeed; must be ⇐ parallelism.

--polling-interval

(Event-driven jobs only) how often, in seconds, KEDA checks the scale rule’s event source; defaults to 30.

Dapr: sidecar, building blocks and components

Dapr (Distributed Application Runtime) runs as a sidecar container next to each enabled app’s own container, giving it a local HTTP/gRPC API for cross-cutting concerns — service invocation, pub/sub messaging, state management, secrets, bindings and actors — without linking a Dapr SDK into application code (though SDKs exist and are the more ergonomic choice from Java/.NET). Container Apps manages the sidecar’s lifecycle automatically once Dapr is enabled on an app; jobs cannot use Dapr at all, which is why notifier reaches Bookshelf’s messaging exclusively through its Service Bus scale rule rather than through a Dapr pub/sub subscription.

Enabling the sidecar

In the Azure portal

  1. Open orders-api → Dapr configuration.

  2. Toggle Enabled.

  3. Set App ID (orders-api), App port (the container’s listening port), and App protocol (http or grpc).

  4. Select Save, which creates a new revision with the sidecar attached.

With the Azure CLI

az containerapp create \
  --name orders-api --resource-group $RG --environment $ENV \
  --image acrbookshelf.azurecr.io/orders-api:1.0.0 \
  --target-port 8080 --ingress internal \
  --enable-dapr \
  --dapr-app-id orders-api \
  --dapr-app-port 8080 \
  --dapr-app-protocol http

Components: az containerapp env dapr-component set

A Dapr component is a YAML manifest — state store, pub/sub broker, secret store, or binding — registered once on the environment and then scoped to the apps allowed to use it, so catalog-api and orders-api share one pub/sub component definition without either one embedding Service Bus configuration directly. The manifest’s shape mirrors upstream Dapr components almost exactly (componentType, version, metadata, an optional secrets block, and a scopes list), which is why component YAML found in Dapr’s own documentation ports directly to Container Apps with only the CLI invocation changing.

Bookshelf’s orders-placed pub/sub component, authenticating to Service Bus with `orders-api’s managed identity rather than a connection string:

# pubsub-orders.yaml
componentType: pubsub.azure.servicebus.topics
version: v1
metadata:
  - name: namespaceName
    value: "sb-bookshelf.servicebus.windows.net"
  - name: azureClientId
    value: "<orders-api-user-assigned-identity-client-id>"
scopes:
  - orders-api
  - notifier-subscriber

With the Azure CLI

az containerapp env dapr-component set \
  --name $ENV --resource-group $RG \
  --dapr-component-name pubsub-orders \
  --yaml pubsub-orders.yaml

az containerapp env dapr-component list --name $ENV --resource-group $RG --output table

az containerapp env dapr-component show \
  --name $ENV --resource-group $RG --dapr-component-name pubsub-orders

Service invocation between Bookshelf services

With the sidecar enabled on both apps, catalog-api calls orders-api through Dapr’s own service invocation building block instead of resolving `orders-api’s internal ingress URL directly — the call is addressed by Dapr app ID and travels sidecar-to-sidecar, which gives it built-in mTLS, retries and distributed tracing for free:

// From catalog-api, calling orders-api's /orders endpoint via the local Dapr sidecar
WebClient daprClient = WebClient.create("http://localhost:3500");

Order order = daprClient.get()
    .uri("/v1.0/invoke/orders-api/method/orders/{id}", orderId)
    .retrieve()
    .bodyToMono(Order.class)
    .block();

Pub/sub over Service Bus

orders-api publishes an order-placed event through the pubsub-orders component above once an order is persisted; any Dapr-enabled subscriber scoped to the same component (a future notification app, or a Dapr-side consumer standing in for notifier in an environment that does not need a job) declares a subscription and receives the event pushed to it, rather than polling. notifier itself, as an event-driven job, does not participate in this Dapr subscription — it reaches the same Service Bus topic/queue directly through its scale rule and the Service Bus SDK, precisely because jobs cannot run the Dapr sidecar. General Service Bus/AMQP messaging patterns beyond this Dapr-specific angle are covered in Messaging Protocols.

Dapr add-on concepts specific to AKS (the Dapr extension for AKS, alongside the KEDA add-on) are covered in Deploying Microservices on AKS rather than repeated here; this page only covers Dapr as Container Apps exposes it directly.

Health probes

Container Apps supports the same three probe types Kubernetes does, each governing a different failure response:

Probe Effect on failure

Liveness

The platform restarts the replica’s container.

Readiness

The replica is removed from the ingress load balancer (not restarted) until it reports ready again.

Startup

While a startup probe is active, liveness and readiness probes are disabled — it exists to give a slow-booting container (a JVM warming up, a Flyway migration running) time to finish without being killed by an impatient liveness probe or marked unready by a readiness probe with too short an initial delay.

There is no dedicated CLI flag surface for probes (no --liveness-probe-* family of parameters); they are set through the container app’s YAML template, either applied at creation or via an update.

In the Azure portal

  1. Open the app → Containers → select the container → Health probes tab.

  2. Add a probe, choose its type (Liveness, Readiness, Startup), the check mechanism (HTTP, TCP socket, or exec), the path/port, and timing (initial delay, period, failure threshold).

With the Azure CLI

# probes.yaml (partial container template)
containers:
  - name: orders-api
    image: acrbookshelf.azurecr.io/orders-api:1.0.0
    probes:
      - type: startup
        httpGet:
          path: /actuator/health
          port: 8080
        initialDelaySeconds: 5
        periodSeconds: 5
        failureThreshold: 12
      - type: liveness
        httpGet:
          path: /actuator/health/liveness
          port: 8080
        periodSeconds: 15
        failureThreshold: 3
      - type: readiness
        httpGet:
          path: /actuator/health/readiness
          port: 8080
        periodSeconds: 10
        failureThreshold: 3
az containerapp update --name orders-api --resource-group $RG --yaml probes.yaml

Volumes

Container Apps mounts three kinds of volume into a replica’s containers, none of which is meant to replace a managed database — they cover local scratch space, a shared network file share, and secret material:

Volume type Use and lifetime

Ephemeral (EmptyDir)

Local scratch space shared between containers of the same replica (main container plus any sidecar/init containers). Contents survive a container restart within the replica but are gone once the replica itself is removed — never durable, never shared across replicas.

Azure Files (AzureFile / NfsAzureFile)

A file share from an Azure Files storage account, registered once on the environment and then mounted by name (storageName) into any app or job that needs it — SMB (AzureFile) or NFS (NfsAzureFile). This is the only volume type that is both durable and shared across replicas and across apps.

Secret volume

A volume with storageType: Secret, projecting one or more of the app’s own configured secrets as files inside the container instead of (or in addition to) environment variables — useful for a library that expects a certificate or credentials file path rather than an env var.

In the Azure portal

  1. Open the app → Volumes (for Azure Files: register the storage first at the environment level under Storage → Add, naming it and pointing it at the storage account and file share).

  2. On the app, select Add volume, choose the type (EmptyDir, Azure File Share, or Secret), and for Azure Files select the environment-level storage name registered above.

  3. Under Containers → the container’s Volume mounts tab, mount the volume at a path inside the container.

With the Azure CLI

Register an Azure Files share on the environment once, then reference it by name from any app’s YAML template:

az containerapp env storage set \
  --name $ENV --resource-group $RG \
  --storage-name bookshelf-reports \
  --azure-file-account-name stbookshelf \
  --azure-file-account-key "$(az storage account keys list --account-name stbookshelf --resource-group $RG --query [0].value -o tsv)" \
  --azure-file-share-name reports \
  --access-mode ReadWrite
# volumes.yaml (partial container template)
volumes:
  - name: scratch
    storageType: EmptyDir
  - name: reports
    storageType: AzureFile
    storageName: bookshelf-reports
  - name: tls-cert
    storageType: Secret
    secrets:
      - secretRef: orders-api-tls-cert
        path: tls.crt
containers:
  - name: catalog-report
    image: acrbookshelf.azurecr.io/catalog-report:1.0.0
    volumeMounts:
      - volumeName: scratch
        mountPath: /tmp/work
      - volumeName: reports
        mountPath: /mnt/reports
az containerapp job update --name catalog-report --resource-group $RG --yaml volumes.yaml

Networking: VNet, private endpoint, internal environment, UDR/NAT

By default, a Container Apps workload profiles environment (the current default environment type) still gets its own managed virtual network, but every feature in this section — bringing an existing VNet, a private endpoint, an internal-only environment, user-defined routes and NAT — requires the environment to sit in a custom VNet with a dedicated, delegated subnet. Consumption-only (legacy) environments support a custom VNet too, but not UDR, NAT gateway egress control, or private endpoints — those three are workload-profiles-only.

Feature What it changes

Custom VNet

The environment’s infrastructure and (optionally) apps run inside a subnet Bookshelf owns and controls, instead of a Microsoft-managed one — required before any of the rows below apply. Minimum subnet size: /27 for a workload profiles environment, /23 for the legacy Consumption-only environment; the subnet must be delegated to Microsoft.App/environments.

Internal environment

--internal-only true at environment creation removes the environment’s public inbound endpoint entirely — the environment gets only an internal IP behind an internal load balancer, reachable solely from within the VNet or a peered/connected network. This is the setting Bookshelf would use to keep orders-api reachable from catalog-api and from a VPN-connected office network, but never from the public internet.

Private endpoint

A private endpoint on the environment lets other Azure VNets (not necessarily peered) reach it through Azure Private Link, without the environment itself needing to be --internal-only. Workload-profiles environments only.

User-defined routes (UDR) / NAT gateway

A UDR on the delegated subnet forces the environment’s egress traffic through a network virtual appliance or firewall instead of straight to the internet; a NAT gateway on the same subnet instead gives all outbound traffic one static public IP, which is the simpler option when the goal is just an allow-list-able egress IP rather than full traffic inspection. Both are workload-profiles-only, and Container Apps does not support the older KEDA HTTP proxy settings as an alternative route for this — a NAT gateway or UDR-routed appliance is the supported path.

In the Azure portal

  1. When creating the environment (or a new one to migrate into), on the Networking tab select Custom VNet, choose the delegated subnet, and set the VNet integration to Internal or External.

  2. To attach a private endpoint after creation, open the environment → Networking → Private endpoint connections → Add.

  3. To route egress through a NAT gateway, create the NAT gateway resource, associate it with the environment’s delegated subnet from the Subnets blade of the VNet (not from the Container Apps environment blade itself).

With the Azure CLI

az containerapp env create \
  --name $ENV --resource-group $RG --location $LOCATION \
  --infrastructure-subnet-resource-id "$(az network vnet subnet show --vnet-name vnet-bookshelf --name snet-cae --resource-group $RG --query id -o tsv)" \
  --internal-only true

az network private-endpoint create \
  --name pe-cae-bookshelf --resource-group $RG \
  --vnet-name vnet-bookshelf --subnet snet-private-endpoints \
  --private-connection-resource-id "$(az containerapp env show --name $ENV --resource-group $RG --query id -o tsv)" \
  --group-id managedEnvironments \
  --connection-name cae-bookshelf-connection

Deeper VNet peering, AKS-side networking (CNI plugins, network policies) and general Azure networking concepts belong to AKS Clusters and are not repeated here; this section only covers the Container-Apps-specific surface — environment-level custom VNet, internal-only, private endpoint, and UDR/NAT egress control.

Pricing model

Container Apps bills the Consumption plan per vCPU-second and GiB-second actually used while a replica is running, plus a per-request charge above a monthly free grant, and charges nothing while an app or job execution is scaled to zero — which is the direct economic reason notifier is worth running as an event-driven job rather than an always-on worker: it only consumes compute for the seconds it is actually processing a message. A replica that is idle but still resident in memory (below the minimum replica count, or kept warm during a scale-down stabilization window) can be billed at a lower "idle charge" rate rather than the full active rate, which is worth remembering when comparing an app kept at --min-replicas 1 against one left free to scale to zero. The Dedicated workload-profile plan instead bills for reserved vCPU/memory on dedicated instances regardless of utilization, and is the plan Bookshelf would reach for only if catalog-api or orders-api needed guaranteed capacity, larger instance sizes, or the isolation of not sharing underlying nodes with other customers' workloads — Bookshelf’s Consumption-only workloads described throughout this section do not need it. Jobs bill the same per-second Consumption model as apps, scoped to each execution’s actual running replicas rather than to the job definition itself, so a scheduled job that never has work to do (a catalog-report run that finds nothing new) still bills for the seconds its replica took to start up, run its check, and exit, but nothing between runs. Dapr’s sidecar container adds its own small vCPU/memory footprint to every replica it runs alongside, which is itself billed as part of that replica’s consumption.

Troubleshooting

A scale rule that never seems to fire is the most common failure mode, and it is almost always one of: the app has ingress disabled, --min-replicas 0, and no custom scale rule at all (the pitfall called out under the KEDA scale loop diagram above — nothing is polling anything, so the app never wakes); the custom scale rule’s --scale-rule-identity principal lacks the data-plane role needed to read the event source (a Service Bus scaler needs at least Azure Service Bus Data Receiver to read queue depth, not just Data Owner on the namespace for unrelated reasons); or the scale rule’s metadata does not match the event source’s actual resource names (a typo’d queueName silently measures depth 0 forever rather than erroring). az containerapp revision list and the app’s own Log stream blade (or whichever log destination the environment forwards to) show KEDA’s own scaler errors, which name the exact metadata key or authentication failure.

A job execution stuck in Running past its expected duration is usually --replica-timeout set too high for the actual workload, masking a hung container that should have failed fast; a job that fails every execution with zero retries attempted is --replica-retry-limit 0, which is correct for anything that must not silently repeat side effects (an email send, for instance) but wrong for anything that should tolerate a transient dependency outage. az containerapp job execution list plus az containerapp job logs show (or the log destination) narrow this down per execution.

A Dapr-enabled app that cannot reach a component almost always traces to one of: the component’s scopes list does not include the calling app’s Dapr app ID (a component with no scopes is available to every Dapr-enabled app in the environment, which is a security smell more often than an intended default); the app was not restarted after the component was added (a component change, like a scale-rule change, only takes effect on the next revision); or, for managed-identity component authentication, the identity referenced in the component YAML lacks the target service’s data-plane role, the same class of failure as a scale rule’s --scale-rule-identity.

An internal or custom-VNet environment that an app cannot reach from outside the VNet is very often working exactly as configured — --internal-only true intentionally removes the public endpoint — so the fix is usually a VPN/ExpressRoute/peering path into the VNet, or a private endpoint from the calling network, not a setting to revert. A NAT gateway or UDR that leaves the environment unable to reach anything at all typically means the route table or NAT association was placed on the wrong subnet, or omits the return path the environment’s control-plane traffic needs; re-check against the exact subnet association the References below call out before assuming the environment itself is broken.

Clean up

az group delete --name "$RG" --yes --no-wait