.NETKubernetes
June 22, 2026

Why .NET Aspire Microservices Work Locally but Fail After Kubernetes Deployment

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 frontend
  • availability-worker, a background service that updates available trips
  • catalog-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:

  • replicas defines 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:

  1. Read it and configure the target orchestrator manually.
  2. 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.

Share:

Comments0

Home Profile Menu Sidebar
Top