Part 1 handed the job of triggers and bindings to Dapr and then stopped, which leaves the obvious question sitting there: what does a second process in every replica give you that a NuGet package cannot? The honest answer is narrower than the pitch. Dapr does not remove your HTTP client, your message schema, or your working knowledge of Cosmos DB, and the .NET SDK makes that point better than any blog post can, having deprecated its own service invocation methods in favour of a plain HttpClient. It removes exactly one thing: the part of your code that names a backing service. Whether that trade pays for the sidecar it arrives in is what I want to settle before Part 3.
The sidecar, and what it costs you
Dapr does not arrive as a library your application calls. It arrives as a separate process named daprd running beside yours: a second container in the same Container Apps replica or Kubernetes pod, a second OS process under dapr run on your laptop. Your code never links an assembly that knows how to reach Cosmos DB. It makes a loopback call to daprd, and daprd makes the call that leaves the machine.
Two ports carry that loopback traffic: 3500 for the Dapr HTTP API and 50001 for gRPC. Both are defaults rather than guarantees, and the environment variable reference says to read DAPR_HTTP_PORT "instead of hardcoding the port value".
// 3500 holds for exactly as long as nobody passes -H to dapr run.
// The CLI sets DAPR_HTTP_PORT for self-hosted runs, and the
// dapr-sidecar-injector sets it on every container in the pod.
var port = Environment.GetEnvironmentVariable("DAPR_HTTP_PORT") ?? "3500";
var sidecar = new Uri($"http://localhost:{port}");
DAPR_GRPC_PORT is the same contract for 50001. Container Apps documents both ports inside the replica, so the constant would survive there. Read the variable anyway: the same binary runs on your laptop, where -H and -G are one flag away from moving both.
Whichever protocol you picked to reach your own sidecar, sidecar-to-sidecar traffic is always gRPC. The choice changes only how your process talks to a process on the same host.
What it costs
The numbers Dapr publishes come from AKS, three Standard_D2s_v6 nodes, and they spread wider than the single figure that gets quoted in every Dapr thread. At roughly 1,000 iterations per second the sidecar holds 44 to 51 MB of memory, while CPU swings by more than an order of magnitude depending on what you are calling: 4 millicores for a state get over gRPC, 17 for service invocation over HTTP, 106 for a pub/sub publish. Actors and workflow are measured at lower throughputs and cost more memory per replica, up to 241 MB on the actor stress test. Treat 50 MB as a floor, not a budget.
There is a question those numbers do not answer, and I could not find anyone who does: whether the sidecar in Container Apps eats your app's allocated CPU and memory, adds to it, or comes free. No Learn page says either way. So the figures above are the only concrete ones in reach, and they were measured on AKS rather than on the platform this series is heading for.
The startup race
The sidecar is not ready when your container starts. It reaches readiness "once the application is accessible on its configured port", and until then "the application cannot access the Dapr components".
That ordering breaks the obvious pattern of loading configuration from a Dapr secret store in Program.cs. The escape hatch is a second health endpoint: GET /v1.0/healthz checks components, the HTTP port, and the app channel, while GET /v1.0/healthz/outbound runs the same check without the app channel, so it answers while your own startup is still in progress. Both return 204 when healthy and 500 when not, and code that tests for 200 reports a healthy sidecar as broken. Do not depend on the first one in application code: it fails for apps using the Actor and Workflow APIs and creates a circular dependency everywhere else.
The .NET SDK ships CheckOutboundHealthAsync and WaitForSidecarAsync for this. The docs limit both to secrets and configuration retrieval, promise to remove them in a future release, and attach a caveat that will cost you an afternoon: an application that waits on WaitForSidecarAsync without using actors, secret management, configuration retrieval, or workflows "will indefinitely lock up during startup", because the runtime never opens an outbound connection for it to wait on. Read "indefinitely" literally. DAPR_HEALTH_TIMEOUT caps the runtime's own 60-second wait, but the SDK method is a poll loop with no timeout in it: it ends when the sidecar answers or when you cancel the token, and on the path above it does neither.
The reverse race has its own answer: app health checks, off by default, enabled with --enable-app-health-check, hold back every pub/sub subscription, input binding, and inbound invocation until the first probe of your app succeeds.
What the five building blocks actually remove
Every building block has the same two parts, and no third: an HTTP or gRPC surface on localhost, and a YAML file naming the implementation behind it. Your code calls a path, the path names a component, and a type field in that YAML decides whether the name resolves to Redis or to Cosmos DB.
Part 1 made the case that a Functions binding is a property of the host. This is the same contract moved out to the environment, and each of the five below bites in its own way.
Service invocation: you keep the HttpClient
The surface is one route with every verb on it, /v1.0/invoke/<appID>/method/<method-name>, where <appID> is a logical name, not a host. Cross-namespace targets use <appID>.<namespace>, and targets that are not Dapr apps put an HTTPEndpoint resource name or an FQDN in the same slot. A second form matters more for migration, because it leaves existing URLs alone and moves the routing into a dapr-app-id header.
The .NET SDK has already walked away from its own helper for this.
// Before: the call that docs.dapr.io still teaches.
// Compiles with warning CS0618: ... is obsolete: 'Recommended guidance is to
// use a native HTTP or gRPC client for service invocation'
await daprClient.InvokeMethodAsync<ReserveStock>("inventory", "reserve", request);
// After: an ordinary HttpClient whose BaseAddress is http://inventory.
// One client per target app ID, because the app ID is the base address.
builder.Services.AddSingleton(_ =>
new InventoryClient(DaprClient.CreateInvokeHttpClient(appId: "inventory")));
public sealed class InventoryClient(HttpClient http)
{
public async Task<Reservation?> ReserveAsync(ReserveStock request, CancellationToken ct)
{
var response = await http.PostAsJsonAsync("/stock/reserve", request, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Reservation>(ct);
}
}
CreateInvokeHttpClient installs an invocation handler and sets BaseAddress to http://<app-id>, throwing when that is not a legal hostname. Because the app ID is the base address, you get one client per target app whether you wanted the ceremony or not, and the XML docs add that passing appId stops being optional the moment your app ID contains an uppercase letter.
Strip the pitch back and the claim gets smaller, and more interesting for it. Dapr does not replace your HTTP client. It replaces the service discovery, the mTLS, the retries, the tracing, and the round-robin load balancing behind it. The hostname, the port, the certificate handling, and the Polly policy all go.
State management: the key you write is not the key that lands
The API is asymmetric in a way that shows up the first time you read a trace: a save puts the key in a body array, a get and a delete put it in the path. Three .NET methods cover almost everything, and none is deprecated:
public sealed record Order(string OrderId, string CustomerId, decimal Total, string Currency);
// "statestore" is the component's metadata.name. Your code never names the type.
const string StoreName = "statestore";
var order = new Order("order-1041", "cust-8802", 149.95m, "EUR");
// POST /v1.0/state/statestore (the key travels in the body)
await daprClient.SaveStateAsync(StoreName, order.OrderId, order);
// GET /v1.0/state/statestore/order-1041 (and DELETE on the same path)
var stored = await daprClient.GetStateAsync<Order>(StoreName, order.OrderId);
Concurrency is optimistic and ETag-based. concurrency takes first-write or last-write, consistency takes strong or eventual, and on a save both ride in an options object next to the value. Send no ETag and first-write behaves as last-write-wins. Eventual is the default, because "Dapr assumes data stores are eventually consistent by default": read that again if you just configured a Cosmos DB account with strong consistency and assumed the component inherited it.
Do not go looking for a status code on an ETag mismatch, and do not trust the 409 that circulates online. A save, a delete, and a get for a missing key all answer 204; a concurrency failure arrives as an error body carrying ERR_STATE_SAVE and "possible etag mismatch". The 409 traces back to dapr/dapr#2619, a proposal that never shipped.
The next one shows up in the portal, not the debugger. Dapr prefixes every state key with your app ID, using || as the separator. The key your code passes is order-1041; the document that lands in Cosmos DB has the id orders-api||order-1041. Nothing in your code mentions the prefix and nothing in the portal explains it. Two consequences: two Dapr apps cannot read each other's state, and any existing system reading that container looks for order-1041 and finds nothing. The keyPrefix metadata field is the lever, taking appid (the default), name, namespace, or none.
Two constraints here turn into component decisions later: /transaction depends on a store that supports transactions, and actor state additionally requires actorStateStore: "true" on a store that can do those transactions, with the docs adding that a distributed database behind it has to provide strong consistency. That is the first crack in the abstraction.
Pub/sub: the envelope you did not ask for
Publishing is one call, POST /v1.0/publish/<pubsubname>/<topic>, with any metadata as query parameters. Delivery is at-least-once, so your handler has to tolerate seeing the same order twice. The subscriber acknowledges with a 2xx and a body of {"status": "<status>"}, where the status is SUCCESS, RETRY, or DROP.
In .NET, PublishEventAsync(pubsubName, topicName, payload) sends the order. What arrives at the other end is not the order.
{
"id": "5929aaac-a5e2-4ca1-859c-edfe73f11565",
"source": "orders-api",
"type": "com.dapr.event.sent",
"specversion": "1.0",
"datacontenttype": "application/json",
"time": "2026-08-21T09:14:02Z",
"topic": "orders",
"pubsubname": "orderpubsub",
"traceid": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"tracestate": "",
"data": {
"orderId": "order-1041",
"customerId": "cust-8802",
"total": 149.95,
"currency": "EUR"
}
}
CloudEvents is the default, and it is the number one first-timer surprise. Your subscriber's model binder sees id, source, and data, binds nothing useful, and hands your handler an Order with every property null. The bug report writes itself, and the fix is one line:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers().AddDapr();
var app = builder.Build();
app.UseCloudEvents(); // unwraps the envelope above, so binding sees `data`
app.MapControllers();
app.MapSubscribeHandler(); // serves GET /dapr/subscribe from the [Topic] attributes
app.Run();
[ApiController]
public sealed class OrderEventsController : ControllerBase
{
[Topic("orderpubsub", "orders")]
[HttpPost("/orders/placed")]
public async Task<IActionResult> OnOrderPlaced(Order order, OrderStore store)
{
await store.RecordAsync(order);
return Ok(new { status = "SUCCESS" }); // RETRY redelivers, DROP discards
}
}
Remove app.UseCloudEvents() and the same code compiles, runs, and silently records empty orders.
The subscription itself can be declared three ways, and the operational difference matters more than the mechanics. Declarative subscriptions are a YAML Subscription resource that "removes the Dapr dependency from your code" and hot reloads by default. Programmatic subscriptions are the [Topic] attributes above, "only read once during application start-up", so a new topic means a restart. Streaming subscriptions invert the flow: the application pulls from Dapr through Dapr.Messaging.PublishSubscribe, and a subscription can start and stop at runtime.
You can opt out of the envelope with rawPayload, and the bill is longer than it looks: raw mode "disables support for tracing, event deduplication per messageId, content-type metadata, and any other features built using the CloudEvent schema". There is also a naming asymmetry with no defence: the publish-side key is rawPayload, while .NET and the Kubernetes CRDs spell it isRawPayload.
Dapr provides dead letter topics for every pub/sub component, "even if the underlying system does not support this feature natively", set with deadLetterTopic on the subscription. Then the default that catches everyone:
By default, when a dead letter topic is set, any failing message immediately goes to the dead letter topic.
No retry, no backoff, no second chance: one transient timeout against your database and the order is in the dead letter topic. Configure a retry resiliency policy first, then enable the dead letter topic.
Message TTL is ttlInSeconds in metadata, enforced by the runtime, so every component supports it. Azure Service Bus is the one component with native entity-level TTL, where Dapr hands the metadata to the broker.
Bindings: the trigger, minus the host
Direction is the part everyone gets backwards. An output binding is your application calling out through the sidecar; an input binding is Dapr calling in, to a route on your own app port.
Output binding calls go through DaprClient.InvokeBindingAsync, with a required operation field. The generic set is create, update, delete, and exec, but the legal values are whatever the component implements, so get, list, and query show up on some and not others; the component's reference page is the only list that binds. On the input side there is no DaprClient method at all.
// Output binding: your app calls out through the sidecar.
await daprClient.InvokeBindingAsync("order-archive", "create", order);
// Input binding: Dapr calls in. The route name is the component's metadata.name.
app.MapPost("order-received", async (Order order, OrderService orders) =>
{
await orders.HandleAsync(order);
return Results.Ok(); // anything other than 200 OK schedules redelivery
});
// Dapr probes every input binding route with OPTIONS on startup and expects
// 2xx or 405. A 404 here means no events, ever.
app.MapMethods("order-received", [HttpMethods.Options], () => Results.Ok());
That OPTIONS probe is the failure mode to remember, because it produces no error anywhere: a router that answers 404 never subscribes, never receives an event, and never logs a reason. The component behind the route is an ordinary bindings.azure.storagequeues resource named order-received, and its direction property, while not required, "is highly recommended" for input bindings.
Compared against [QueueTrigger], this is more ceremony, not less: an endpoint, a component file, and a name that has to match in two places, where an attribute used to do it. In exchange you get an ordinary HTTP endpoint, one that runs in a test, a console host, or any service with no Functions host around to supply the trigger. You traded brevity for portability.
Secrets: one API with a dangerous default
Two routes, /v1.0/secrets/<store-name>/<name> for one secret and /bulk for everything, and a .NET method behind each of them. GetSecretAsync is the one you will use:
// The declared return type is Dictionary<string, string>, not your secret.
Dictionary<string, string> secret =
await daprClient.GetSecretAsync("orderssecrets", "orders-cosmos-key");
// Azure Key Vault is a name/value store, so the field is the secret name:
// { "orders-cosmos-key": "..." }
var cosmosKey = secret["orders-cosmos-key"];
// A Kubernetes store returns the inner data keys instead, and no secret name:
// { "accountKey": "...", "accountEndpoint": "..." }
The return type is the gotcha. GetSecretAsync hands back a dictionary rather than your secret, because the response shape depends on the store type. Code written against a local file store in development still compiles against Kubernetes, and then throws KeyNotFoundException on the first lookup. In exchange the API takes away the vendor SDK, the chicken-and-egg of needing a secret in order to fetch secrets, and secrets in appsettings.json.
The default it ships with is the one to change:
Once you configure a secret store for your application, any secret defined within that store is accessible by default from the Dapr application.
Every secret. Not the ones your app asked for, not the ones it was scoped to. Narrowing it means a secrets.scopes policy on the Dapr Configuration resource, where allowedSecrets or deniedSecrets take priority over defaultAccess.
That fix has a sting attached. Azure Container Apps does not expose the Dapr Configuration spec at all, so secrets.scopes is not available there. Scope moves down to the Azure layer instead: a Key Vault holding only what this application needs, RBAC on that vault, and component scopes limiting which app IDs load the component at all.
Where the component model leaks
A component is a YAML resource, and four lines of it do the work:
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore # <- what your code says
spec:
type: state.azure.cosmosdb # <- what actually runs
version: v1
metadata: # <- how that implementation is configured
- name: url
value: "https://acct.documents.azure.com:443/"
scopes:
- orders-api # <- which app IDs may load it
Your code addresses statestore and never mentions state.azure.cosmosdb, which is the entire proposition: change spec.type from state.redis to state.azure.cosmosdb, redeploy, and the SaveStateAsync call above runs unchanged. Since Dapr v1.18 you do not even restart for it: components hot reload by default, going briefly unavailable while they close and re-initialize. Actor state stores and workflow backends are carved out of that, so the component whose swap you would most like to be quiet is the one that still needs a restart.
That promise holds at compile time. It leaks at the level of behaviour, in five places.
Capability is per component, and the code that used it stops working.
Swap statestore from Cosmos DB to Blob Storage without touching a line of C# and your build stays green while your /transaction calls fail, your TTLs stop expiring, and your actors refuse to activate. Note the Cosmos DB row too: every capability except workflow, which is not the gap anybody predicts.
Support level is per component as well. Azure Service Bus Topics pub/sub is Stable since runtime 1.0; the Queues variant is still Beta. Same building block, same publish call, different answer to "is this supported in production". Container Apps then disagrees with the project that printed the label and lists that same Beta queues component in its own fully-supported tier, so the answer also depends on who you ask.
The backing store's own constraints show through anyway. Cosmos DB requires the container's partition key to be named exactly /partitionKey, and the Service Bus subscription Dapr creates is named after your consumerID. Neither fact appears in the Dapr API surface, and both are the first thing you see in the Azure portal.
Emulation is not equivalence. Dead letter topics exist "even if the underlying system does not support this feature natively", which on Service Bus leaves you owning two dead letter mechanisms with different semantics. And stores without native ETags "are expected to simulate ETags", so your optimistic concurrency is as strong as somebody else's emulation of it.
The physical data is not what you wrote. orders-api||order-1041 is still in that container under a key no other system will look for.
The YAML swap does what it says at compile time. The behavioural equivalence is not real, and nothing in the API surface tells you which capabilities you gave up. Treat a component change as a dependency change with a review and a test pass behind it, not as a configuration change you make on a Friday.
Mapping the blocks onto Azure
None of the three services below is new to you. A state store component is the [CosmosDBOutput] binding you have already configured, a pub/sub component is the [ServiceBusTrigger] you have already debugged, and a secret store is the Key Vault reference already in your app settings. What changes is ownership: the connection detail leaves local.settings.json for a resource that outlives the app, and the identity belongs to the workload rather than to the Functions host.
Each mapping has one detail that fails your first deploy. Cosmos DB fails on a role assignment you cannot make in the portal, Service Bus on a permission nobody documents, Key Vault on a secret name that was never legal.
State store to Azure Cosmos DB
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore # the string your SaveStateAsync call passes
spec:
type: state.azure.cosmosdb
version: v1
initTimeout: 5m # sits on spec, not inside metadata
metadata:
- name: url
value: "https://acct-orders.documents.azure.com:443/"
- name: database
value: "orders"
- name: collection # the field is collection, not container
value: "orderstate"
- name: azureClientId # user-assigned identity
value: "8f3c1d2a-...-9b4e"
# no masterKey: dropping it is what selects Entra ID authentication
scopes:
- orders-api
# The data plane wants the identity's OBJECT id, not the client ID above.
PRINCIPAL_ID=$(az identity show \
--name id-orders-api --resource-group rg-orders \
--query principalId --output tsv)
# ...0002 is Cosmos DB Built-in Data Contributor (...0001 is Data Reader).
# There is no portal equivalent of this command.
az cosmosdb sql role assignment create \
--account-name acct-orders --resource-group rg-orders \
--scope "/" \
--principal-id "$PRINCIPAL_ID" \
--role-definition-id "00000000-0000-0000-0000-000000000002"
The field names are where the first hour goes. It is collection on a service whose portal has called it a container for years, and masterKey is documented as required "only when not using Microsoft Entra ID authentication", so dropping that one line is the switch onto the shared azure* credential fields. Raise initTimeout to 5m as the docs recommend, because Cosmos DB rate-limits metadata requests account-wide, exactly the request every cold sidecar makes at once.
Then the requirement that rejects a container you created last week:
The partition key for the collection must be named
/partitionKey(note: this is case-sensitive).
For ordinary state, the Dapr key doubles as the partition key value, which is why orders-api||order-1041 shows up in both fields of the same document. Actor state differs: the partition key comes from app ID plus actor type plus actor ID, so everything one actor owns lands on one physical partition, because actor operations are transactional and Cosmos DB transactions are single-partition.
The second code block catches people who have done Azure RBAC a hundred times. Cosmos DB for NoSQL does not use standard Azure RBAC for data-plane access. Contributor on the account buys management-plane rights and no ability to read a single document. You need a native Cosmos role assignment, identified by a fixed GUID, and two things about it will trip you:
-
--principal-idis the service principal object ID, whileazureClientIdtakes the client ID. Different GUIDs for the same identity, and the wrong one produces an assignment that exists and never applies. - Data-plane role assignments cannot be managed in the Azure portal. CLI, PowerShell, or Bicep, or it does not happen.
Pub/sub to Azure Service Bus
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: orderpubsub
spec:
type: pubsub.azure.servicebus.topics # Stable. The queues variant is Beta.
version: v1
metadata:
- name: namespaceName # FQDN, and required for Entra ID
value: "sb-orders.servicebus.windows.net"
- name: azureClientId
value: "8f3c1d2a-...-9b4e"
# Leave the next two out in development and Dapr creates the topic and the
# subscription on first use. Keep them for production, where the entities
# come from your IaC and the sidecar never needs admin rights.
- name: disableEntityManagement
value: "true"
- name: consumerID
value: "orders-fulfilment"
scopes:
- orders-api
connectionString and namespaceName are mutually exclusive: setting both fails the component with "connectionString and namespaceName cannot both be specified".
Leave those last two fields out, publish once, and the portal shows you a subscription you never created, named after your application. Dapr creates one Service Bus subscription per topic per consumerID, and consumerID defaults to your Dapr app ID. That default decides your delivery semantics without appearing in any config file: two applications with different app IDs each get their own subscription and both see every message, while two replicas of one application share a subscription and compete for it. Setting it explicitly, as the YAML above does, is how you make the choice visible to whoever reads it next.
Auto-creation carries a permission requirement nobody advertises. Dapr creates topics and subscriptions through the Service Bus admin client, which it constructs only when disableEntityManagement is false. I want to be straight about where that requirement comes from: the Dapr docs never state it. It is derived from the component source plus Microsoft's role definitions. Creating an entity is a management operation, and neither Data Sender nor Data Receiver includes management rights, so as it ships the component needs Azure Service Bus Data Owner. Verify that against your own namespace instead of taking it on trust, and know the shape of the failure: a subscriber whose sidecar cannot start, not a message that goes missing.
The least-privilege path is to create the entities in your IaC, set disableEntityManagement: "true", and assign scoped Data Sender and Data Receiver. Two Learn caveats come with it: an assignment for a topic subscription needs effective scope over that topic subscription resource, which the portal cannot assign at, and propagation takes up to five minutes, long enough that your first test run afterwards fails for a reason unrelated to your configuration.
Secrets to Azure Key Vault
secretstores.azure.keyvault has the shortest spec of the three: vaultName and the shared auth fields. No key mode, no connection string mode, so Entra ID is the only way in. Dapr's own setup creates the vault with --enable-rbac-authorization true and assigns Key Vault Secrets User. Dapr's page describes that role as "Get secrets", which undersells it by one action: it also reads secret metadata, which is exactly what the /bulk route needs in order to list names before it fetches values. No write, no delete, no keys, no certificates. Take the definition from Learn rather than from the Dapr page, and stop there.
The naming rule is where two separate facts usually get merged into one:
- Azure constrains a secret name to alphanumerics and hyphens, so a configuration key like
ConnectionStrings:Orderscannot be a Key Vault secret name at all. -
Dapr does no name translation and no JSON flattening for the Key Vault store.
GetSecretreturns one entry keyed by the name you asked for, so JSON in the value is an opaque string your own code parses.
The Section--Name to Section:Name convention that makes fact 1 survivable belongs to the ASP.NET Core Key Vault configuration provider, not to Dapr; through Dapr, a double hyphen is two hyphens in a name. The only Dapr secret store with a separator is the local file store, via nestedSeparator.
Keeping connection strings out of component YAML closes the loop: a metadata entry takes secretKeyRef in place of value, and auth.secretStore names the store as a sibling of spec:
spec:
metadata:
- name: connectionString
secretKeyRef:
name: orders-servicebus-connection # for Key Vault these two
key: orders-servicebus-connection # must be identical
auth:
secretStore: orderssecrets
The two differ only for a multi-key store such as Kubernetes, where name picks the secret and key picks a field inside it.
One asymmetry changes a runbook rather than a code file. Kubernetes secrets are re-read when they change; every other secret store, Key Vault included, is resolved once at component init. Rotate a secret in the vault and the running sidecar keeps the old value until it restarts, or until you touch the component manifest and let hot reload pick it up. Rotation is a deployment step here, not a vault operation.
The auth chain that hides a misconfiguration
Every Azure-backed component shares one credential model, and with none of its fields set Dapr walks a fixed chain where the first success wins: client credentials, client certificate, workload identity on AKS, SPIFFE, managed identity, and finally the Azure CLI.
That last entry is the problem. On your laptop, where az login succeeded weeks ago, a component with no identity configured authenticates through your own user account and the demo runs. Nothing in the logs suggests that the managed identity the component will use in Azure was never created, never assigned a Cosmos data-plane role, and never granted Key Vault Secrets User. You find all three at once, in a deployment, where the sidecar fails to initialize the component.
The fix is one metadata entry. azureAuthMethods: "managedidentity" restricts the chain to the listed methods, so the CLI credential is never tried and the local run fails the same way the deployment would. A failure you see at dapr run is a config change; the same failure in a deployment is a rollback.
Running it on your machine
dapr init
# Everything after -- is your application's own command line.
dapr run --app-id orders-api --app-port 5000 --resources-path ./components -- dotnet run
dapr init leaves four containers running, not the three that older walkthroughs show: Redis as your default state store and message broker, Zipkin for traces, the placement service for actors, and the scheduler service for jobs, which only arrived in Dapr 1.14. The companion sample is a .NET 10 minimal API that exercises service invocation, state management, and pub/sub against a sidecar: DaprDemo in azure-functions-samples. Its README carries the full local loop, and it holds the local Redis component YAML next to the Azure one so you can see exactly what changes between them.
Treat the CLI as a way to see the moving parts rather than as your inner loop: Part 4 replaces most of it with .NET Aspire, which models the sidecar and the components as part of the app host instead of as a YAML folder you remember to point at.
What Azure Container Apps gives you, and what it takes away
az containerapp create -n orders-api -g rg-orders \
--environment cae-orders --image acrorders.azurecr.io/orders-api:2026.08.21 \
--enable-dapr --dapr-app-id orders-api --dapr-app-port 8080 --dapr-app-protocol http
One flag turns it on, the others describe your application, and the sidecar is there, patched and upgraded without you. No Helm chart, no control plane, no injector, no placement or scheduler service to keep alive. For most teams that is the whole argument for Container Apps over AKS.
The Dapr settings are application-scope, so changing them restarts every existing revision; no new one is created. Components change shape too: in open-source Dapr a component belongs to an app or a namespace, while here it is an environment-level resource deployed once and visible to everything in the environment, with YAML simplified to match:
# statestore.yaml for Container Apps. The name is not in the file: it comes from
# az containerapp env dapr-component set --dapr-component-name statestore ...
componentType: state.azure.cosmosdb
version: v1
initTimeout: 5m # root level here, because there is no spec wrapper
metadata:
- name: url
value: "https://acct-orders.documents.azure.com:443/"
# database, collection, and azureClientId exactly as before
scopes:
- orders-api
No apiVersion, no kind, no spec wrapper, and componentType where spec.type used to be, which is why initTimeout sits at the root here and on spec in the open-source component earlier. The name comes from --dapr-component-name, not the file, so a component copied out of a docs.dapr.io tutorial is a rewrite, not a paste.
scopes carries more weight here, because every Dapr-enabled app in the environment loads every deployed component unless a scope says otherwise. And the values are Dapr app IDs, not container app names: the same string often enough that nobody notices until a --dapr-app-id diverges from the resource name and a component silently stops loading.
Identity wiring loses a moving part, because the component has no identity of its own. It uses the managed identity of the container apps in its scope, so the Cosmos role assignment from earlier is made against the app.
The constraints below are trades, not defects.
The Dapr Configuration spec is not available, stated exactly once, in a bullet list, as "any capabilities that require use of the Dapr configuration spec". If you carry one limitation out of this article, carry that one. Everything in the open-source Configuration resource goes at once: tracing sampling rates, access control policies between apps, API allowlists, feature flags, spec.mtls, and secrets.scopes. Tracing itself survives the cut, wired at the environment level instead through daprAIConnectionString or an OpenTelemetry configuration with includeDapr set; the sampling rate is the one knob with no documented replacement.
secrets.scopes is the item to sit with, because it is the fix the secrets section pointed you at and it is not here. Your Key Vault store hands every secret in the vault to every app scoped to the component, and your only boundary is the vault itself and the RBAC on it.
mTLS between sidecars is on, and there is no documented way off. No Learn page says you cannot disable it; the conclusion follows from the exclusion above. Do not confuse it with the environment-level peerAuthentication.mtls setting or with ingress.clientCertificateMode, which sit at different layers and solve different problems.
You cannot pin the Dapr version. The only lever is timing, through planned maintenance. Version strings carry an Azure suffix such as -msft.N whose numbers skip values, so watch the update feed and not a version number.
Resiliency exists, in a different place than you would look for it, and still in preview. daprComponents/resiliencyPolicies carries the timeouts, HTTP retries, and circuit breakers, with independent outboundPolicy (sidecar to component) and inboundPolicy (sidecar to your app) settings. The portal exposes only timeout and retry, you restart your Dapr apps after applying one, and this is where the retry policy goes that has to exist before you enable deadLetterTopic.
Then the coverage gaps, which matter when you are choosing NuGet packages: the Dapr server extension, actor, and workflow SDK packages are not compatible with Azure Container Apps. Dapr is not supported for Container Apps jobs either, so a scheduled job runs without a sidecar, and actor reminders require minReplicas of at least 1, which takes scale-to-zero off the table.
Which leaves the question from the top of the article where it started. Nobody has published what the sidecar costs inside a replica, so size it on the AKS figures and then measure your own.
Conclusion
What you buy is a code base with no vendor name in it: no CosmosClient, no ServiceBusClient, no SecretClient, no connection string reaching your application at all. The bill arrives as a second process in every replica, an envelope you did not design, a key prefix you did not write, and a capability matrix to read before every component swap. Most of that price is paid in things you have to know rather than things you have to run, which is the part nobody puts on a slide.
For a service with one queue and one table, that price buys nothing: a process, a component model, and a class of failure that exists only because Dapr is there, in exchange for portability you will never exercise.
It starts paying when you have several services that call each other, share state, and need to prove which one is calling. Service discovery, mTLS, and retry policy stop being things you write and start being things the platform holds, and on Container Apps it is already holding the sidecar.
Part 3 builds it: .NET 10 minimal APIs with service invocation and state management wired end to end, against the Cosmos DB and Service Bus components from this article rather than the Redis defaults dapr init handed you.





Top comments (1)
One operational trap I’d add is retry ownership. Dapr’s guidance says a pub/sub component’s implicit retries are augmented—not replaced—by an explicit Dapr policy. Add an application-side Polly policy and three total attempts at each of three layers can become up to 27 lower-level attempts. I’d make that an adoption test: inject a timeout before broker acceptance, then a lost acknowledgement after acceptance; carry one stable idempotency key; assert one business effect, a bounded attempt budget, and attempt-level traces across app, sidecar, and component. Repeat on consumption by crashing after the database commit but before returning success. Which layer will own the retry budget?