Lesson 64 of 66 · 16 min read · Updated

Deploying a .NET Aspire App to Kubernetes

In the previous lesson you hand-wrote every line of a Deployment for one ASP.NET Core service. Real systems are rarely one service. A typical .NET solution has an API, a worker, a frontend, Redis, PostgreSQL and a message broker, and the connection strings that tie them together. .NET Aspire describes that whole system in C# in an AppHost project, and runs it locally with one command and a dashboard. The question this lesson answers is: how does that same description get to Kubernetes?

Aspire's answer is publishers. You add a Kubernetes environment to the AppHost, and aspire publish turns the app model into a Helm chart: Deployments, StatefulSets, Services, ConfigMaps, Secrets, even a Gateway API route. You build an AppHost with an API and Redis, publish it, install the chart on the kind cluster, fix the things the generated chart does not do for you, and look at what aspire deploy adds on top.

We will cover the following topics:

  1. The Aspire app model, and what a Kubernetes compute environment does with it
  2. aspire publish and the generated Helm chart, file by file
  3. How connection strings, parameters and secrets end up in values.yaml
  4. Building the project images with the .NET SDK's built-in container support
  5. Customizing the output with PublishAsKubernetesService: replicas and probes
  6. Real persistence with AddPersistentVolume, and a trap with WithDataVolume
  7. Generating a Gateway API route with AddGateway
  8. aspire deploy versus helm install, and which to use when

Technical Requirements

  1. The multi-node kind cluster used throughout this series: Docker Desktop's Kubernetes with the kind provisioner (one control-plane and three workers, Kubernetes v1.36), or kind create cluster with an equivalent config.
  2. The .NET 10 SDK, Docker, kubectl and Helm 4.2 or later (Aspire's Kubernetes integration requires it; the lesson used Helm 4.3).
  3. The Aspire CLI 13.6. Install it as a .NET tool with dotnet tool install -g Aspire.Cli, or with the install script from aspire.dev. It also works installed into a folder with --tool-path.

Core Concepts

The App Model

An Aspire AppHost is a small console program. It does not contain your business logic; it describes resources and the references between them:

var cache = builder.AddRedis("cache");

builder.AddProject<Projects.Store_Api>("api")
.WithReference(cache)
.WaitFor(cache);

From this, Aspire knows that api is a .NET project, cache is a Redis container, and api needs a connection string for cache. During aspire run it starts Redis in Docker, starts the API as a local process, injects ConnectionStrings__cache into the API's environment, and shows everything in the dashboard.

On the consuming side, an Aspire client integration such as Aspire.StackExchange.Redis reads that connection string by name (builder.AddRedisClient("cache")) and also registers a health check and tracing for it.

Compute Environments and Publishers

The same app model can be published to different targets. Each target is a compute environment you add to the AppHost:

MethodPackageOutput
AddDockerComposeEnvironmentAspire.Hosting.DockerA docker-compose.yaml
AddKubernetesEnvironmentAspire.Hosting.KubernetesA Helm chart
AddAzureContainerAppEnvironmentAspire.Hosting.Azure.AppContainersBicep for Azure Container Apps

Aspire.Hosting.Kubernetes is still marked preview (13.6.2-preview at the time of writing). The shape of the output is stable enough to use, but expect changes between versions and always review the generated chart.

publish vs deploy

CommandWhat it doesNeeds a registry?
aspire publish -o <dir>Writes the Helm chart. Builds nothing, pushes nothing, touches no cluster.No
aspire deployBuilds the project images, pushes them to a registry, resolves parameters and runs helm upgrade --install against your current kubectl contextYes, reachable from your machine and from the cluster nodes

publish fits a GitOps or CI/CD flow: generate the chart, review it, commit it, and let your pipeline or Argo CD install it. deploy is a one-command path for development and small setups. Both use the same generator, so the chart is identical.

How Values Are Split

The generated values.yaml has three sections:

SectionContainsEnds up in
parametersImage names, portsPod specs directly
configNon-secret environment variables (service URLs, OTLP endpoint, service name)A ConfigMap per resource, loaded with envFrom
secretsGenerated passwords and every variable that contains one, such as a connection stringA Secret per resource, loaded with envFrom

Aspire generates a random Redis password during aspire run. In a published chart that value is empty, and you must supply it at install time. That is deliberate: the chart contains no secrets.

Hands-On: Kubernetes Commands

Generate the Helm chart

Run from the solution folder.

aspire publish --project Store.AppHost/Store.AppHost.csproj -o k8s-artifacts

Render the chart without installing it

Shows the final manifests with your values filled in.

helm template store ./k8s-artifacts -n net-aspire --set parameters.api.api_image=net-store-api:1.1

Validate the rendered chart against the API server

Catches invalid names and immutable-field changes before an install.

helm template store ./k8s-artifacts -n net-aspire | kubectl -n net-aspire apply --dry-run=server -f -

Build a project image without a Dockerfile

The .NET SDK builds an OCI image straight into your local Docker.

dotnet publish Store.Api/Store.Api.csproj -c Release -t:PublishContainer -p:ContainerRepository=net-store-api -p:ContainerImageTag=1.1

Install or upgrade the release

Supply the secrets the chart leaves empty.

helm upgrade --install store ./k8s-artifacts -n net-aspire --create-namespace --set parameters.api.api_image=net-store-api:1.1 --set secrets.cache.cache_password=S3cr3t-Redis --set secrets.api.cache_password=S3cr3t-Redis

Find the dashboard login link

The dashboard prints a one-time login URL with a token.

kubectl -n net-aspire logs deploy/k8s-dashboard-deployment | grep "Login to the dashboard"

Step-by-Step Example

The Scenario

A store API counts visits in Redis. Locally the team runs it with aspire run. Now it has to run on the kind cluster with two API replicas, a readiness probe, Redis data that survives a Pod restart, and the Aspire dashboard for telemetry.

Step 1 — The API

dotnet new web -n Store.Api
cd Store.Api
dotnet add package Aspire.StackExchange.Redis --version 13.6.2
dotnet add package OpenTelemetry.Extensions.Hosting --version 1.20.1
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol --version 1.20.1
dotnet add package OpenTelemetry.Instrumentation.AspNetCore --version 1.20.0

Program.cs:

using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

// Reads the "cache" connection string that the AppHost injects (ConnectionStrings__cache)
// and registers IConnectionMultiplexer, a Redis health check and Redis tracing.
builder.AddRedisClient("cache");

// What the ServiceDefaults project of an Aspire template does, in short:
// send traces, metrics and logs to OTEL_EXPORTER_OTLP_ENDPOINT (the Aspire dashboard).
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddAspNetCoreInstrumentation())
.WithMetrics(m => m.AddAspNetCoreInstrumentation())
.WithLogging()
.UseOtlpExporter();

var app = builder.Build();

app.MapHealthChecks("/health");

app.MapGet("/", async (IConnectionMultiplexer redis, ILogger<Program> logger) =>
{
var visits = await redis.GetDatabase().StringIncrementAsync("visits");
logger.LogInformation("Visit number {Visits}", visits);
return Results.Ok(new { message = "Hello from Aspire on Kubernetes", visits, servedBy = Environment.MachineName });
});

app.Run();

A project created from the Aspire templates would reference a ServiceDefaults project instead of the OpenTelemetry lines. It does the same and more (resilience, service discovery, /health and /alive endpoints). It is kept inline here so you can see exactly what reaches the cluster.

Step 2 — The AppHost with a Kubernetes Environment

Create Store.AppHost/Store.AppHost.csproj. In Aspire 13 the AppHost uses its own SDK:

<Project Sdk="Aspire.AppHost.Sdk/13.6.2">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.Redis" Version="13.6.2" />
<PackageReference Include="Aspire.Hosting.Kubernetes" Version="13.6.2-preview.1.26519.11" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Store.Api\Store.Api.csproj" />
</ItemGroup>

</Project>

aspire add kubernetes adds the same package reference for you. Start with the smallest possible AppHost.cs:

var builder = DistributedApplication.CreateBuilder(args);

// Publishing target: turns the app model into a Helm chart.
var k8s = builder.AddKubernetesEnvironment("k8s");

var cache = builder.AddRedis("cache");

builder.AddProject<Projects.Store_Api>("api")
.WithReference(cache)
.WaitFor(cache)
.WithExternalHttpEndpoints();

builder.Build().Run();

AddKubernetesEnvironment changes nothing about aspire run. It only takes part when you publish or deploy.

Step 3 — Publish and Read the Chart

aspire publish --project Store.AppHost/Store.AppHost.csproj -o k8s-artifacts
✅ Pipeline succeeded

k8s-artifacts/Chart.yaml
k8s-artifacts/values.yaml
k8s-artifacts/templates/api/config.yaml
k8s-artifacts/templates/api/deployment.yaml
k8s-artifacts/templates/api/secrets.yaml
k8s-artifacts/templates/api/service.yaml
k8s-artifacts/templates/cache/secrets.yaml
k8s-artifacts/templates/cache/service.yaml
k8s-artifacts/templates/cache/statefulset.yaml
k8s-artifacts/templates/k8s-dashboard/deployment.yaml
k8s-artifacts/templates/k8s-dashboard/service.yaml

Three workloads: the API as a Deployment, Redis as a StatefulSet, and an Aspire dashboard that Aspire added by itself. The chart is named after the AppHost, store-apphost, version 0.1.0.

values.yaml:

parameters:
api:
port_http: 8080
api_image: "api:latest"
secrets:
cache:
cache_password: ""
api:
ConnectionStrings__cache: ""
cache_password: ""
CACHE_URI: ""
config:
api:
OTEL_DOTNET_EXPERIMENTAL_OTLP_RETRY: "in_memory"
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
HTTP_PORTS: ""
CACHE_HOST: "cache-service"
CACHE_PORT: "6379"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://k8s-dashboard-service:18889"
OTEL_EXPORTER_OTLP_PROTOCOL: "grpc"
OTEL_SERVICE_NAME: "api"

The connection string is assembled in the API's Secret template from the Service name and the password value:

stringData:
ConnectionStrings__cache: "cache-service:6379,password={{ .Values.secrets.api.cache_password }}"
CACHE_PASSWORD: "{{ .Values.secrets.api.cache_password }}"
CACHE_URI: "redis://:{{ .Values.secrets.api.cache_password }}@cache-service:6379"

And Redis gets the same password through its own Secret:

containers:
- command:
- "/bin/sh"
image: "docker.io/library/redis:8.6"
name: "cache"
envFrom:
- secretRef:
name: "cache-secrets"
args:
- "-c"
- "redis-server --requirepass $REDIS_PASSWORD"

Notice what is not there: no probes, no resource requests or limits, no securityContext, a single API replica, and no volume for Redis. The generator translates the app model. Production concerns are yours to add, which is what Steps 6 and 7 do.

Step 4 — Build the API Image

The chart expects an image for api (parameters.api.api_image). The .NET SDK can build one without a Dockerfile:

dotnet publish Store.Api/Store.Api.csproj -c Release -t:PublishContainer -p:ContainerRepository=net-store-api -p:ContainerImageTag=1.0
docker image inspect net-store-api:1.0 --format '{{.Config.User}} {{.Os}}/{{.Architecture}}'
1654 linux/arm64

The SDK picks the matching ASP.NET Core base image, sets the non-root user 1654 and builds for your machine's architecture. Docker Desktop's Kubernetes can use the image immediately; with plain kind, run kind load docker-image net-store-api:1.0. Use a new tag for every build: the nodes cache images by tag.

Step 5 — Install the Chart

helm install store ./k8s-artifacts -n net-aspire --create-namespace \
--set parameters.api.api_image=net-store-api:1.0 \
--set secrets.cache.cache_password=S3cr3t-Redis \
--set secrets.api.cache_password=S3cr3t-Redis \
--wait
kubectl -n net-aspire get pods,svc
NAME READY STATUS RESTARTS AGE
pod/api-deployment-855cdd47c4-lt9t4 1/1 Running 0 83s
pod/cache-statefulset-0 1/1 Running 0 83s
pod/k8s-dashboard-deployment-68b9996884-pf5bx 1/1 Running 0 83s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
service/api-service ClusterIP 10.96.115.96 <none> 8080/TCP
service/cache-service ClusterIP 10.96.140.151 <none> 6379/TCP
service/k8s-dashboard-service ClusterIP 10.96.254.49 <none> 18888/TCP,18889/TCP,18890/TCP

The password appears twice because the generator keeps one value per consuming resource. Both must match, or the API cannot authenticate. In a real pipeline, pass them from a secret store with --set-file or a values file that never touches Git.

Try it:

kubectl -n net-aspire port-forward svc/api-service 8080:8080
curl -s localhost:8080/
{"message":"Hello from Aspire on Kubernetes","visits":1,"servedBy":"api-deployment-855cdd47c4-lt9t4"}
curl -s localhost:8080/
{"message":"Hello from Aspire on Kubernetes","visits":2,"servedBy":"api-deployment-855cdd47c4-lt9t4"}
curl -s localhost:8080/health
Healthy

Open the dashboard. It prints a login link with a one-time token:

kubectl -n net-aspire logs deploy/k8s-dashboard-deployment | grep "Login to the dashboard"
Login to the dashboard at http://localhost:18888/login?t=90c62e3e4a03b1f6184f8cc5a76a7377
kubectl -n net-aspire port-forward svc/k8s-dashboard-service 18888:18888

Open that URL in a browser. After some requests, the dashboard lists the api resource with logs, traces and metrics. The traces include GET / spans and INCR spans from the Redis client integration, and also a steady stream of GET /health spans whose user agent is kube-probe/1.36: once you add probes, the kubelet's checks are traced too. In a real service, filter health endpoints out of tracing.

The dashboard log also warns OTLP server is unsecured. Anything in the cluster can send it telemetry. It is fine for development clusters. For anything shared, protect it, or remove it with k8s.WithDashboard(false) and send telemetry to your real observability stack, as in the OpenTelemetry lesson.

Step 6 — Customize: Replicas, Probes, Helm Settings

PublishAsKubernetesService gives you the generated Kubernetes objects as C# objects before they are written. Update AppHost.cs:

using Aspire.Hosting.Kubernetes.Resources;

var builder = DistributedApplication.CreateBuilder(args);

// Publishing target: turns the app model into a Helm chart.
var k8s = builder.AddKubernetesEnvironment("k8s")
.WithHelm(helm =>
{
helm.WithNamespace("net-aspire");
helm.WithReleaseName("store");
helm.WithChartVersion("1.1.0");
});

var cache = builder.AddRedis("cache");

builder.AddProject<Projects.Store_Api>("api")
.WithReference(cache)
.WaitFor(cache)
.WithExternalHttpEndpoints()
.PublishAsKubernetesService(resource =>
{
if (resource.Workload is Deployment deployment)
{
deployment.Spec.Replicas = 2;

var container = deployment.Spec.Template.Spec.Containers[0];
container.ReadinessProbe = new ProbeV1
{
HttpGet = new HttpGetActionV1 { Path = "/health", Port = 8080 },
PeriodSeconds = 5
};
}
});

builder.Build().Run();

Publish again and compare with the previous output:

diff -r k8s-artifacts-v1 k8s-artifacts
diff -r k8s-artifacts-v1/Chart.yaml k8s-artifacts/Chart.yaml
< version: "0.1.0"
> version: "1.1.0"
diff -r k8s-artifacts-v1/templates/api/deployment.yaml k8s-artifacts/templates/api/deployment.yaml
> readinessProbe:
> periodSeconds: 5
> httpGet:
> path: "/health"
> port: 8080
< replicas: 1
> replicas: 2

WithChartVersion changed Chart.yaml. WithNamespace and WithReleaseName did not change any file: they are used by aspire deploy when it runs Helm for you. With helm install you pass the release name and namespace yourself.

Diffing the generated output after every AppHost change is a good habit. Keep the published chart in Git and review the diff in pull requests like any other code.

Build a new image tag and upgrade, reusing the passwords from the first install:

dotnet publish Store.Api/Store.Api.csproj -c Release -t:PublishContainer -p:ContainerRepository=net-store-api -p:ContainerImageTag=1.1
helm upgrade store ./k8s-artifacts -n net-aspire --reuse-values --set parameters.api.api_image=net-store-api:1.1 --wait
helm -n net-aspire list
NAME NAMESPACE REVISION STATUS CHART APP VERSION
store net-aspire 2 deployed store-apphost-1.1.0 1.1.0

Step 7 — Make Redis Data Survive a Pod Restart

So far the visit counter lives only in the Redis container's memory and filesystem. Delete the Pod and watch it reset:

curl -s localhost:8080/
{"message":"Hello from Aspire on Kubernetes","visits":24,...}
kubectl -n net-aspire delete pod cache-statefulset-0
curl -s localhost:8080/
{"message":"Hello from Aspire on Kubernetes","visits":1,...}
kubectl -n net-aspire get pvc
No resources found in net-aspire namespace.

The obvious fix from local Aspire development is WithDataVolume(). It is not enough for Kubernetes. With the 13.6 preview of the integration, publishing this:

var cache = builder.AddRedis("cache")
.WithDataVolume();

produced an emptyDir volume, which is deleted together with the Pod, and named it after the AppHost project, store.apphost-40b3714102-cache-data. The dots in that name are illegal in Kubernetes, and the API server rejects the StatefulSet:

helm template store ./k8s-artifacts -n net-aspire | kubectl -n net-aspire apply --dry-run=server -f -
The StatefulSet "cache-statefulset" is invalid:
* spec.template.spec.volumes[0].name: Invalid value: "store.apphost-40b3714102-cache-data": must not contain dots

That is why the server-side dry run from the Hands-On section is worth running on every generated chart. Passing an explicit name, WithDataVolume("cache-data"), fixes the name but still gives you an emptyDir.

The Kubernetes integration has a first-class way to ask for a real PersistentVolumeClaim:

// A PersistentVolumeClaim in the generated chart.
var cacheData = k8s.AddPersistentVolume("cache-data")
.WithCapacity("1Gi");

var cache = builder.AddRedis("cache")
.WithDataVolume("cache-data") // locally: a Docker volume
.WithPersistentVolume(cacheData); // on Kubernetes: the PVC above, matched by name

Publish again. The chart now contains templates/cache-data/cache-data.yaml:

apiVersion: "v1"
kind: "PersistentVolumeClaim"
metadata:
name: "cache-data"
spec:
accessModes:
- "ReadWriteOnce"
resources:
requests:
storage: "1Gi"

and the StatefulSet mounts it, with an fsGroup so the Redis user can write to it, and tells Redis to save to disk:

args:
- "-c"
- "redis-server --requirepass $REDIS_PASSWORD --save 60 1"
volumeMounts:
- name: "cache-data"
mountPath: "/data"
volumes:
- name: "cache-data"
persistentVolumeClaim:
claimName: "cache-data"
securityContext:
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch"

Install it fresh and test persistence. --save 60 1 writes a snapshot when at least one key changed in the last 60 seconds, so wait a minute before deleting the Pod:

helm uninstall store -n net-aspire
helm install store ./k8s-artifacts -n net-aspire --set parameters.api.api_image=net-store-api:1.1 --set secrets.cache.cache_password=S3cr3t-Redis --set secrets.api.cache_password=S3cr3t-Redis --wait
kubectl -n net-aspire get pvc
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
cache-data Bound pvc-2fd0d33c-0e08-4a1b-942d-b0563f2eb86e 1Gi RWO standard
# ... a few requests, then wait 60+ seconds
curl -s localhost:8080/
{"message":"Hello from Aspire on Kubernetes","visits":6,...}
kubectl -n net-aspire delete pod cache-statefulset-0
curl -s localhost:8080/
{"message":"Hello from Aspire on Kubernetes","visits":7,...}

The count continued from 6 to 7. Why uninstall instead of upgrade? In an intermediate attempt the volume was added to the StatefulSet's volumeClaimTemplates, and Helm refused the upgrade with Kubernetes' rule for StatefulSets: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', ... are forbidden. Storage changes to a StatefulSet usually mean deleting and recreating it. Plan them in advance for real data.

Step 8 — Generate a Gateway API Route

The integration can also generate the Gateway API resources from the Gateway API series of this course. Add to AppHost.cs:

var api = builder.AddProject<Projects.Store_Api>("api")
// ... as before

// A Gateway API Gateway + HTTPRoute in the generated chart (needs a Gateway controller in the cluster).
k8s.AddGateway("public")
.WithGatewayClass("eg")
.WithRoute("/", api.GetEndpoint("http"));

Publishing adds templates/public/public.yaml and templates/public/route.yaml:

apiVersion: "gateway.networking.k8s.io/v1"
kind: "Gateway"
metadata:
name: "public"
spec:
gatewayClassName: "eg"
listeners:
- name: "http"
protocol: "HTTP"
port: 80
allowedRoutes:
namespaces:
from: "Same"
---
apiVersion: "gateway.networking.k8s.io/v1"
kind: "HTTPRoute"
metadata:
name: "public-route"
spec:
parentRefs:
- name: "public"
rules:
- matches:
- path:
type: "PathPrefix"
value: "/"
backendRefs:
- name: "api-service"
port: 8080

eg is the Envoy Gateway GatewayClass from the Gateway API lessons. The chart only installs if the Gateway API CRDs exist in the cluster, so install Envoy Gateway first, as shown in that series. There is also k8s.AddIngress(...) for clusters that still use Ingress.

Step 9 — When to Use aspire deploy

aspire deploy performs Steps 4 and 5 for you: it builds every project image, pushes them, resolves parameter values and runs Helm with the release name and namespace from WithHelm. For a vanilla cluster it needs a registry that both your machine and the cluster nodes can reach, declared in the AppHost:

var registry = builder.AddContainerRegistry("registry", "myregistry.example.com:5000");
builder.AddKubernetesEnvironment("k8s").WithContainerRegistry(registry);

The registry APIs are in preview and require #pragma warning disable ASPIRECOMPUTE003. On a local kind cluster without a registry, the publish + dotnet publish -t:PublishContainer + helm route used in this lesson is simpler and fully transparent. In CI/CD, aspire publish produces the chart as a build artifact and your pipeline or GitOps tool installs it.

Step 10 — Clean Up

helm uninstall store -n net-aspire
kubectl delete namespace net-aspire

Deleting the namespace also deletes the PVC, and with the Delete reclaim policy of the standard StorageClass, the data.

Summary

  1. An Aspire AppHost describes resources and their references in C#. AddKubernetesEnvironment adds Kubernetes as a publishing target without changing aspire run.
  2. aspire publish generates a Helm chart: projects become Deployments, databases and caches become StatefulSets, references become Services, ConfigMaps and Secrets, and an Aspire dashboard is included by default.
  3. Secrets such as the Redis password are left empty in values.yaml. Supply them at install time.
  4. Build project images with dotnet publish -t:PublishContainer. Use a new tag per build.
  5. The generated chart has no probes, resources or security settings. Add them with PublishAsKubernetesService, and review the chart diff after every change.
  6. WithDataVolume() is not persistent storage on Kubernetes. Use AddPersistentVolume with WithPersistentVolume, and validate every chart with a server-side dry run.
  7. AddGateway generates Gateway API resources; aspire deploy adds image build, push and Helm install on top of the same chart, and needs a registry.

With this lesson the .NET on Kubernetes section is complete: you can run a single ASP.NET Core service the way Kubernetes expects, and ship a whole Aspire application to a cluster as a reviewed Helm chart.