A team runs a distributed .NET application through .NET Aspire. The API starts, the worker connects to the database, service-to-service calls resolve by name, and the Aspire dashboard shows logs and traces from every component.
The same containers are then deployed to Kubernetes. The frontend starts, but calls to the booking service fail. The worker cannot find its database. Kubernetes sends traffic to a replica before it is ready, and the production telemetry view is empty.
Nothing is necessarily wrong with the application code. The missing piece is orchestration configuration. During development, the Aspire App Host creates service discovery mappings, injects connection strings, starts resources in the correct order, and supplies OpenTelemetry settings. Kubernetes cannot infer all of that from the container image. The production deployment must recreate the same relationships with Kubernetes objects and environment configuration.
Context and Scope
Assume a travel platform contains these components:
trip-api, an ASP.NET Core API used by the frontendavailability-worker, a background service that updates available tripscatalog-db, a SQL Server database- An OpenTelemetry endpoint for logs, traces, and metrics
The local development topology is:
.NET Aspire App Host
|
+--> trip-api
| |
| +--> catalog-db
|
+--> availability-worker
| |
| +--> trip-api
| +--> catalog-db
|
+--> Aspire dashboard and OTLP endpoint
The Kubernetes topology must provide equivalent capabilities:
Ingress or Load Balancer
|
v
Kubernetes Service: trip-api
|
v
Deployment: trip-api replicas
|
+--> Database connection configuration
+--> Health endpoints
+--> OpenTelemetry configuration
Deployment: availability-worker replicas
|
+--> Kubernetes Service name for trip-api
+--> Database connection configuration
+--> OpenTelemetry configuration
Stateful database or external managed database
The goal is not to make Kubernetes copy the internal implementation of Aspire. The goal is to preserve the relationships the application depends on.
Why Local Success Can Hide Deployment Gaps
.NET Aspire simplifies distributed development through two main project types.
The App Host declares projects, containers, executables, databases, message brokers, and the relationships among them.
The Service Defaults project configures common behavior inside each .NET service, including:
- HTTP client service discovery
- Standard HTTP resilience handling
- OpenTelemetry
- Health endpoints
When the application runs locally, Aspire supplies configuration automatically. A service can call another service by a symbolic name instead of a machine-specific URL. A project that references a database receives its connection string through configuration. The dashboard receives telemetry because Aspire injects the OpenTelemetry Protocol, or OTLP, endpoint settings.
Kubernetes provides similar capabilities through different objects:
| Aspire development concern | Kubernetes production concern |
|---|---|
| Project declaration | Deployment or StatefulSet |
| Project replica count | Deployment replicas |
| Symbolic service name | Kubernetes Service and DNS |
| Resource reference | Environment variable or mounted configuration |
| Secret parameter | Kubernetes Secret |
| Local data volume | PersistentVolumeClaim |
| Health endpoint | Liveness and readiness probes |
| Aspire dashboard telemetry | External OTLP collector |
| Public project endpoint | LoadBalancer Service or Ingress |
A container image includes the application and its runtime dependencies. It does not include the complete deployment topology.
Step 1: Declare the Development Topology in the App Host
The App Host should describe the relationships between services and resources instead of placing environment-specific addresses inside application code.
var builder = DistributedApplication.CreateBuilder(args);
var databaseServer = builder
.AddSqlServer("catalog-sql")
.WithDataVolume();
var catalogDatabase = databaseServer
.AddDatabase("catalog");
var tripApi = builder
.AddProject<Projects.TripApi>("trip-api")
.WithReference(catalogDatabase)
.WaitFor(catalogDatabase);
builder
.AddProject<Projects.AvailabilityWorker>(
"availability-worker")
.WithReference(tripApi)
.WaitFor(tripApi)
.WithReference(catalogDatabase)
.WaitFor(catalogDatabase);
builder.Build().Run();
WithReference declares that one component uses another component or resource. Aspire uses that relationship to inject the configuration required for service discovery or resource access.
WaitFor controls local startup order. It prevents a dependent project from starting before the referenced resource is considered ready.
These declarations are useful documentation even before deployment. They show what must be recreated in the target environment.
Step 2: Apply Service Defaults to Every .NET Service
Each service should call the common extension methods supplied by the Aspire Service Defaults project.
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.MapDefaultEndpoints();
app.Run();
The default configuration includes service discovery, resilience handling, telemetry, and health endpoints.
For HTTP clients, the shared configuration can apply service discovery and standard resilience handling consistently.
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
A worker can call the API through its symbolic name:
builder.Services.AddHttpClient(
"TripApi",
client =>
{
client.BaseAddress =
new Uri("https+http://trip-api");
})
.AddServiceDiscovery();
The https+http form allows service discovery to try HTTPS first and then HTTP. That is useful when local development exposes HTTP while a deployed environment exposes HTTPS.
The symbolic name must match the service name declared in the App Host and the corresponding production service name.
Step 3: Understand What Aspire Injects
When the App Host creates a database resource and a project references it, Aspire computes the connection string and injects it using the normal .NET configuration naming convention.
A resource named catalog becomes available through an environment variable shaped like:
ConnectionStrings__catalog
ASP.NET Core converts the double underscore into a configuration section separator. Application code can therefore request the connection string by its logical name without knowing where the database runs.
Service discovery uses injected configuration to map a symbolic service name to one or more actual endpoints.
Telemetry uses environment variables such as:
OTEL_EXPORTER_OTLP_ENDPOINT
OTEL_SERVICE_NAME
OTEL_RESOURCE_ATTRIBUTES
During local development, the Aspire App Host supplies these values. In Kubernetes, the deployment configuration must supply equivalent values.
This is the main reason a container can work inside Aspire and fail when started alone. The container has the executable, but not necessarily the configuration Aspire provided around it.
Step 4: Map Stateless Services to Deployments
A Kubernetes Deployment defines the desired number of interchangeable Pod replicas and continuously works to keep that number available.
A simplified API Deployment can look like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: trip-api
spec:
replicas: 3
selector:
matchLabels:
app: trip-api
template:
metadata:
labels:
app: trip-api
spec:
containers:
- name: trip-api
image: registry.example/trip-api:release
ports:
- containerPort: 8080
env:
- name: ConnectionStrings__catalog
valueFrom:
secretKeyRef:
name: catalog-database
key: connection-string
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: http://telemetry-collector:4317
- name: OTEL_SERVICE_NAME
value: trip-api
The Deployment replaces several local Aspire responsibilities:
replicasdefines how many interchangeable API instances should run.- The label identifies the Pods that belong to the workload.
- Environment variables provide the database and telemetry configuration.
- Kubernetes controllers replace failed Pods and maintain the desired replica count.
Do not deploy a stateful component as interchangeable replicas unless its storage and identity requirements support that model.
Step 5: Create a Stable Service Name
Pods can be recreated and moved between nodes. Their individual addresses are not stable enough to use as application configuration.
A Kubernetes Service gives a stable virtual address to a selected group of Pods.
apiVersion: v1
kind: Service
metadata:
name: trip-api
spec:
selector:
app: trip-api
ports:
- name: default
protocol: TCP
port: 8080
targetPort: 8080
The Service name trip-api matches the symbolic address used by the .NET client.
Inside the cluster, service discovery can resolve calls to the Service instead of one particular Pod. Kubernetes then distributes traffic across the selected replicas.
A ClusterIP Service is suitable for internal communication. A frontend that must receive external traffic can be exposed through a LoadBalancer Service or an Ingress.
When one service exposes several endpoints, give its ports names. Aspire also supports named endpoints, and the production Service must preserve names that clients use during discovery.
Step 6: Separate External Routing from Internal Discovery
Internal services should not each receive a public IP address.
Use internal Services for service-to-service communication:
availability-worker
|
v
ClusterIP Service: trip-api
|
v
trip-api replicas
Use one external entry mechanism for browser or partner traffic:
Internet
|
v
Ingress or Load Balancer
|
v
ClusterIP Service: trip-api
|
v
trip-api replicas
An Ingress can route different URL paths to different frontend microservices through one HTTP entry point. It normally works together with an Ingress controller that performs the actual web routing.
This keeps the public boundary separate from internal service discovery.
Step 7: Recreate Resource Configuration Safely
Do not place production passwords or full connection strings directly in the Deployment file.
Create a Kubernetes Secret and reference its keys from environment variables.
kubectl create secret generic catalog-database --from-literal=connection-string='Server=catalog-sql;Database=Catalog;User Id=appuser;Password=replace-me'
The Pod reads the value through secretKeyRef, while the application continues using the logical connection string name.
Kubernetes Secrets can also be mounted as files. That is useful for certificates or values that an application expects at a file path.
The important migration rule is to preserve the configuration key expected by the application:
Aspire resource name: catalog
.NET connection name: catalog
Kubernetes variable: ConnectionStrings__catalog
A spelling mismatch can make the application appear healthy while its first database operation fails.
Step 8: Add Readiness and Liveness Probes
Kubernetes needs the application to report two different conditions.
A liveness check answers: Is this container still functioning, or should it be restarted?
A readiness check answers: Is this instance currently able to receive traffic?
Aspire Service Defaults expose /alive and /health endpoints during development. They can be used as the conceptual basis for production probes when the endpoints are safely exposed in the deployed environment.
containers:
- name: trip-api
image: registry.example/trip-api:release
ports:
- containerPort: 8080
livenessProbe:
httpGet:
path: /alive
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 1
A liveness failure can cause the container or Pod to be restarted. A readiness failure removes the Pod temporarily from Service load balancing without treating it as permanently dead.
These probes should not be interchangeable by accident.
A process can be alive while temporarily unable to serve requests. For example, it may be congested or waiting for a required resource. Restarting it for every temporary dependency problem can create a restart loop.
Health endpoints also need security consideration. Service Defaults expose them only in development by default. A backend that is unreachable from external users can expose them more safely inside the cluster. A public frontend should protect such endpoints appropriately and prevent abuse.
Step 9: Preserve Data Outside Ephemeral Pods
A Pod may move to another node. Data written only to the current node's local disk can disappear from the application's point of view after that move.
Use one of these approaches:
- An external managed database
- A database running inside the cluster with appropriate stateful configuration
- Cloud or network storage represented by a PersistentVolumeClaim, or PVC
A PVC requests storage independently of one physical cluster node.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: catalog-files
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
A container can mount the claim at the path where the application expects durable files.
spec:
containers:
- name: document-worker
image: registry.example/document-worker:release
volumeMounts:
- name: catalog-storage
mountPath: /app/data
volumes:
- name: catalog-storage
persistentVolumeClaim:
claimName: catalog-files
Use a StatefulSet instead of a Deployment when replicas need stable identities and storage that remains associated with specific instances. StatefulSets commonly use a headless Service so each Pod receives its own DNS identity.
Stateless APIs and workers should normally store durable business data outside their Pod filesystem.
Step 10: Reconnect Production Telemetry
The Aspire dashboard shows distributed telemetry because the App Host supplies an OTLP endpoint and identifies every service.
Production requires the same three telemetry dimensions:
- Logs show events from individual services and resources.
- Traces correlate work that belongs to one logical request across processes.
- Metrics report measurements produced by each service.
Set the telemetry variables in every deployment:
OTEL_EXPORTER_OTLP_ENDPOINT=<production collector URL>
OTEL_SERVICE_NAME=<same logical name used in orchestration>
OTEL_RESOURCE_ATTRIBUTES=service.instance.id=<unique instance identifier>
Keep OTEL_SERVICE_NAME aligned with the App Host and Kubernetes Service names when possible. Consistent names make local and production traces easier to compare.
The instance identifier must distinguish replicas. Without it, telemetry from several Pods may be difficult to separate during diagnosis.
A production deployment is incomplete when the application runs but distributed traces disappear. Telemetry is part of the operational configuration, not only a development convenience.
Step 11: Apply and Reapply Declarative Configuration
Kubernetes configuration describes the desired final state. Apply the file to create or update its objects.
kubectl apply -f travel-platform.yaml
After modifying the same configuration, apply it again:
kubectl apply -f travel-platform.yaml
To remove the objects described by the file:
kubectl delete -f travel-platform.yaml
This declarative model is similar to the App Host in one important way: both describe what resources and relationships should exist.
The syntax differs, and some Aspire conveniences must be translated, but the topology should remain recognizable.
Step 12: Use the Aspire Manifest as a Deployment Bridge
.NET Aspire can produce a manifest that describes the application's projects, resources, references, and configuration.
That manifest can support two deployment paths:
- Read it and configure the target orchestrator manually.
- Use tooling that converts the description into target platform resources.
Visual Studio can publish an App Host to Azure Container Apps and provision supported resources. Kubernetes deployments still need careful review because production concerns include secrets, storage classes, public routing, probe exposure, and telemetry endpoints.
Treat generated deployment configuration as a starting point that must preserve the application's operational requirements.
A Practical Parity Review
Before calling a deployment production-ready, compare every App Host relationship with the target cluster.
| Local Aspire declaration | Production verification |
|---|---|
AddProject("trip-api") |
A Deployment or other workload exists |
WithReference(tripApi) |
A resolvable Kubernetes Service exists |
WithReference(catalogDatabase) |
The expected connection configuration is injected |
WaitFor(catalogDatabase) |
Startup and readiness behavior handles dependency availability |
WithDataVolume() |
Durable storage or an external database exists |
AddServiceDefaults() |
Discovery, resilience, health, and telemetry are configured |
MapDefaultEndpoints() |
Kubernetes probes can reach approved health endpoints |
| Aspire dashboard | Production OTLP collector receives telemetry |
This review catches mismatches that container-level testing cannot detect.
Testing the Deployment Before Production
Test the orchestration behavior, not only the application endpoints.
Service discovery test
Run more than one API replica and verify that the worker calls the Kubernetes Service name rather than a Pod address.
Readiness test
Make one replica temporarily unable to satisfy its readiness condition. Confirm that the Service stops routing traffic to it without removing healthy replicas.
Liveness test
Simulate an unhealthy process and verify that Kubernetes replaces or restarts it according to the probe policy.
Configuration test
Start the application without the expected connection secret and verify that the deployment fails visibly rather than continuing with an unintended default.
Storage test
Write test data to the mounted durable path, recreate the Pod, and verify that the data remains available.
Telemetry test
Send one request that crosses several services and verify that logs, traces, and metrics use the expected service names and instance identifiers.
Scaling test
Increase the desired replica count and confirm that the Service continues to route through the stable name.
Common Mistakes
Treating Aspire as the production orchestrator
Aspire is highly useful for local distributed development and for producing deployment descriptions. Kubernetes remains responsible for the actual production workload, networking, storage, health, and recovery configuration.
Hardcoding localhost or development ports
A container's localhost refers to that container or Pod context. Use symbolic service names and Kubernetes Services for cross-service communication.
Deploying containers without recreating references
WithReference causes Aspire to inject discovery or resource configuration. Kubernetes needs equivalent Services, environment variables, and Secrets.
Using liveness for every dependency failure
A temporary database or downstream outage may make a Pod unready without making its process dead. Restarting every unready Pod can amplify an external failure.
Exposing health endpoints publicly without protection
Health endpoints are operational interfaces. Keep them internal where possible and protect public access.
Saving durable data inside a stateless Pod
Pods are replaceable. Use an external database, StatefulSet storage, or a PersistentVolumeClaim.
Giving every internal service a public load balancer
Use ClusterIP Services for internal communication and one controlled public entry point where possible.
Forgetting production telemetry variables
Local traces exist because Aspire provides the collector configuration. Production must provide its own OTLP endpoint and service identity.
Using inconsistent service names
A mismatch among the App Host name, symbolic HTTP address, Kubernetes Service, and telemetry service name makes both connectivity and diagnosis harder.
Deployment Parity Checklist
- [ ] Every App Host project maps to a production workload
- [ ] Stateless services use Deployments
- [ ] Stateful replicas use appropriate stable identity and storage
- [ ] Replica counts reflect the required capacity and availability
- [ ] Every symbolic service name maps to a Kubernetes Service
- [ ] Internal services use ClusterIP where appropriate
- [ ] External HTTP traffic uses a controlled LoadBalancer or Ingress
- [ ] Connection string keys match the names expected by .NET configuration
- [ ] Sensitive values come from Kubernetes Secrets
- [ ] Durable data is outside ephemeral Pod storage
- [ ] Liveness and readiness have different operational meanings
- [ ] Probe startup delays allow the application to initialize
- [ ] Health endpoints are exposed safely
- [ ] OTLP endpoint, service name, and instance identity are configured
- [ ] Generated deployment artifacts are reviewed before release
- [ ] Service discovery, restart, storage, scaling, and telemetry paths are tested
Conclusion
A .NET Aspire application can work perfectly on a developer machine and still fail in Kubernetes because Aspire supplied an operational environment that the container image does not carry with it.
The fix is to map the local topology deliberately. App Host projects become Kubernetes workloads. Symbolic names become Services. Resource references become environment variables and Secrets. Data volumes become durable storage. Service Default health endpoints become liveness and readiness probes. Aspire telemetry configuration becomes production OTLP configuration.
Once those relationships are treated as deployable architecture rather than local convenience, Kubernetes can reproduce the behavior the application depended on during development.