An agent running in a console window is a demo. To make it a product, it has to run somewhere reachable, handle real traffic, keep its secrets safe, and not cost money while it sits idle. Azure Container Apps hits that sweet spot for .NET agents: it runs your container as a serverless service, scales out under load, and scales to zero when nobody’s calling — you pay for what you use.
This guide takes a minimal API-wrapped agent to production. It assumes you already have an agent like the one from our tool-using agent tutorial, exposed over an HTTP endpoint.
Step 1: Wrap the agent in a minimal API
Container Apps expects an HTTP service, so expose your agent with a tiny endpoint:
using Microsoft.Agents.AI;
using Azure.AI.OpenAI;
using Azure.Identity;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var chatClient = new AzureOpenAIClient(
new Uri(Environment.GetEnvironmentVariable("AOAI_ENDPOINT")!),
new DefaultAzureCredential())
.GetChatClient("gpt-4o-mini");
var agent = chatClient.CreateAIAgent(
name: "SupportBot",
instructions: "You help customers with orders and returns.");
app.MapPost("/ask", async (AskRequest req) =>
Results.Ok(new { answer = (await agent.RunAsync(req.Message)).ToString() }));
app.MapGet("/health", () => Results.Ok("healthy"));
app.Run();
record AskRequest(string Message);
Note the endpoint reads its Azure OpenAI endpoint from an environment variable and authenticates with DefaultAzureCredential — no keys in code. That pays off in the next steps.
Step 2: Containerize it
Add a Dockerfile using a multi-stage build so the final image only contains the runtime, not the SDK:
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "AgentDemo.dll"]
Build and test locally to confirm it runs before you push anything to the cloud:
docker build -t agent-demo .
docker run -p 8080:8080 -e AOAI_ENDPOINT=... agent-demo
Two things about that Dockerfile are worth understanding rather than copying. COPY . . before the restore means every source change invalidates the layer cache and re-downloads your NuGet packages, which is tolerable on a laptop and irritating in CI; copying the project files and running dotnet restore first, then copying the rest, is the standard fix. And image size matters more here than it does on a VM, because the platform pulls that image every time it starts a replica from cold. A fat image lengthens every scale-out event and every cold start, which is precisely the moment a user is waiting.
Step 3: Push to Azure Container Registry
Container Apps pulls from a registry. Create one and push your image:
az acr create -g agents-rg -n agentsregistry --sku Basic
az acr login -n agentsregistry
docker tag agent-demo agentsregistry.azurecr.io/agent-demo:v1
docker push agentsregistry.azurecr.io/agent-demo:v1
Step 4: Deploy — and handle secrets the right way
Here’s the part people get wrong: don’t paste your Azure OpenAI key into an environment variable. Because the agent uses DefaultAzureCredential, you can give the Container App a managed identity and grant that identity access to your Azure OpenAI resource. No key ever exists in your config.
az containerapp create \
--name agent-demo \
--resource-group agents-rg \
--environment agents-env \
--image agentsregistry.azurecr.io/agent-demo:v1 \
--target-port 8080 \
--ingress external \
--system-assigned \
--env-vars AOAI_ENDPOINT=https://<your-resource>.openai.azure.com \
--min-replicas 0 \
--max-replicas 5
Then grant the app’s managed identity the Cognitive Services OpenAI User role on your Azure OpenAI resource. Now the container authenticates with its own identity — nothing to leak.
There’s an ordering trap here that will cost you an afternoon if you meet it cold. A system-assigned identity doesn’t exist until the app that owns it exists, so it can’t be the thing that authenticates the very first image pull. You’ll see the deployment fail on an unauthorised registry pull rather than on anything that mentions identity. Create a user-assigned managed identity in advance, grant it AcrPull on the registry and the Azure OpenAI role, and attach that instead — one identity you can reason about, reuse across environments, and grant permissions to before anything tries to use them.
The role assignment itself is also eventually consistent. A container that starts within a few seconds of the grant can get a 403 from Azure OpenAI, retry a minute later, and succeed with no change on your side. Don’t spend an hour re-checking the role name; restart the revision and see whether it clears.
--min-replicas 0 is the money-saver: when no requests arrive, the app scales to zero and costs nothing. The first request after idle incurs a cold start, which is fine for most agent workloads — but be honest about what that cold start contains. It is the image pull, the container start, the .NET runtime and app startup, the first DefaultAzureCredential token acquisition, and then a model call that was never fast to begin with. The runtime’s own contribution is the small part. The image pull and the credential round-trip are the parts you can actually shorten.
Step 5: Scale on the right signal
First, the .NET-specific step almost everyone misses
Before you scale past one replica, configure ASP.NET Core Data Protection. Microsoft’s own guidance is explicit that data protection must be enabled for .NET apps on Container Apps, and the reason is that the default key ring is local to the instance that generated it.
The failure this produces is genuinely nasty because it is intermittent. With one replica everything works. Scale to three and a fraction of requests start failing: antiforgery validation rejects a form the same user just loaded, an authentication cookie encrypted by replica A can’t be decrypted by replica B, and users are logged out apparently at random. The errors mention key ring or unprotect failures rather than anything to do with scaling, so the connection is easy to miss. Restarting the app “fixes” it for exactly as long as it takes to scale out again.
The fix is to persist the key ring somewhere shared — a blob container the app’s managed identity can read and write, with the keys themselves protected by a key in Key Vault. Do it before the first production deployment, not after the first support ticket. If your agent endpoint is a pure stateless API with no cookies, sessions or antiforgery tokens, you may never trip over this; the moment you put a Blazor or MVC front end in front of it, you will.
Know the default behaviour precisely, because it’s what you get if you don’t specify anything. With no scale rule defined, Container Apps applies an HTTP rule with a minimum of zero and a maximum of ten replicas, and the HTTP rule’s concurrentRequests threshold defaults to 10. The platform recalculates concurrency every 15 seconds, adds replicas in steps of 1, then 4, 8, 16 and so on, and — importantly — waits out a five-minute cool-down before it takes the last replica down to zero.
That last number surprises people. Scale-to-zero is not instant when traffic stops; the app sits at one replica for the cool-down window first. It’s the difference between “costs nothing when idle” and “costs nothing five minutes after idle”, which matters if you were expecting a demo environment to be free between sporadic requests.
For an agent endpoint the concurrency threshold deserves a deliberate choice rather than the default. An agent request spends nearly all of its wall-clock time waiting on the model, using almost no CPU, so a replica can hold far more than ten in-flight requests without breaking a sweat. Scaling out at ten therefore adds replicas to relieve pressure that isn’t real, and each new replica competes for the same Azure OpenAI tokens-per-minute quota — which turns a latency problem into a wall of 429s spread across more instances. Raise the threshold until replicas are genuinely saturated, and set --max-replicas to a ceiling your quota can actually feed. Scaling past your token limit relocates the bottleneck; it doesn’t remove it. Resilient LLM calls with Polly covers handling the 429s you’ll still get at the edges.
If your agent genuinely does long-running work, the shape to reach for is a queue rather than a bigger concurrency number: accept the request, enqueue it, return an identifier, and scale workers on queue length through KEDA. That also sidesteps the timeout described below.
The timeout that will cut your longest requests
The environment’s ingress proxy enforces an idle request timeout, and in the default ingress mode that value is four minutes and not configurable. Premium ingress makes it adjustable between four and thirty minutes, along with a termination grace period for draining in-flight work during shutdown.
Four minutes is generous for a single model call and tight for an agent that chains several tool calls, retries a rate-limited request, or works through a long document. When you exceed it the client sees the connection dropped rather than a clean error from your app, and your own logs show the request as still running — which makes it look like a client bug.
The word doing the work in “idle request timeout” is idle. If your endpoint streams tokens back as they arrive, the connection is never idle for long and the timeout stops being a practical concern, which is one more reason streaming responses is the first optimisation worth making rather than the last. If you can’t stream — a batch job, a long research task — don’t fight the proxy. Move to the queue-and-poll shape, where the HTTP request returns in milliseconds and the work happens somewhere that has no ingress timeout at all.
Step 6: See what it’s doing
Enable logging so you can debug in production:
az containerapp logs show -n agent-demo -g agents-rg --follow
For real observability, wire up OpenTelemetry in the app and point it at Azure Monitor. Agent traces — which tools were called, how long each model round-trip took — are invaluable when an agent misbehaves, and far more useful than raw logs.
One operational detail that shapes how you debug: most meaningful changes create a new revision, an immutable snapshot of the app. Editing an environment variable, changing a scale rule or deploying a new image all produce one. In single-revision mode the new one replaces the old; in multiple-revision mode both stay alive and you split traffic between them, which is how you do a canary or a blue-green cutover without a second environment. The trap is diagnosing a problem against the wrong revision — logs and metrics are per-revision, and “I fixed that” is easy to believe when you’re reading output from the replica that still has the old image. Check which revision is actually taking traffic before you conclude anything.
When Container Apps isn’t the right host
It’s the default answer for a .NET agent on Azure, but not the universal one.
Reach for something else when you need control the platform deliberately doesn’t expose: a service mesh with your own mTLS and routing policy, custom scheduling, DaemonSets, or node-level configuration. That’s AKS, and if your organisation already runs one, the agent probably belongs beside everything else rather than in a platform nobody else operates. The same argument applies in reverse on Google Cloud — see running a .NET agent on GKE.
Reach elsewhere too if the workload is genuinely bursty and short-lived rather than a service, where Azure Functions fits better; or if it’s scheduled or event-driven batch work, which Container Apps jobs handle more naturally than a web app pretending to be one. And if you’re serving a model yourself rather than calling a hosted one, the GPU requirement changes the conversation entirely — that’s a different sizing exercise, and a much more expensive one.
Note: Azure CLI flags and role names occasionally change. Verify commands against the current Azure Container Apps docs for your CLI version; the architecture — containerize, push, deploy with managed identity, scale to zero — is what matters and is stable.
Takeaway
Azure Container Apps gives a .NET agent a production home without you managing servers: a container, a managed identity instead of secrets, scale-to-zero economics, and KEDA-based scaling when you need it. Containerize once, and the same image runs identically on your laptop and in the cloud — which is exactly the property you want when an agent starts handling real users.
Next: serverless AI with Azure Functions for workloads that are bursty rather than continuous.
Have a correction or a topic you want covered? Email mani.bc72@gmail.com.
