Dapr-enabled application pods protected by identity and policy while scaling across AKS nodes

A Dapr-enabled application can work perfectly with one replica and still be unprepared for production. The sidecar may have no resource requests, every application may be able to load every component, a rollout may require capacity the cluster does not have, or an autoscaler may react to application CPU while ignoring a throttled daprd container.

Production readiness is therefore not one switch. It is a chain of explicit decisions about identity, authorization, isolation, capacity, rollout behaviour, scaling signals, and control-plane availability.

Outcome: Apply a secure Dapr application baseline, verify mTLS and least-privilege invocation, calculate sidecar overhead, tune a safe rolling update, scale on application CPU or Azure Service Bus backlog, and understand which protections belong to Dapr, Kubernetes, and AKS.

This is Part 9 of the Dapr on Kubernetes and AKS series. It builds on the telemetry from Part 8: Observability for Dapr on AKS, because resource limits and scaling policies should be based on measurements rather than copied defaults.

Four security boundaries, not one

The security and scaling controls around a Dapr-enabled pod

Different controls protect different boundaries:

BoundaryPrimary controls
Sidecar to sidecarDapr mTLS identity and service-invocation access policies
Application to its local sidecarLoopback binding, Dapr API allowlist, optional API token
Application to Azure servicesMicrosoft Entra Workload ID and Azure RBAC
Pod to pod or external endpointKubernetes NetworkPolicy, private networking, egress controls

These controls complement rather than replace one another. Dapr mTLS authenticates Dapr workloads; it does not decide whether a compromised pod may connect directly to an unrelated database. NetworkPolicy restricts network paths; it cannot distinguish the application container from daprd inside the same pod because they share one network namespace.

Step 1: Check the platform baseline

Start with the installed versions and control-plane health:

kubectl version
dapr version
dapr status --kubernetes

kubectl get nodes \
  --label-columns topology.kubernetes.io/zone

Dapr follows the Kubernetes version-skew policy, so confirm that the installed Dapr release supports the Kubernetes version used by AKS. For a production AKS cluster, also confirm that node pools, zones, network policy, workload identity, monitoring, upgrade channels, and maintenance windows match the platform design. These cluster-level decisions cannot be repaired solely in an application manifest.

Check that mTLS is enabled and inspect certificate expiry:

dapr mtls --kubernetes
dapr mtls expiry

The top-level dapr mtls command needs --kubernetes (or -k) to select the cluster. The expiry subcommand is Kubernetes-specific and does not accept that flag.

On Kubernetes, Dapr enables mTLS by default. Sentry issues short-lived workload certificates whose SPIFFE identity contains the trust domain, namespace, and app ID. Do not disable mTLS merely because traffic remains inside the cluster.

Treat root and issuer certificate rotation as an operated procedure. Test it in a non-production cluster, monitor certificate expiry, preserve the private root key when your rotation design requires trust continuity, and follow the procedure for the installation method you use. An AKS extension-managed control plane should be managed through its supported extension lifecycle rather than modified as though it were a Helm installation.

Step 2: Apply the application baseline

Download the complete production baseline manifest, save it as production-baseline.yaml, and apply it:

kubectl apply \
  --filename production-baseline.yaml

kubectl rollout status \
  deployment/orders-api \
  --namespace dapr-production

kubectl rollout status \
  deployment/frontend-api \
  --namespace dapr-production

The sample creates three orders-api replicas and one frontend-api caller. It deliberately combines several controls that are often left until later:

  • a dedicated namespace and service accounts;
  • disabled automatic service-account token mounting;
  • a Dapr access policy with a deny-by-default rule;
  • an API allowlist on the protected sidecar;
  • explicit application and daprd resource requests and limits;
  • a PodDisruptionBudget and topology spreading;
  • a rollout strategy that keeps all existing replicas available; and
  • an HPA that measures only the application container’s CPU.

Verify injection and resources:

kubectl get pods \
  --namespace dapr-production \
  --output custom-columns='POD:.metadata.name,CONTAINERS:.spec.containers[*].name'

kubectl get deployment orders-api \
  --namespace dapr-production \
  --output jsonpath='{range .spec.template.spec.containers[*]}{.name}{" request="}{.resources.requests}{" limit="}{.resources.limits}{"\n"}{end}'

The second command shows both the application resources written in the manifest and the injected sidecar resources created from annotations.

Step 3: Make authorization explicit

The protected sidecar loads this Dapr Configuration:

apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
  name: orders-security
  namespace: dapr-production
spec:
  accessControl:
    defaultAction: deny
    trustDomain: public
    policies:
      - appId: frontend-api
        namespace: dapr-production
        trustDomain: public
        defaultAction: deny
        operations:
          - name: /orders
            httpVerb: [POST]
            action: allow
  api:
    allowed:
      - name: invoke
        version: v1.0
        protocol: http
      - name: metadata
        version: v1.0
        protocol: http

The access policy is evaluated by the called application’s sidecar. Only frontend-api in the expected namespace and trust domain may invoke POST /orders. Dapr derives the caller identity from its mTLS certificate; trusting a user-supplied app ID header would not provide the same boundary.

The API allowlist solves a different problem. It limits which Dapr building-block APIs the orders application can call on its own sidecar. Without an allowlist, all Dapr APIs are enabled. Add only the APIs the workload needs.

Test the permitted path by forwarding the caller’s Dapr port:

kubectl port-forward \
  deployment/frontend-api \
  3500:3500 \
  --namespace dapr-production

In another terminal:

curl --fail-with-body --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"orderId":"prod-1001","sku":"coffee-001","quantity":1}' \
  http://localhost:3500/v1.0/invoke/orders-api/method/orders

Expected response:

{"accepted": true, "servedBy": "orders-api-..."}

An ACL is only one layer. Apply component scopes so a state store, pub/sub broker, binding, or secret store is loaded only by approved app IDs:

metadata:
  name: orders-pubsub
  namespace: dapr-production
spec:
  type: pubsub.azure.servicebus.topics
  version: v1
  metadata:
    # Component-specific metadata belongs here.
scopes:
  - orders-api

For pub/sub, also protect sensitive topics with protectedTopics, publishingScopes, and subscriptionScopes. Namespace isolation alone does not express which app may publish which business event.

Step 4: Use namespaces as tenancy boundaries

Dapr applications load components from their Kubernetes namespace. This makes namespaces useful for separating environments or teams, but a namespace is not a complete tenant boundary by itself.

A production namespace should normally have:

  • Kubernetes RBAC limiting who may change Deployments, Dapr Component, Configuration, Resiliency, and Subscription resources;
  • ResourceQuota and LimitRange policies;
  • default-deny NetworkPolicy with explicitly tested ingress and egress paths;
  • admission policies for images, security context, resources, and approved registries; and
  • separate workload identities and Azure role assignments.

Avoid sharing one broadly authorized component across unrelated teams simply because Dapr makes the component easy to reference. If two namespaces use the same Azure Service Bus namespace or database, deploy a component resource into each Kubernetes namespace and give each workload identity only the permissions it requires.

Example: create a bounded team namespace

This example uses the Azure Service Bus Topics component, so you need a Service Bus namespace before completing the component YAML. You can reuse an existing namespace or create a dedicated one. The namespace name must be globally unique, and it must use the Standard or Premium tier because the Basic tier does not support topics.

To find an existing Service Bus namespace in the active subscription:

az servicebus namespace list \
  --query '[].{Name:name,ResourceGroup:resourceGroup,Location:location,Sku:sku.name}' \
  --output table

If you want to create the resources for this walkthrough, set the values first. Choose your own globally unique namespace name:

AZURE_LOCATION="westeurope"
SERVICE_BUS_RESOURCE_GROUP="rg-dapr-production"
SERVICE_BUS_NAMESPACE="<globally-unique-service-bus-name>"
SERVICE_BUS_TOPIC="orders"
SERVICE_BUS_SUBSCRIPTION="order-worker"

Create the resource group, Standard Service Bus namespace, topic, and subscription:

az group create \
  --name "$SERVICE_BUS_RESOURCE_GROUP" \
  --location "$AZURE_LOCATION" \
  --output none

az servicebus namespace create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$SERVICE_BUS_NAMESPACE" \
  --location "$AZURE_LOCATION" \
  --sku Standard \
  --output none

az servicebus topic create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --namespace-name "$SERVICE_BUS_NAMESPACE" \
  --name "$SERVICE_BUS_TOPIC" \
  --output none

az servicebus topic subscription create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --namespace-name "$SERVICE_BUS_NAMESPACE" \
  --topic-name "$SERVICE_BUS_TOPIC" \
  --name "$SERVICE_BUS_SUBSCRIPTION" \
  --output none

Verify the result and print the hostname required by Dapr:

az servicebus namespace show \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$SERVICE_BUS_NAMESPACE" \
  --query '{name:name,status:status,sku:sku.name}' \
  --output table

SERVICE_BUS_FQDN="${SERVICE_BUS_NAMESPACE}.servicebus.windows.net"
printf '%s\n' "$SERVICE_BUS_FQDN"

Creating these Azure resources does not authorize the Dapr application. Step 5 creates the managed identity, federated credential, and Azure role assignments.

Save the following as team-orders-namespace.yaml:

apiVersion: v1
kind: Namespace
metadata:
  name: team-orders
  labels:
    owner: team-orders
    environment: production
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: team-orders
  namespace: team-orders
spec:
  hard:
    requests.cpu: "8"
    requests.memory: 16Gi
    limits.cpu: "16"
    limits.memory: 32Gi
    pods: "50"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: container-defaults
  namespace: team-orders
spec:
  limits:
    - type: Container
      defaultRequest:
        cpu: 50m
        memory: 64Mi
      default:
        cpu: 500m
        memory: 512Mi
---
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orders-pubsub
  namespace: team-orders
spec:
  type: pubsub.azure.servicebus.topics
  version: v1
  metadata:
    - name: namespaceName
      value: <service-bus-namespace>.servicebus.windows.net
    - name: azureAuthMethods
      value: workloadidentity
scopes:
  - orders-api

Apply and inspect it:

kubectl apply \
  --filename team-orders-namespace.yaml

kubectl get resourcequota,limitrange,components.dapr.io \
  --namespace team-orders

Replace <service-bus-namespace> with the short namespace name. For example, if SERVICE_BUS_NAMESPACE is wolkwacht-messaging, the YAML value is wolkwacht-messaging.servicebus.windows.net. A normal YAML file does not expand ${SERVICE_BUS_NAMESPACE} automatically.

The namespace limits total consumption. The LimitRange supplies safety defaults, but explicit application and sidecar requests are still preferable. The component exists only in team-orders, and scopes prevents other app IDs in that namespace from loading it.

At this point there is no Workload Identity yet. That is harmless if no Dapr-enabled orders-api pod is running in team-orders: the component is stored but no sidecar loads it. If such a pod already exists, omit the Component document for now and apply it after completing Step 5; otherwise its initialization will fail until identity and RBAC propagation finish.

Namespace creation alone does not grant a team access. Bind an Entra-backed Kubernetes group to a namespace Role containing only the resources that team may operate. Keep changes to Dapr security Configuration, admission policy, quota, and production identities under platform-owner control when those resources form a security boundary.

Step 5: Replace credentials with AKS Workload ID

AKS Workload ID lets a Kubernetes service account exchange its projected token for a Microsoft Entra token. A Dapr Azure component can then authenticate without a connection string stored in YAML or a Kubernetes Secret.

The complete chain is:

  1. AKS has the OIDC issuer and workload identity enabled.
  2. A user-assigned managed identity receives a narrow Azure RBAC role on the target resource.
  3. A federated credential binds that identity to one Kubernetes service-account subject.
  4. The service account has the identity client ID annotation.
  5. The pod template carries azure.workload.identity/use: "true" and uses that service account.
  6. The Dapr component selects Microsoft Entra authentication using the metadata supported by that component.

Do not assume that configuring the identity on the application container automatically configures every component or KEDA scaler. Verify the authentication contract in the exact Dapr component and KEDA scaler version you deploy. The Dapr sidecar and application share the pod, but Azure authorization still depends on the identity and role assignments used by the SDK or component.

Where neither the application nor sidecar needs Kubernetes API credentials, keep:

spec:
  automountServiceAccountToken: false

The sample also sets dapr.io/disable-builtin-k8s-secret-store: "true". Remove that annotation and restore an appropriately scoped token only when the workload actually uses Dapr’s Kubernetes secret-store API. Component secretKeyRef values can be resolved by the Dapr operator during injection without giving every workload a broadly readable service-account token.

Example: give orders-api access to Service Bus

This example continues with the Service Bus namespace selected or created in Step 4. Start by setting the remaining non-secret values. Use the resource group that contains the managed identity; the Service Bus namespace may be in the same or a different resource group.

AKS_RESOURCE_GROUP="<aks-resource-group>"
AKS_CLUSTER_NAME="<aks-cluster-name>"
IDENTITY_RESOURCE_GROUP="<identity-resource-group>"
IDENTITY_NAME="dapr-orders-api"
SERVICE_BUS_RESOURCE_GROUP="<service-bus-resource-group>"
SERVICE_BUS_NAMESPACE="<service-bus-namespace>"
SERVICE_ACCOUNT_NAMESPACE="team-orders"
SERVICE_ACCOUNT_NAME="orders-api"

Confirm Workload ID and obtain the OIDC issuer:

az aks show \
  --resource-group "$AKS_RESOURCE_GROUP" \
  --name "$AKS_CLUSTER_NAME" \
  --query '{workloadIdentity:securityProfile.workloadIdentity.enabled,oidc:oidcIssuerProfile}' \
  --output yaml

AKS_OIDC_ISSUER="$(
  az aks show \
    --resource-group "$AKS_RESOURCE_GROUP" \
    --name "$AKS_CLUSTER_NAME" \
    --query oidcIssuerProfile.issuerUrl \
    --output tsv
)"

if [[ -z "$AKS_OIDC_ISSUER" ]]; then
  echo 'AKS OIDC issuer is empty; enable OIDC and Workload ID first.' >&2
  exit 1
fi

For AKS Standard, enable missing features with az aks update --enable-oidc-issuer --enable-workload-identity during an approved change window. AKS Automatic normally configures them by default. Verify current platform requirements before changing an existing cluster.

Create the user-assigned managed identity and capture both IDs:

az identity create \
  --resource-group "$IDENTITY_RESOURCE_GROUP" \
  --name "$IDENTITY_NAME" \
  --output none

IDENTITY_CLIENT_ID="$(
  az identity show \
    --resource-group "$IDENTITY_RESOURCE_GROUP" \
    --name "$IDENTITY_NAME" \
    --query clientId \
    --output tsv
)"

IDENTITY_PRINCIPAL_ID="$(
  az identity show \
    --resource-group "$IDENTITY_RESOURCE_GROUP" \
    --name "$IDENTITY_NAME" \
    --query principalId \
    --output tsv
)"

Create the trust relationship. The subject must exactly match the namespace and service-account name used by the pod:

az identity federated-credential create \
  --resource-group "$IDENTITY_RESOURCE_GROUP" \
  --identity-name "$IDENTITY_NAME" \
  --name orders-api \
  --issuer "$AKS_OIDC_ISSUER" \
  --subject "system:serviceaccount:${SERVICE_ACCOUNT_NAMESPACE}:${SERVICE_ACCOUNT_NAME}" \
  --audience api://AzureADTokenExchange \
  --output none

Grant only the required Service Bus data-plane role. This example gives the component both publish and receive permissions because the application does both; use only one role when the workload only sends or only receives.

SERVICE_BUS_RESOURCE_ID="$(
  az servicebus namespace show \
    --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
    --name "$SERVICE_BUS_NAMESPACE" \
    --query id \
    --output tsv
)"

az role assignment create \
  --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Azure Service Bus Data Sender" \
  --scope "$SERVICE_BUS_RESOURCE_ID" \
  --output none

az role assignment create \
  --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Azure Service Bus Data Receiver" \
  --scope "$SERVICE_BUS_RESOURCE_ID" \
  --output none

Create the service account and component in team-orders. Save this as orders-workload-identity.yaml, replace both placeholders, and apply it:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: orders-api
  namespace: team-orders
  annotations:
    azure.workload.identity/client-id: <managed-identity-client-id>
---
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orders-pubsub
  namespace: team-orders
spec:
  type: pubsub.azure.servicebus.topics
  version: v1
  metadata:
    - name: namespaceName
      value: <service-bus-namespace>.servicebus.windows.net
    - name: azureAuthMethods
      value: workloadidentity
scopes:
  - orders-api

The Deployment must select the service account and carry the mandatory pod label:

spec:
  template:
    metadata:
      labels:
        app: orders-api
        azure.workload.identity/use: "true"
      annotations:
        dapr.io/enabled: "true"
        dapr.io/app-id: orders-api
        dapr.io/app-port: "8080"
    spec:
      serviceAccountName: orders-api

Restart the Deployment after changing the service account, pod label, or component authentication, and wait for Entra role assignments and the federated credential to propagate:

kubectl rollout restart deployment/orders-api \
  --namespace team-orders

kubectl rollout status deployment/orders-api \
  --namespace team-orders

kubectl logs deployment/orders-api \
  --container daprd \
  --namespace team-orders \
  --since 10m

Look for successful initialization of orders-pubsub. An authentication failure usually means one of five values does not line up: OIDC issuer, federated subject, service-account annotation, pod label, or Azure role assignment.

Step 6: Understand what NetworkPolicy can protect

NetworkPolicy applies to a pod, not an individual container. The application and its sidecar share the same IP address and communicate over loopback, so a policy cannot allow app → daprd while denying another container in that same pod.

NetworkPolicy can still:

  • allow sidecar-to-sidecar traffic only from selected namespaces or pods;
  • restrict direct traffic to the application pod;
  • permit DNS and required Dapr control-plane endpoints;
  • allow only approved Azure private endpoints or egress gateways; and
  • deny unexpected lateral movement.

Start with an observed traffic map from Part 8, then introduce policies in audit or a test namespace. Dapr features use several paths: service invocation, Sentry, Operator, Placement for actors, Scheduler for workflows/jobs/reminders, name resolution, metrics, and external components. A copied policy that allows only port 3500 will break the runtime because 3500 is normally the local HTTP API, not the complete inter-pod/control-plane path.

On AKS, prefer Azure CNI powered by Cilium for a new production design unless another supported data plane is required. Use private endpoints and controlled egress for Azure dependencies. NetworkPolicy is not an egress firewall by itself; combine it with the AKS network architecture and, where required, Azure Firewall or another approved egress control.

Example: default deny, then open deliberate paths

Apply policies in stages and keep a second terminal ready for diagnostics. The following baseline assumes pods carry app labels and Dapr is installed in dapr-system.

First deny ingress and egress for all pods in team-orders:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny
  namespace: team-orders
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress

Allow DNS and outbound HTTPS. Port 443 permits token exchange and Azure public/private endpoints, but it is still broad; production egress should be constrained with private endpoints, an egress gateway, Azure Firewall, or another control capable of enforcing the required destinations.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-and-https
  namespace: team-orders
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53
    - ports:
        - protocol: TCP
          port: 443

Allow only frontend-api to reach the orders Dapr sidecar’s internal gRPC port. The application itself remains reachable from its own sidecar over loopback without a NetworkPolicy rule.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: orders-dapr-invocation
  namespace: team-orders
spec:
  podSelector:
    matchLabels:
      app: orders-api
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: frontend-api
      ports:
        - protocol: TCP
          port: 50001

Allow Dapr-enabled pods to reach the Dapr control-plane namespace. This deliberately allows all ports to selected control-plane pods because the exact services required depend on whether the workload uses actors, workflows, jobs, reminders, or other runtime features. Narrow it only after observing the installed version and enabled features.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dapr-control-plane
  namespace: team-orders
spec:
  podSelector:
    matchExpressions:
      - key: dapr.io/enabled
        operator: In
        values: ["true"]
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: dapr-system

The injector annotation is not automatically copied into pod labels, so ensure the pod template also includes the label dapr.io/enabled: "true" if you use that selector. Alternatively, select the workload’s stable app.kubernetes.io/part-of label.

After applying each policy, verify Dapr component initialization and invocation before continuing:

kubectl get pods \
  --namespace team-orders

kubectl logs deployment/orders-api \
  --container daprd \
  --namespace team-orders \
  --since 5m

kubectl get networkpolicy \
  --namespace team-orders

If Service Bus uses a private endpoint, confirm DNS resolves the namespace to the intended private IP from the pod and that the egress design permits that subnet. Standard Kubernetes NetworkPolicy selects IPs and pods, not DNS names.

Step 7: Budget for the complete pod

The Kubernetes scheduler places a pod based on the resource requests of all its containers. For a Dapr-enabled replica:

pod request = application request + daprd request + any other sidecars/init overhead

In the sample, each orders replica requests:

CPU:    150m application + 100m daprd = 250m
Memory: 128Mi application + 250Mi daprd = 378Mi

Three steady-state replicas therefore request at least 750m CPU and 1134Mi memory before other pod overhead. A rollout with maxSurge: 1 temporarily needs one additional complete pod: another 250m CPU and 378Mi memory.

The sidecar annotations are:

dapr.io/sidecar-cpu-request: "100m"
dapr.io/sidecar-cpu-limit: "300m"
dapr.io/sidecar-memory-request: "250Mi"
dapr.io/sidecar-memory-limit: "1Gi"
dapr.io/env: "GOMEMLIMIT=900MiB"

These are starting values from current Dapr production guidance, not universal answers. Baseline daprd CPU, working-set memory, request rate, component activity, payload sizes, actors, retries, and trace sampling under realistic load. Then tune each workload class.

CPU limits can cause throttling during bursts even while node CPU appears available. A memory limit that is too low causes OOMKilled; no request at all lets the scheduler overpack the node. GOMEMLIMIT gives the Go runtime a soft memory target below the Kubernetes hard limit, leaving headroom for non-heap memory.

Inspect the actual results:

kubectl top pods \
  --containers \
  --namespace dapr-production

kubectl get pods \
  --namespace dapr-production \
  --output custom-columns='POD:.metadata.name,DAPRD_RESTARTS:.status.containerStatuses[?(@.name=="daprd")].restartCount,DAPRD_REASON:.status.containerStatuses[?(@.name=="daprd")].lastState.terminated.reason'

For CPU throttling, use the container CPU throttling metrics from your Prometheus backend. For memory, compare working set and RSS with the request, hard limit, restarts, and OOMKilled events. Averages hide short saturation periods; examine percentiles and rollout windows.

Step 8: Make rollouts fit the cluster

The sample uses:

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 0

This protects availability but requires capacity for a fourth app-plus-sidecar pod. If the fourth pod cannot be scheduled, the rollout waits. That may be safer than taking a replica down, but it is not progress.

Before a production rollout, calculate:

surge request = maxSurge pods × complete pod requests

Then verify node allocatable capacity, namespace quota, topology constraints, and cluster-autoscaler maximums. The cluster autoscaler reacts to unschedulable pods; it does not create capacity instantaneously. Leave headroom or pre-scale when startup latency matters.

The PodDisruptionBudget protects against voluntary disruptions such as node drain, not node failure. With three replicas and minAvailable: 2, one healthy replica may be voluntarily evicted. Avoid a budget that blocks every eviction unless you also accept that node maintenance can stall.

Topology spread distributes replicas across hosts and, when the AKS node pool spans zones, across zones. A three-replica Deployment cannot provide zone resilience if the node pool exists in only one zone.

Native sidecars are lifecycle semantics, not free capacity

Dapr can inject daprd as a Kubernetes native sidecar on supported Kubernetes versions:

dapr.io/enable-native-sidecar: "true"

Native sidecars improve startup and shutdown ordering and are particularly helpful for Jobs. They do not remove the sidecar’s CPU or memory overhead. Validate support in the installed Dapr version and AKS Kubernetes version, then test probes, termination, Jobs, admission policies, monitoring, and rollout behaviour before enabling the mode broadly.

Step 9: Choose one pod-scaling owner

HPA for synchronous services

The baseline uses an autoscaling/v2 HPA with a ContainerResource metric:

metrics:
  - type: ContainerResource
    containerResource:
      name: cpu
      container: orders-api
      target:
        type: Utilization
        averageUtilization: 65

This targets the application container instead of averaging application and sidecar CPU together. That is useful when application CPU is the saturation signal. It can be the wrong choice when the sidecar or downstream dependency is the bottleneck. Consider request rate, latency, queue depth, concurrency, and dependency quotas as alternative or additional signals.

Check the HPA after generating traffic:

kubectl get hpa \
  --namespace dapr-production

kubectl describe hpa orders-api \
  --namespace dapr-production

KEDA for asynchronous work

For an Azure Service Bus consumer, queue or subscription backlog is usually more meaningful than CPU. Download the KEDA Service Bus template. It uses a TriggerAuthentication with Azure Workload ID and a ScaledObject that scales an order-worker Deployment.

The template is intentionally not included in production-baseline.yaml: it requires KEDA, an existing worker Deployment, a Service Bus entity, a federated identity, and Azure RBAC. Replace its placeholders and validate them before applying it.

KEDA does not create the queue, topic, or subscription. The scaler identity must be able to read runtime properties, while the worker identity needs only the data-plane operations required to consume messages. Those can be separate identities.

Do not attach an independently managed HPA and a KEDA ScaledObject to the same Deployment. A ScaledObject creates and manages an HPA; competing controllers produce unstable desired replica counts.

Step 10: Coordinate pod and node scaling

Pod autoscaling and node autoscaling solve different problems:

  1. HPA or KEDA increases the desired pod replicas.
  2. The scheduler places those complete app-plus-sidecar pods.
  3. If requests do not fit, pods remain Pending.
  4. AKS cluster autoscaler or Node Auto Provisioning adds node capacity.

If sidecar requests are missing, the scheduler underestimates future usage and the node autoscaler receives a misleading signal. If requests are inflated, nodes scale earlier and cost more. Accurate requests are therefore part of autoscaling correctness, not only cost optimization.

Set minReplicas from availability and cold-start requirements. Set maxReplicas from downstream capacity, Service Bus throughput, database limits, subnet IP capacity, and budget—not from optimism. A retry storm multiplied by an aggressive autoscaler can make an incident worse.

Step 11: Make the Dapr control plane highly available

Application replicas do not help when the supporting control plane is under-sized or concentrated on one node. Production Dapr guidance recommends HA mode, which creates three replicas of applicable control-plane services.

For a new CLI-managed installation:

dapr init \
  --kubernetes \
  --enable-ha \
  --wait

For Helm, use the matching HA chart values. For the AKS Dapr extension, configure HA through the supported extension settings for the installed version. Do not run dapr init or Helm on top of an extension-managed installation.

Verify replicas and placement:

kubectl get deployments,statefulsets \
  --namespace dapr-system \
  --output wide

kubectl get pods \
  --namespace dapr-system \
  --output custom-columns='POD:.metadata.name,NODE:.spec.nodeName,READY:.status.containerStatuses[*].ready'

HA replicas still need topology spreading, disruption budgets, sufficient system-node capacity, monitoring, and tested upgrades. If you use actors, give Placement special attention; if you use workflows, jobs, or actor reminders, include Scheduler in the availability review.

Production review checklist

Before approving a Dapr workload for production, verify:

  • mTLS is enabled and certificate expiry is monitored;
  • invocation ACLs default to deny where the boundary requires it;
  • Dapr API, component, topic, and secret access are scoped;
  • Azure components use Workload ID and narrow Azure RBAC roles;
  • namespaces have Kubernetes RBAC, quotas, admission policy, and tested network policy;
  • every application and daprd container has measured resource requests;
  • sidecar CPU throttling and OOMKilled events are monitored;
  • rollout surge capacity includes the complete pod;
  • probes, disruption budgets, and topology spread match the availability objective;
  • one controller owns pod scaling, with bounded minimum and maximum replicas;
  • AKS node scaling can satisfy the complete pod request; and
  • the Dapr control plane is highly available and distributed across failure domains.

Cleanup

Stop the port-forward with Ctrl+C, then remove the sample:

kubectl delete namespace dapr-production
kubectl delete namespace team-orders

If you created a dedicated Service Bus namespace only for this walkthrough, remove that namespace after confirming the variables still identify the intended resource:

printf 'Delete Service Bus namespace %s from resource group %s\n' \
  "$SERVICE_BUS_NAMESPACE" \
  "$SERVICE_BUS_RESOURCE_GROUP"

az servicebus namespace delete \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$SERVICE_BUS_NAMESPACE"

Deleting the namespace also deletes its topics and subscriptions. This cleanup does not remove KEDA, Dapr, the managed identity, its federated credential, the resource group, or cluster-level monitoring. Delete shared infrastructure only when you have confirmed that no other workload uses it.

Takeaway

The Dapr sidecar is part of every replica’s security and capacity envelope. Protect it with identity and least privilege, measure it as an independent container, and include it in scheduling, rollout, and autoscaling calculations. Production safety comes from composing Dapr policy, Kubernetes controls, and AKS identity and infrastructure—not from expecting one layer to understand all three.

The final post will turn this baseline into an operating model: upgrades, GitOps promotion, disaster recovery, multi-cluster design, cost, and the lessons that matter after deployment day.

Sources and further reading