You can put Front Door, API Management and an internal Container Apps environment in front of orders-api and still ship an app that takes a token from any workload in your tenant. Container Apps authentication checks that API Management's managed identity token came from your tenant, and checks nothing else, so a workload with any identity calls the app directly and names whichever end user it likes. Entra closes that with one setting. The four hops in front of the app each run on a different answer to the same question, and only two of those answers are a credential.
Front Door takes TLS, the WAF and a per-client rate limit off the gateway's hands, which leaves API Management deciding who may call and Container Apps running the code. The scenario is the order API from Part 5, with the Function App gone.
The shape you land on, and the tiers it forces
Each tier below is the cheapest one with a feature the topology cannot do without.
Front Door Premium. On Standard, "only custom rules are supported": the managed Default Rule Set and Bot Manager are Premium, and so are Private Link origins. Either reason alone settles it.
API Management Standard v2. The gateway has to accept Front Door's traffic privately and send its own traffic into the VNet that holds the Container Apps environment. Those are separate networking features, and the tier table decides which instances get both:
Two constraints choose for you, and both bite late. Classic Premium looks like the enterprise answer and is the tier that blocks you: "In the classic API Management tiers, private endpoints aren't supported in instances injected in an internal or external virtual network." For the v2 tiers Microsoft's private endpoint guide says to "combine inbound private endpoints to Standard v2 instances with outbound virtual network integration to provide end-to-end network isolation", which leaves Standard v2 as the lowest tier that does both.
The other is an ordering rule, and it breaks pipelines. Integration on its own leaves the gateway and developer portal endpoints publicly reachable, so you switch public network access off once the private endpoint exists. You can only do that on an instance that already exists, never in the deployment that creates it, which means the gateway is public for as long as it takes you to run the second deployment. Give it an integration subnet of its own: at least /27, delegated to Microsoft.Web/serverFarms, with a network security group attached.
Container Apps: a workload profiles environment, internal, in your own VNet. You pick accessibility once, when you create the environment, and an internal one has no public endpoint. The subnet is fixed at creation too, and you cannot resize it afterwards: /27 minimum, delegated to Microsoft.App/environments. Size it for the most revisions you expect to keep active at once. Single revision mode briefly doubles address use during a revision change, and orders-api runs in multiple revision mode, where every active revision keeps its own minReplicas running on the same subnet (one address per 10 replicas on the Consumption profile, one per node on a dedicated one).
Service Bus Premium. Service Bus private endpoints are a Premium feature, and the data hop at the end of this article runs over them.
Front Door: what the edge actually does for you
WAF on a JSON API
The WAF policy is a separate resource from the profile, and a security policy attaches it to the custom domain:
resource wafPolicy 'Microsoft.Network/FrontDoorWebApplicationFirewallPolicies@2025-03-01' = {
name: 'wafOrdersApi'
location: 'global'
sku: { name: 'Premium_AzureFrontDoor' } // defaults to Classic_AzureFrontDoor when omitted
properties: {
policySettings: {
enabledState: 'Enabled'
mode: 'Detection'
}
managedRules: {
managedRuleSets: [
{
ruleSetType: 'Microsoft_DefaultRuleSet'
ruleSetVersion: '2.2'
ruleSetAction: 'Block'
}
]
}
customRules: {
rules: [
{
name: 'ApiPerClientRateLimit'
priority: 10
enabledState: 'Enabled'
ruleType: 'RateLimitRule'
rateLimitDurationInMinutes: 5
rateLimitThreshold: 3000
matchConditions: [
{
matchVariable: 'RequestUri'
operator: 'Contains'
matchValue: [ '/api/' ]
}
]
action: 'Block'
}
]
}
}
}
resource wafAttachment 'Microsoft.Cdn/profiles/securityPolicies@2025-06-01' = {
parent: frontDoorProfile
name: 'waf-api-contoso'
properties: {
parameters: {
type: 'WebApplicationFirewall'
wafPolicy: { id: wafPolicy.id }
associations: [
{
domains: [ { id: apiCustomDomain.id } ]
patternsToMatch: [ '/*' ]
}
]
}
}
}
Start in mode: 'Detection' on purpose. DRS 2.x uses anomaly scoring: a request is acted on once its score reaches 5, which one Critical match does on its own, and in Detection mode that action is only a log entry. Microsoft's rollout guidance is detection, then tuning, then prevention, and "the whole process might take several weeks." Do the tuning in Bicep or the CLI: changing the rule set version in the portal "resets all previous customizations".
Expect Authorization to be your first exclusion, because Entra access tokens in a request header "can contain special characters that trigger a false positive detection". The WAF also "doesn't support content-encoding", so a client that gzips its POST body gets no meaningful body inspection.
The rate limit rule is narrower than its property names suggest. It counts requests "from each socket IP address" over a fixed window of one or five minutes, and once a client crosses the threshold, "all traffic matching that rate limiting rule is blocked for the remainder of the fixed window." The action in the rule above is one of only two a rate limit rule accepts, Log or Block. Counters live on individual Front Door servers, so low thresholds leak: below about 200 requests a minute "you might see some requests above the threshold get through". The five-minute window with a larger threshold is the more accurate shape, and you take the number itself from a week of Detection logs.
Layer 3, 4 and 7 DDoS protection arrives with the profile whether you write a WAF policy or not.
One origin, and it is the gateway
The origin is API Management: Front Door cannot see apps behind an internal environment, and the per-path routing it did during the migration now happens in the gateway. If you leave API Management out, the Private Link origin is the environment itself, and Part 5 covered why that takes every app in it private at once.
resource apimOriginGroup 'Microsoft.Cdn/profiles/originGroups@2025-06-01' = {
parent: frontDoorProfile
name: 'apim-gateway'
properties: {
loadBalancingSettings: {
sampleSize: 4
successfulSamplesRequired: 3
}
healthProbeSettings: {
probePath: '/status-0123456789abcdef'
probeRequestType: 'GET'
probeProtocol: 'Https'
probeIntervalInSeconds: 30
}
}
}
resource apimOrigin 'Microsoft.Cdn/profiles/originGroups/origins@2025-06-01' = {
parent: apimOriginGroup
name: 'apim-primary'
properties: {
hostName: '${apim.name}.azure-api.net'
originHostHeader: '${apim.name}.azure-api.net'
httpsPort: 443
priority: 1
weight: 1000
enforceCertificateNameCheck: true
sharedPrivateLinkResource: {
privateLink: { id: apim.id }
groupId: 'Gateway'
privateLinkLocation: apim.location
requestMessage: 'Front Door Premium to the orders gateway'
}
}
}
resource apiEndpoint 'Microsoft.Cdn/profiles/afdEndpoints@2025-06-01' = {
parent: frontDoorProfile
name: 'orders-api'
location: 'global'
properties: { enabledState: 'Enabled' }
}
resource apiRoute 'Microsoft.Cdn/profiles/afdEndpoints/routes@2025-06-01' = {
parent: apiEndpoint
name: 'api'
dependsOn: [ apimOrigin ]
properties: {
customDomains: [ { id: apiCustomDomain.id } ]
originGroup: { id: apimOriginGroup.id }
patternsToMatch: [ '/api/*' ]
supportedProtocols: [ 'Https' ]
forwardingProtocol: 'HttpsOnly'
}
}
Deploying this does not connect anything yet. Front Door creates a private endpoint that shows up as a pending connection on the API Management instance, and traffic flows only after someone approves it with az network private-endpoint-connection approve. Put that step in the pipeline, or the deployment succeeds and serves nothing. A Private Link origin can't share an origin group with public origins, and it has a ceiling worth writing down before you size anything: "each Front Door regional cluster has a limit of 7200 RPS (requests per second) per Front Door profile", and requests past it come back 429.
The route has no cacheConfiguration, and leaving it out is how you disable caching for an API. The managed certificate on apiCustomDomain has a condition of its own: it renews by itself only if the domain's CNAME "points directly to a Front Door endpoint".
The probe tests the gateway
You are configuring this probe for a day that has not arrived yet. With one gateway region there is one origin, and a single-origin group routes to it whatever the probe reports. The probe starts deciding anything on the day you add a second region, which is also the day you would least like to discover you pointed it at the wrong path.
/status-0123456789abcdef is the path Microsoft's Front Door and API Management guide configures. It is built into every managed gateway outside the Consumption tier and answers 200 when the gateway is up, which is all a probe choosing between gateways needs to know. On a Private Link origin, health probes take the same network path as traffic.
An app route would fail as a probe target. The gateway rejects a probe that carries no subscription key or token, and the /health endpoint from Part 4 is mapped in Development only. When every origin fails, Front Door "considers all origins unhealthy and routes traffic in a round robin distribution across all of them": the probe fails open. App health is decided one layer down anyway, by readiness probes on the container apps and by a circuit breaker on the API Management backend.
API Management: the only layer that knows the caller
Front Door knows a socket address and Container Apps knows a token issuer. Only API Management knows which person or partner is calling and on which product, so most of this architecture's configuration lives in its policies.
A backend per app, reached through the VNet
There is no backend for "the Container Apps environment". An environment has no single URL: each app has its own FQDN and the environment's proxy routes on the host header. API Management gets one backend entity per app, and the VNet gets a private DNS zone that resolves those names to the environment's internal address:
param acaDefaultDomain string // az containerapp env show --query properties.defaultDomain
param acaStaticIp string // az containerapp env show --query properties.staticIp
resource ordersBackend 'Microsoft.ApiManagement/service/backends@2024-05-01' = {
parent: apim
name: 'orders-api'
properties: {
url: 'https://orders-api.${acaDefaultDomain}'
protocol: 'http'
}
}
resource acaZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
name: acaDefaultDomain
location: 'global'
}
resource acaRecords 'Microsoft.Network/privateDnsZones/A@2024-06-01' = [for recordName in [ '*', '@' ]: {
parent: acaZone
name: recordName
properties: {
ttl: 3600
aRecords: [ { ipv4Address: acaStaticIp } ]
}
}]
resource acaZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
parent: acaZone
name: 'vnet-orders'
location: 'global'
properties: {
registrationEnabled: false
virtualNetwork: { id: vnet.id }
}
}
The zone is named after the environment's default domain, and it has to be linked to the VNet that API Management integrates with, or the gateway resolves the public name and gets nowhere. Leave the host header alone: "by default, API Management overrides the host name that's sent to the back end" with the host of the URL it calls, which is the app FQDN the environment proxy routes on. Reference it from policy with backend-id rather than base-url, because Part 5 covered the trap in mixing the two across scopes.
The failure that survives all of this is a 404. Aspire maps an internal endpoint to internal-only ingress, so azd infra gen writes ingress.external: false for every project the AppHost doesn't mark with WithExternalHttpEndpoints(), and on an internal environment that setting hides the app from the rest of the VNet as well: the DNS name resolves and the TLS handshake succeeds, but the proxy rejects the request with a 404. It reads as a routing bug and is a visibility setting. Add WithExternalHttpEndpoints() to orders-api in the AppHost; on an internal environment, external ingress still keeps the app off the internet.
One inbound policy, in the order it has to run
The policy on the orders API does the rest of this section's work, and the order of the elements is load-bearing:
<policies>
<inbound>
<base />
<check-header name="X-Azure-FDID" failed-check-httpcode="403"
failed-check-error-message="Invalid request." ignore-case="false">
<value>{{FrontDoorId}}</value>
</check-header>
<validate-azure-ad-token tenant-id="{{TenantId}}" output-token-variable-name="caller">
<audiences>
<audience>{{OrdersPublicAudience}}</audience>
</audiences>
</validate-azure-ad-token>
<rate-limit-by-key calls="120" renewal-period="60"
counter-key="@("orders-" + ((Jwt)context.Variables["caller"]).Subject)" />
<set-header name="X-End-User-Id" exists-action="override">
<value>@(((Jwt)context.Variables["caller"]).Claims.GetValueOrDefault("oid", ""))</value>
</set-header>
<set-backend-service backend-id="orders-api" />
<set-header name="Ocp-Apim-Subscription-Key" exists-action="delete" />
<authentication-managed-identity resource="{{OrdersApiAppId}}" />
</inbound>
<backend><base /></backend>
<outbound><base /></outbound>
<on-error><base /></on-error>
</policies>
check-header goes first, so nothing downstream spends a token validation on a request that did not come through your profile. Front Door adds X-Azure-FDID to every request it forwards, and anyone with portal access can read the value, so it proves only that a request passed through your profile. That still matters, because Front Door's address ranges are shared: "IP address filtering alone isn't sufficient ... because other Azure customers use the same IP addresses", and someone else's profile pointed at your gateway passes an IP filter and fails this check. Keep it once Private Link is in place, as the backstop for the day public network access is switched back on. ignore-case is required on this policy.
validate-azure-ad-token is the reason the Front Door hop uses Private Link. Front Door can authenticate to an origin with a managed identity, but it "overwrites an existing Authorization header with its origin authentication token", so the caller's token would never reach this line unless a Front Door rule copied it into a different header first. The feature is still in preview: its authentication block exists only in a preview API version of the origin group, which also adds a tokenDestinationHeader that can move the token out of Authorization. Neither helps here, because the feature "isn't currently supported for origins with Private Link enabled". output-token-variable-name keeps the validated token as a Jwt object for the elements after it.
Microsoft's example for rate-limit-by-key keys on context.Request.IpAddress, which is the wrong key here. Behind Front Door, the connection API Management sees comes from Front Door, so an IP key throttles Front Door itself. The policy above keys on the validated subject instead, and the per-client-IP limit stays where it already works, in the WAF. Counters are tracked per gateway ("It doesn't aggregate call data across the entire instance") and shared by every scope that uses the same key, hence the orders- prefix. v2 tiers count with a token bucket where classic tiers use a sliding window, and Microsoft warns that inconsistent limits on a shared key there "can cause unpredictable behavior", so give every policy that shares a key the same numbers.
The last element, authentication-managed-identity, is the reason the two set-header elements above it are there at all. It requests a token for the orders-api app registration and writes it into Authorization, over the top of the caller's own token, so the end user's identity reaches orders-api only if you copy it out first: here as the oid claim in X-End-User-Id, with exists-action="override" so that a client sending its own X-End-User-Id gets it replaced rather than trusted. The subscription key goes in the same pass, because by default it is "passed to the backend and might be exposed in backend monitoring logs".
The gateway then caches that token until it expires, and the element "doesn't validate which backend the token is sent to". Anyone who can write an API policy can point this element at a resource of their choosing and collect the gateway identity's token, so treat policy-edit rights as token rights.
Subscription keys, per product
A partner's key is issued against a product that requires a subscription, and the API it contains is the one the policy above is attached to:
resource ordersHttpApi 'Microsoft.ApiManagement/service/apis@2024-05-01' = {
parent: apim
name: 'orders'
properties: {
displayName: 'Orders'
path: 'api/orders'
protocols: [ 'https' ]
subscriptionRequired: true
}
}
resource partnerProduct 'Microsoft.ApiManagement/service/products@2024-05-01' = {
parent: apim
name: 'orders-partners'
properties: {
displayName: 'Orders for partners'
subscriptionRequired: true
state: 'published'
}
}
resource partnerProductOrders 'Microsoft.ApiManagement/service/products/apis@2024-05-01' = {
parent: partnerProduct
name: ordersHttpApi.name
}
A subscription key tells you which product a caller bought, and it is a shared secret with no lifecycle: API Management "doesn't provide built-in features to manage the lifecycle of subscription keys, such as setting expiration dates or automatically rotating keys". That is why the policy validates a token as well, and because tenant-id pins the issuer to your tenant, a partner needs a token your tenant issues: from an app registration you create for them, or from their multitenant app once it is consented into your tenant.
The same page describes the configuration that quietly disables every key you have issued. "If the key isn't valid but a product exists that includes the API without requiring a subscription (an open product), API Management ignores the key and handles the request as an API request without a subscription key." One open product left over from a demo turns a revoked partner key into anonymous access. Scope is the other trap: requests made with an API-scoped, all-APIs or built-in all-access subscription skip product-scope policies entirely. Keep the checks in the policy above at API scope, and use product scope for what legitimately differs per product, such as rate-limit and quota, which only apply when a subscription key is used.
Managed identity to Container Apps, and who else gets a token
Every check so far runs in the gateway, which holds up until a caller skips it.
Container Apps authentication, the Easy Auth sidecar, validates the token that the last policy element attaches, and on its own it accepts far more than the gateway. Microsoft's page for this exact flow says it "allows any client application in your Microsoft Entra tenant to request an access token and authenticate to the target app." Any workload in your tenant with a managed identity could call orders-api directly, set X-End-User-Id to any user's object ID, and look like API Management vouching for that user.
That is worth a second read, because it inverts the usual reading of this diagram. The hop that gets pointed at when someone calls this architecture secure is the hop where nothing about the caller is checked beyond which tenant it lives in.
Close it in Entra. Define an app role, assign it to the gateway's identity, and set assignment required on the orders-api service principal. The client credentials documentation says that setting "will block users and applications without assigned roles from being able to get a token for this application", so a workload without the role never gets as far as the sidecar:
az ad app update --id "$ORDERS_API_APP_ID" --set api.requestedAccessTokenVersion=2
az ad app update --id "$ORDERS_API_APP_ID" --app-roles \
'[{"allowedMemberTypes":["Application"],"value":"Orders.Gateway","displayName":"Orders gateway","description":"API Management calls orders-api","isEnabled":true}]'
GATEWAY_ROLE_ID=$(az ad app show --id "$ORDERS_API_APP_ID" --query "appRoles[?value=='Orders.Gateway'].id" -o tsv)
# Role assignments and the assignment requirement live on the service principal, which may not exist yet.
ORDERS_SP_ID=$(az ad sp show --id "$ORDERS_API_APP_ID" --query id -o tsv 2>/dev/null \
|| az ad sp create --id "$ORDERS_API_APP_ID" --query id -o tsv)
az ad sp update --id "$ORDERS_SP_ID" --set appRoleAssignmentRequired=true
# The Azure CLI has no command for assigning an app role to a managed identity.
APIM_PRINCIPAL_ID=$(az resource show --ids "$APIM_ID" --query identity.principalId -o tsv)
az rest -m POST \
-u "https://graph.microsoft.com/v1.0/servicePrincipals/$APIM_PRINCIPAL_ID/appRoleAssignments" \
-b "{\"principalId\":\"$APIM_PRINCIPAL_ID\",\"resourceId\":\"$ORDERS_SP_ID\",\"appRoleId\":\"$GATEWAY_ROLE_ID\"}"
az containerapp auth microsoft update \
--resource-group rg-orders --name orders-api \
--client-id "$ORDERS_API_APP_ID" \
--issuer "https://login.microsoftonline.com/$TENANT_ID/v2.0" \
--yes
az containerapp auth update \
--resource-group rg-orders --name orders-api \
--enabled true --unauthenticated-client-action Return401
The first command exists because of the issuer line. The token version API Management receives is chosen by the orders-api registration: a requestedAccessTokenVersion of null or 1 results in v1.0 tokens, and the manifest reference is explicit that null is where a registration starts, since "if the value is null, this parameter defaults to 1, which corresponds to the v1.0 endpoint". A v1.0 token's issuer is https://sts.windows.net/<tenant>/, which never matches the /v2.0 URL Microsoft tells you to configure. Set the registration to 2 and the two match by construction.
Assignment required decides who gets a token. Any role on the registration satisfies it, and Easy Auth "doesn't perform the validation steps" for roles, so a role check in code is the second line, for the day someone gives another identity a different role on the same registration:
using System.Text.Json;
public static class GatewayCaller
{
private sealed record PrincipalClaim(string Typ, string Val);
private sealed record ClientPrincipal(string? RoleTyp, PrincipalClaim[]? Claims);
private static readonly JsonSerializerOptions Json =
new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
public static bool HasRole(HttpRequest request, string role)
{
string? header = request.Headers["X-MS-CLIENT-PRINCIPAL"];
if (string.IsNullOrEmpty(header)) return false;
if (JsonSerializer.Deserialize<ClientPrincipal>(Convert.FromBase64String(header), Json)
is not { Claims: { } claims, RoleTyp: var roleType })
return false;
return claims.Any(c => (c.Typ is "roles" || c.Typ == roleType) && c.Val == role);
}
}
var orders = app.MapGroup("/api/orders")
.AddEndpointFilter(async (context, next) =>
GatewayCaller.HasRole(context.HttpContext.Request, "Orders.Gateway")
? await next(context)
: Results.StatusCode(StatusCodes.Status403Forbidden));
The identity headers come from the sidecar, and "external requests aren't allowed to set these headers". The Base64 X-MS-CLIENT-PRINCIPAL header and its role_typ field are documented for App Service's Easy Auth, whose claims "undergo a default claims-mapping process", which is why the filter accepts roles or whatever role_typ names. Role changes also arrive late. Managed identity back ends cache tokens per resource "for around 24 hours", and API Management caches its own until expiry, so a token issued before the assignment can keep arriving without the roles claim, and the filter answers 403 until it ages out.
Container Apps: the layer that decides who gets in and how many answer
Which ingress settings let the gateway in
Whether API Management can reach orders-api at all depends on the environment's accessibility, fixed when the environment is created, and on the app's external flag, which you can change on any deploy without creating a revision.
The bottom-right cell is the Aspire 404 from the backend section, and the bottom-left cell is where orders-api belongs. inventory-api sits outside the table, because it has no ingress at all. Other Dapr-enabled apps still invoke it through Dapr service invocation, which is the only way anything in this design calls it.
This is orders-api as the rest of the section configures it:
param blueSuffix string
param greenSuffix string = ''
param greenWeight int = 0
param apimIntegrationSubnetPrefix string
var latestSuffix = !empty(greenSuffix) ? greenSuffix : blueSuffix
resource ordersApp 'Microsoft.App/containerApps@2026-01-01' = {
name: 'orders-api'
location: location
identity: { type: 'SystemAssigned' }
properties: {
environmentId: acaEnvironment.id
configuration: {
activeRevisionsMode: 'Multiple'
ingress: {
external: true
targetPort: 8080
transport: 'auto'
ipSecurityRestrictions: [
{
name: 'apim-outbound'
ipAddressRange: apimIntegrationSubnetPrefix
action: 'Allow'
}
]
traffic: !empty(blueSuffix) && !empty(greenSuffix) ? [
{ revisionName: 'orders-api--${blueSuffix}', label: 'blue', weight: 100 - greenWeight }
{ revisionName: 'orders-api--${greenSuffix}', label: 'green', weight: greenWeight }
] : [
{ revisionName: 'orders-api--${blueSuffix}', label: 'blue', weight: 100 }
]
}
dapr: { enabled: true, appId: 'orders-api', appPort: 8080 }
}
template: {
revisionSuffix: latestSuffix
containers: [
{ name: 'orders-api', image: 'acrorders.azurecr.io/orders-api:${latestSuffix}' }
]
scale: {
minReplicas: 1
maxReplicas: 20
rules: [
{ name: 'http-rate', http: { metadata: { concurrentRequests: '40' } } }
]
}
}
}
}
The only caller with ingress access is API Management, leaving from its integration subnet, so the allow rule is that subnet's prefix; Front Door's addresses never reach this app. Rules take CIDR ranges only, and all of one type ("You can't combine allow rules and deny rules"), and they cannot read a header, so the X-Azure-FDID check stays in the gateway. If a browser client ever appears, CORS goes in API Management's cors policy as the first inbound element, because during a preflight "only the cors policy is evaluated on the OPTIONS request" and the request never reaches the app.
The HTTP rule counts a rate, not concurrency
concurrentRequests: '40' reads as "add a replica when 40 requests are in flight". It means something else. "Every 15 seconds, the number of concurrent requests is calculated as the number of requests in the past 15 seconds divided by 15": requests per second, averaged over a 15-second window, and Microsoft's own example describes its setting as "100 concurrent requests per second". The replica count then follows desiredReplicas = ceil(currentMetricValue / targetMetricValue).
Request duration appears nowhere in that formula, so one number can be wrong in both directions. Take the rule above on paper. An order lookup answering in 25 ms at 400 requests per second holds about 10 requests in flight across the whole app, and the rule asks for ceil(400 / 40) = 10 replicas, each doing one thing at a time. A report export taking 4 seconds at 5 requests per second holds 20 requests open and gets ceil(5 / 40) = 1 replica. The first overspends, the second queues, and the replica chart for both looks like autoscaling working.
Set the value from throughput. Load one replica until latency starts to climb, note the request rate it sustained, and set concurrentRequests below that. If an app serves both shapes of request, the rule can be right for one of them only, which is an argument for moving the slow endpoint into its own app. maxReplicas is the cost ceiling either way: the scale limits table puts its default at 10 with 1,000 configurable, and replicas arrive in a scale up step of "1, 4, 8, 16, 32, ... up to configured maximum replicas".
minReplicas: 1 matters more here than it did during the migration. Nothing sends orders-api traffic on a schedule any more, because Front Door's probe stops at the gateway's status path, so at zero replicas the first call after a quiet spell waits for a replica to start while API Management holds the request open. scale.cooldownPeriod does not soften that: "The cool down period only takes effect when scaling in from the final replica to 0."
Queue depth for the app nobody calls over HTTP
inventory-api scales on the backlog of the orders topic instead:
resource inventoryApp 'Microsoft.App/containerApps@2026-01-01' = {
name: 'inventory-api'
location: location
identity: { type: 'SystemAssigned' }
properties: {
environmentId: acaEnvironment.id
configuration: {
activeRevisionsMode: 'Single'
dapr: { enabled: true, appId: 'inventory-api', appPort: 8080 }
}
template: {
containers: [
{ name: 'inventory-api', image: 'acrorders.azurecr.io/inventory-api:${releaseSuffix}' }
]
scale: {
minReplicas: 1
maxReplicas: 10
rules: [
{
name: 'orders-backlog'
custom: {
type: 'azure-servicebus'
identity: 'system'
metadata: {
namespace: serviceBusNamespaceName
topicName: 'orders'
subscriptionName: 'inventory-api'
messageCount: '30'
}
}
}
]
}
}
}
}
There is no ingress block, and activeRevisionsMode: 'Single' is deliberate: the scale rules page asks for single revision mode whenever a scale rule is not an HTTP one, which is one reason the canary lives on orders-api. messageCount is a target per replica, so 150 waiting messages ask for five replicas, and subscriptionName has to name the subscription the Dapr component drains, the pairing Part 5 covered. Keep namespace even though that Microsoft sample leaves it out: KEDA's Service Bus scaler requires it when it authenticates with an identity, and no Container Apps page says the platform fills it in.
identity: 'system' authenticates the rule with the app's own identity. Container Apps documents neither the Service Bus role that identity needs nor whether the scaler reaches a namespace with public access disabled. The nearest answers are both too generous: the AKS guide for KEDA assigns Azure Service Bus Data Owner, and KEDA's own reference wants a Manage policy for a connection string, either of them more than a consumer should hold. That is why minReplicas is 1: a replica that exists keeps draining the subscription whether or not the scaler can read it, and it is also awake for Dapr invocations from orders-api, which don't pass through ingress and so can't wake an app from zero.
Revisions: the rollout Front Door no longer does
In Part 5, Front Door origin weights moved traffic between two runtimes. With one origin and one gateway, the rollout axis is the app's revisions, two layers below anything Front Door can see.
The traffic block follows Microsoft's blue-green template. The first deploy sets only blueSuffix, and the single entry sends everything to the one revision that deploy creates. The next deploy sets greenSuffix, creates the green revision and keeps it at greenWeight: 0. Weights split the requests that arrive on the app's own FQDN, which is the URL API Management's backend calls, so a deploy with greenWeight: 10 is a canary for every caller of the API with no gateway change. Labels give a revision its own address, orders-api---green.<default domain>, and a label keeps its URL when you move it to another revision. The zone's * record already resolves that name, but the smoke test still has to come from an address ipSecurityRestrictions allows: add a test operation in API Management that targets the label address, or add your build agents' subnet to the rule for the duration of the rollout.
Promotion follows the template too: az containerapp ingress traffic set --label-weight blue=0 green=100, then a deploy with greenWeight: 100 so the next template run doesn't put the old split back. Rollback is the same with the numbers reversed. Neither creates a revision, because ingress changes apply to every revision at once. Keeping both revisions active has a price: minReplicas counts per revision, so each one holds its own warm replica, and sticky sessions are single revision mode only, which Part 5 ran into from the Front Door side.
The trust chain, hop by hop
Here are the four hops from the introduction again, with what the receiving side of each one actually relies on:
You settled the first two in the gateway section. From Front Door to API Management, trust rests on a network path and an identifier, and neither of those is a credential, which is why check-header stays in the policy. From API Management to orders-api, the assignment requirement decides which identities can get a token at all, and your role check confirms that the one which arrived carries the gateway's role.
Sidecar to app: the token you no longer set
Container Apps generates APP_API_TOKEN for you. The platform injects it into every Dapr-enabled app, "unique per each app and app revision", and the supported dapr settings have no field for it or for DAPR_API_TOKEN. Part 3's endpoint filter still does the checking, and its fail-open branch never runs in Azure: the variable is always set when Dapr is enabled. The token "can also change at any time", so never copy it into Key Vault or another app's settings.
Placement matters with two filters in play. The token filter goes on routes the sidecar calls: inventory-api's invoked methods and its pub/sub handler. It never goes on the orders-api routes API Management calls, because those requests carry no dapr-api-token and every one of them would get a 401. The GatewayCaller role check is the mirror image and belongs only on those gateway routes.
The token proves that a call came through inventory-api's own sidecar. It does not say which app sent it. The sidecars secure that call with mutual TLS on their own, but the Dapr access control policies that would restrict which app IDs may invoke inventory-api live in the Configuration spec, which Container Apps does not support. Any Dapr-enabled app in the environment can call it, so on this hop the boundary is who is allowed to deploy into the environment.
App to data, and the network under all four hops
On the last hop managed identity carries the trust with no secret anywhere, and what you add is plumbing. Both apps reach Service Bus and Cosmos DB with their own identities and data-plane role assignments, wired as in Part 2.
The VNet in the diagram carries four subnets with one owner each: API Management's inbound private endpoint, its /27 integration subnet, the environment's /27, and snet-pe for the data services. With public network access disabled on both services, each app resolves them through private DNS zones, privatelink.servicebus.windows.net and privatelink.documents.azure.com, linked to the same VNet as the environment's own zone. Miss a link and the name resolves to a public IP instead of the private one, so the failure shows up as a refused or timed-out connection from the app, with no DNS error anywhere.
Verify on first deploy
Each item below rests on documentation that describes the pieces separately and never shows them working together, so check it once in your own subscription:
- The Front Door origin reports healthy once the private endpoint connection is approved.
-
A managed identity without the app role is refused a token for
orders-api. The assignment requirement is documented for applications in general, never for a managed identity. -
One decoded
X-MS-CLIENT-PRINCIPALfrom a real gateway call tells you the claim type the role arrives under. -
A call to
orders-apifrom a VM in another subnet is refused. That is the ingress proxy comparing the source address you expect it to compare. -
The
inventory-apireplica count follows a test backlog with public network access disabled on the namespace, which proves the scaler reaches Service Bus with the role you granted.
Conclusion
This architecture gets sold on one line: each layer authenticates to the next with managed identity. On the data hop that line is true. On the hop from API Management to orders-api it is true only after Entra refuses tokens to identities without the app role, and false every minute before that. The other two hops never claimed it in the first place. Front Door to API Management runs on a network path and an identifier, and sidecar to app runs on a token the platform generates for you.
It is still a sound design, as long as the checks carrying the trust that managed identity does not carry are actually deployed. The expensive mistake is dropping the header check or the assignment requirement because "it's managed identity". The other price is on the invoice: no public origin means a Premium Front Door profile, a v2 gateway and a Premium namespace, all billed before the first container starts, so price them for your region before the design review rather than after it.
Part 7 puts this stack into Terraform, where the steps this article ran by hand (the private endpoint approval, the app role, the assignment requirement) have to become code or be forgotten in the next environment.
Have you set assignment required on your API's service principal, or are you relying on the role check in app code?




Top comments (0)