If you have been anywhere near the .NET ecosystem in 2026, you have heard the Aspire pitch a hundred times. "Cloud-native orchestration." "Production-ready by default." "Service discovery baked in." Honestly, half of that is true and half of it quietly breaks the moment your traffic stops looking like a demo.
I have been running Aspire in production for eight months. Three services on Azure Container Apps, a Postgres, a Redis cache, and a background worker. Average monthly bill: $382. Peak month: $640. I have burned through two Aspire versions, hit three scaling issues nobody documented, and discovered one Aspire feature that cost more in debugging time than it ever saved.
This is what I wish someone had told me in January.
The Aspire pitch, and what it actually solves
Aspire is, at its core, three things glued together:
- An AppHost project that declares your distributed application as C# code.
- A service discovery layer that uses environment variables in dev and DNS in production.
- A telemetry stack that wires OpenTelemetry into every service with one line of code.
If you are coming from Kubernetes, the AppHost is basically a typed Helm chart you can refactor. If you are coming from docker-compose, it is the same thing but with C# and a real type system.
The killer feature, in my opinion, is none of those. It is the fact that resource lifecycle (start Postgres, wait for healthy, then start the API) is just C# code. No more race conditions where your API restarts because the database was not ready. No more depends_on: condition: service_healthy guesswork in compose files. The orchestrator knows the dependency graph because you wrote it as a graph.
Here is a stripped-down AppHost from one of my services:
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache")
.WithLifetime(ContainerLifetime.Persistent);
var db = builder.AddPostgres("postgres")
.WithDataVolume("pgdata")
.AddDatabase("appdb");
var api = builder.AddProject<Projects.Api>("api")
.WithReference(cache)
.WithReference(db)
.WithExternalHttpEndpoints();
builder.Build().Run();
That is the whole deployment manifest. Postgres has a persistent volume, Redis is persistent, and the API project gets connection strings injected at startup via WithReference. In dev, you dotnet run and Aspire spins up a local dashboard at localhost:15000 showing logs, traces, and resource health.
It is genuinely nice. The dashboard alone would have saved my team probably two weeks of debugging in 2024.
Service discovery: how it actually works under the hood
Here is the part nobody explains well. When you call WithReference(cache) on a project, Aspire injects a connection string into the consuming service's environment. In dev mode, the string points at localhost:6379. In production, it points at the FQDN of the backing service in Azure Container Apps.
The mechanism is deceptively simple. Aspire writes a JSON manifest at build time containing every resource's name, endpoint, and connection string format. At runtime, the Aspire orchestrator reads the manifest, starts resources in dependency order, and rewrites environment variables on the consuming processes. Your application code never changes between environments.
The gotcha: TLS. Aspire injects plain localhost:6379 for Redis in dev, but in production it needs rediss:// and a TLS handshake. Your AddRedis call needs to handle both. I have seen teams ship code that works locally and refuses to start in ACA because they hardcoded the dev port. Do not do that. Read the connection string from config, not from constants. Treat dev and prod as the same code path.
The other gotcha: timeouts. Service discovery in ACA relies on DNS resolution at startup. If your DNS provider is slow (Azure's default is fine, but some custom setups add 800ms+), your cold start goes from 3 seconds to 6. We did not notice this until we enabled ACA's scale-to-zero, at which point every cold request was visibly slow. Cache the resolved DNS for the process lifetime.
Telemetry: the actually useful part
The OpenTelemetry integration is, in my view, the most underrated feature in the entire Aspire stack. You add a single block to the AppHost, and every project that references Aspire components gets distributed tracing wired up automatically. The default exporter is OTLP to the Aspire dashboard in dev and to whatever endpoint you configure in production.
We send our prod traces to Honeycomb. Total cost: $0 because we are under 50GB per month. The setup is literally one block in the AppHost:
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddOtlpExporter(o =>
o.Endpoint = new Uri("https://api.honeycomb.io")))
.WithMetrics(m => m.AddOtlpExporter(o =>
o.Endpoint = new Uri("https://api.honeycomb.io")));
What you get for those few lines: every HTTP request, every Postgres query, every Redis call, automatically traced and correlated. No more "where did the latency come from" detective work. The first time you open the trace view in Honeycomb and see the SQL query that took 2.3 seconds inside a request that returned in 2.4 seconds, you will not go back.
The catch: Aspire's auto-instrumentation only covers Aspire-managed components and the standard ASP.NET Core pipeline. If you write your own HTTP client without going through IHttpClientFactory, you get nothing. The instrumentation is magical but not omniscient. I learned this the hard way when a custom HttpClient we hand-rolled for a third-party API showed up as an opaque 1.8 second gap in the trace waterfall. Refactored to AddHttpClient<TInterface>() and the spans appeared immediately.
Deploying to Azure Container Apps: the real numbers
Aspire 9 ships with azd integration. You run azd up, and it provisions the resource group, builds the containers, pushes to ACR, deploys to ACA, and configures the service discovery wiring. On a fresh project, the first deploy took me about 14 minutes. Subsequent deploys: 3-4 minutes end to end.
The bill for our setup:
- API service: 2 vCPU, 4 GB, 1 replica baseline, autoscale to 5
- Worker service: 0.5 vCPU, 1 GB, 1 replica
- Postgres: Flexible Server, Burstable B1ms
- Redis: Basic C0
- Container Apps environment plus log analytics
Average monthly: $382. Peak month (Q4 traffic spike): $640. I was genuinely surprised the first month. I budgeted $200 and got a $410 bill because I forgot the log analytics workspace charges roughly $0.50 per GB ingested and Aspire's verbose default logging dumped 18GB into it.
The fix: turn off the chatty loggers. Aspire's default config logs every resource state change at Information level. In production you want Warning. Set Logging__LogLevel__Default=Warning in your ACA environment variables. That single change cut our log volume from 18GB to 1.2GB.
az containerapp update \
--name api \
--resource-group myapp \
--set-env-vars \
Logging__LogLevel__Default=Warning \
Logging__LogLevel__Aspire=Warning \
Logging__LogLevel__Microsoft_AspNetCore=Warning
This is the kind of thing the docs do not tell you. You find out at the end of month one when the bill arrives. Budget at least one full billing cycle for surprises. The 18GB-to-1.2GB reduction paid for itself inside a week.
The three Aspire features that broke
I want to be specific about this because nobody else is. These are real production incidents, not theoretical concerns.
1. Persistent volumes on Azure.
WithDataVolume("pgdata") works perfectly in dev. In ACA, you need an Azure Files share and a storage account. Aspire 9.0 did not auto-provision this for you. I had to manually create a Files share, mount it, and pray the volume name matched exactly what the AppHost expected. Aspire 9.1 added WithAzureVolume for exactly this case. We upgraded the day the release notes went up.
2. The service discovery via env vars promise in multi-region.
When I tried to deploy the same Aspire app to two regions for failover, service discovery broke. The env var was set to the FQDN of the primary region, so the API in the secondary region was calling the primary region's database across the continent. Latency: 180ms minimum on every request. Aspire does not currently support per-region service resolution. I had to bake in a region-aware config layer on top of Aspire, using ACA's own environment variable to detect the region and rewrite the connection string at startup. Not impossible, but a lot of glue code for what should be a built-in concern.
3. Resource startup timeouts.
The default resource health check timeout is 30 seconds. Postgres on Azure Flexible Server takes 45 to 90 seconds to come up the first time after a fresh deploy. Your AppHost will mark the database as unhealthy and refuse to start the API. The fix is WithHealthCheck(timeout: TimeSpan.FromMinutes(2)). Not in the docs. I found it in a GitHub issue from October 2025 that had 47 thumbs up and zero maintainer response at the time.
These are not deal-breakers. But they are the kind of thing that, if you do not know about them, will cost you a Saturday.
When NOT to use Aspire
Be honest with yourself here. Aspire is not the right tool if:
- You have a single monolith with no service-to-service calls. The overhead is real: the AppHost runtime, the manifest generation, the OTel SDK. You will spend complexity budget for zero benefit.
- You already have a working Kubernetes setup with Helm charts. Aspire's AppHost is a thin layer over what K8s already does. The only thing you gain is C#-typed manifests, which is nice but not $200 a month nice.
- Your team is not on .NET. Aspire is C# only. The Java team is not going to learn C# to integrate with your telemetry stack. Use OpenTelemetry directly instead.
- You have more than 12 services. The AppHost becomes unwieldy. I know teams running 20-plus service Aspire apps and the boot time is over 4 minutes. At that point, you have reinvented Kubernetes badly. Just use Kubernetes.
For 3 to 8 services in a .NET shop, Aspire is genuinely the best developer experience available right now. I will not go back to docker-compose for that scale. The onboarding for a new engineer takes a day, not a week.
Testing Aspire apps without losing your mind
Before Aspire 9.0, integration testing a distributed .NET app was painful. You either spun up real Postgres in CI (slow, flaky), used Testcontainers (better, but boilerplate-heavy), or mocked the world and tested nothing useful. The new DistributedApplicationTestingBuilder is the third path, and it is the one I actually use.
public class ApiIntegrationTests : IClassFixture<AspireAppFactory>
{
[Fact]
public async Task Get_user_returns_seeded_data()
{
var factory = new AspireAppFactory();
var client = factory.CreateClient();
var response = await client.GetAsync("/users/42");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
The AspireAppFactory spins up the full AppHost in-process for the test fixture, wires real Postgres and Redis, and gives you an HttpClient pointed at the API. No Docker required in the test runner, no port conflicts, no flake. Test runs in 4 to 8 seconds depending on warm-up.
The catch: it only works for integration tests at the API boundary. You cannot use it to unit-test a single class in isolation. For that, you still need regular xUnit. Also, the test factory shares state across tests in the same fixture, so test ordering matters. Use IClassFixture and avoid static state in your services.
We replaced 200 lines of Testcontainers setup with 40 lines of AspireAppFactory. The CI suite went from 6 minutes to 90 seconds. Worth the upgrade on its own.
The bottom line
Eight months in, I am still using Aspire. The dashboard alone justifies the migration cost. The telemetry is best-in-class for the .NET ecosystem. The deploy story is good enough that I can onboard a new service in an afternoon and have it running in production by lunch.
It is not magic. You will read source code. You will hit timeout issues on cold starts. You will get a $400 bill the first month. But for a .NET team of two to six engineers shipping a small distributed system, Aspire in 2026 is, in my honest opinion, the most productive environment you can stand up.
Try it on a side project first. Run it for a month. Watch the trace view. Then decide. And whatever you do, set the log level to Warning on day one. You will thank me when the first invoice arrives.
Comments
Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.