Lesson 64 of 66 · 16 min read · Updated
Deploying a .NET Aspire App to Kubernetes
On this page
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:
- The Aspire app model, and what a Kubernetes compute environment does with it
aspire publishand the generated Helm chart, file by file- How connection strings, parameters and secrets end up in
values.yaml - Building the project images with the .NET SDK's built-in container support
- Customizing the output with
PublishAsKubernetesService: replicas and probes - Real persistence with
AddPersistentVolume, and a trap withWithDataVolume - Generating a Gateway API route with
AddGateway aspire deployversushelm install, and which to use when
Technical Requirements
- 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 clusterwith an equivalent config. - The .NET 10 SDK, Docker,
kubectland Helm 4.2 or later (Aspire's Kubernetes integration requires it; the lesson used Helm 4.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:
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:
| Method | Package | Output |
|---|---|---|
AddDockerComposeEnvironment | Aspire.Hosting.Docker | A docker-compose.yaml |
AddKubernetesEnvironment | Aspire.Hosting.Kubernetes | A Helm chart |
AddAzureContainerAppEnvironment | Aspire.Hosting.Azure.AppContainers | Bicep 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
| Command | What it does | Needs a registry? |
|---|---|---|
aspire publish -o <dir> | Writes the Helm chart. Builds nothing, pushes nothing, touches no cluster. | No |
aspire deploy | Builds the project images, pushes them to a registry, resolves parameters and runs helm upgrade --install against your current kubectl context | Yes, 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:
| Section | Contains | Ends up in |
|---|---|---|
parameters | Image names, ports | Pod specs directly |
config | Non-secret environment variables (service URLs, OTLP endpoint, service name) | A ConfigMap per resource, loaded with envFrom |
secrets | Generated passwords and every variable that contains one, such as a connection string | A 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.
Render the chart without installing it
Shows the final manifests with your values filled in.
Validate the rendered chart against the API server
Catches invalid names and immutable-field changes before an install.
Build a project image without a Dockerfile
The .NET SDK builds an OCI image straight into your local Docker.
Install or upgrade the release
Supply the secrets the chart leaves empty.
Find the dashboard login link
The dashboard prints a one-time login URL with a token.
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
Program.cs:
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:
aspire add kubernetes adds the same package reference for you. Start with the smallest possible AppHost.cs:
AddKubernetesEnvironment changes nothing about aspire run. It only takes part when you publish or deploy.
Step 3 — Publish and Read the Chart
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:
The connection string is assembled in the API's Secret template from the Service name and the password value:
And Redis gets the same password through its own Secret:
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:
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
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:
Open the dashboard. It prints a login link with a one-time token:
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:
Publish again and compare with the previous output:
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:
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:
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:
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:
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:
Publish again. The chart now contains templates/cache-data/cache-data.yaml:
and the StatefulSet mounts it, with an fsGroup so the Redis user can write to it, and tells Redis to save to disk:
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:
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:
Publishing adds templates/public/public.yaml and templates/public/route.yaml:
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:
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
Deleting the namespace also deletes the PVC, and with the Delete reclaim policy of the standard StorageClass, the data.
Summary
- An Aspire AppHost describes resources and their references in C#.
AddKubernetesEnvironmentadds Kubernetes as a publishing target without changingaspire run. aspire publishgenerates 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.- Secrets such as the Redis password are left empty in
values.yaml. Supply them at install time. - Build project images with
dotnet publish -t:PublishContainer. Use a new tag per build. - The generated chart has no probes, resources or security settings. Add them with
PublishAsKubernetesService, and review the chart diff after every change. WithDataVolume()is not persistent storage on Kubernetes. UseAddPersistentVolumewithWithPersistentVolume, and validate every chart with a server-side dry run.AddGatewaygenerates Gateway API resources;aspire deployadds 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.