k3s integration
This article is the reference for the Aspire k3s Hosting integration. It enumerates the AppHost APIs — with examples for both AppHost.cs and apphost.mts — that you use to run a lightweight k3s Kubernetes cluster as part of your local development inner loop. The cluster, Helm chart installs, manifest applies, and service endpoint exposures all appear as first-class resources in the Aspire dashboard — no external tooling beyond a supported container runtime is required.
Prerequisites
Section titled “Prerequisites”A container runtime that supports privileged Linux containers:
- Docker Engine 20.10+ (Linux) or Docker Desktop (macOS / Windows)
- Podman 4.0+ (Linux, rootful only — rootless requires cgroup v2 delegation)
To verify Docker Desktop is configured for privileged containers, run:
docker run --rm --privileged alpine echo "Privileged containers supported"If this command fails with a permission error, you may need to enable privileged mode in Docker Desktop settings or configure your Linux runtime for rootful operation.
Installation
Section titled “Installation”To start building an Aspire app that uses k3s, install the 📦 CommunityToolkit.Aspire.Hosting.K3s NuGet package:
aspire add communitytoolkit-k3sLearn more about aspire add in the command reference.
Or, choose a manual installation approach:
#:package CommunityToolkit.Aspire.Hosting.K3s@*<PackageReference Include="CommunityToolkit.Aspire.Hosting.K3s" Version="*" />aspire add communitytoolkit-k3sLearn more about aspire add in the command reference.
This updates your aspire.config.json with the k3s hosting integration package:
{ "packages": { "CommunityToolkit.Aspire.Hosting.K3s": "*" }}The TypeScript AppHost bindings for k3s are provided by the Community Toolkit package. After installation, the .aspire/modules/aspire.mjs file in your project includes addK3sCluster and related APIs.
Add a k3s cluster
Section titled “Add a k3s cluster”Once you’ve installed the hosting integration in your AppHost project, add a k3s cluster and reference it from a project to have KUBECONFIG injected automatically.
var builder = DistributedApplication.CreateBuilder(args);
var cluster = builder.AddK3sCluster("k8s");
builder.AddProject<Projects.MyOperator>("operator") .WaitFor(cluster) .WithReference(cluster); // injects KUBECONFIG automatically
builder.Build().Run();import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const cluster = await builder.addK3sCluster('k8s');
await builder .addProject('operator', '../MyOperator/MyOperator.csproj') .withReference(cluster); // injects KUBECONFIG automatically
await builder.build().run();Configure the cluster
Section titled “Configure the cluster”All cluster options are available as fluent builder methods:
var cluster = builder .AddK3sCluster("k8s", apiServerPort: 16443, // fixed host port for the API server (random by default) agentCount: 2) // 1 server + 2 agent nodes .WithK3sVersion("v1.32.3-k3s1") // pin the k3s image tag .WithPodSubnet("10.42.0.0/16") // --cluster-cidr .WithServiceSubnet("10.43.0.0/16") // --service-cidr .WithDisabledComponent("traefik") // --disable=traefik (repeatable) .WithExtraArg("--write-kubeconfig-mode=644") // raw k3s server flag (repeatable) .WithHelmImage(tag: "3.18.0") // override the alpine/helm image .WithKubectlImage(tag: "1.37.0") // override the alpine/kubectl image .WithDataVolume() // persist cluster state across restarts .WithLifetime(ContainerLifetime.Persistent);import { createBuilder, ContainerLifetime } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const cluster = await builder .addK3sCluster('k8s', { apiServerPort: 16443, agentCount: 2 }) .withK3sVersion('v1.32.3-k3s1') .withPodSubnet('10.42.0.0/16') .withServiceSubnet('10.43.0.0/16') .withDisabledComponent('traefik') .withExtraArg('--write-kubeconfig-mode=644') .withDataVolume({ name: 'k8s-data' }) .withHelmImage({ tag: '3.18.0' }) .withKubectlImage({ tag: '1.37.0' }) .withLifetime(ContainerLifetime.Persistent);The available options are:
| Method | Parameter / Effect |
|---|---|
AddK3sCluster(agentCount:) | Number of worker nodes (0 = single-node). Equivalent to WithAgentCount. |
WithAgentCount(n) | Same as above, fluent alternative. The health check waits for all 1 + n nodes to be Ready. |
WithK3sVersion(tag) | Overrides the k3s image tag, for example v1.32.3-k3s1. Synced to agents automatically. |
WithPodSubnet(cidr) | Sets --cluster-cidr. Defaults to the k3s built-in 10.42.0.0/16. |
WithServiceSubnet(cidr) | Sets --service-cidr. Defaults to the k3s built-in 10.43.0.0/16. |
WithDisabledComponent(c) | Passes --disable=<c>. Call multiple times for multiple components. |
WithExtraArg(arg) | Appends a raw argument to k3s server. |
WithHelmImage(tag?, image?, registry?) | Overrides the alpine/helm image used by AddHelmRelease. |
WithKubectlImage(tag?, image?, registry?) | Overrides the alpine/kubectl image used by AddK8sManifest. |
WithDataVolume(name?) | Mounts a named Docker volume at /var/lib/rancher/k3s. |
WithLifetime(lifetime) | Sets ContainerLifetime.Persistent or Session for the cluster and its agents. |
Persist cluster state across runs
Section titled “Persist cluster state across runs”var cluster = builder.AddK3sCluster("k8s") .WithDataVolume() .WithLifetime(ContainerLifetime.Persistent);const cluster = await builder.addK3sCluster('k8s') .withDataVolume({ name: 'k8s-data' }) .withLifetime(ContainerLifetime.Persistent);WithDataVolume persists the k3s database, certificates, and node tokens across AppHost restarts. WithLifetime(Persistent) tells DCP to keep the Docker container alive between runs, making subsequent starts much faster.
Deploy Helm charts
Section titled “Deploy Helm charts”AddHelmRelease runs helm upgrade --install --wait inside an alpine/helm container — no host-side helm binary is required:
var podinfo = cluster.AddHelmRelease( name: "podinfo", chart: "podinfo", repo: "https://stefanprodan.github.io/podinfo", version: "6.7.1", @namespace: "podinfo") .WithHelmValue("replicaCount", "2") .WithHelmValuesFile("./deploy/podinfo-values.yaml");
// Wait for the chart install to complete before starting the operator.builder.AddProject<Projects.MyOperator>("operator") .WaitForCompletion(podinfo) .WithReference(cluster);const podinfo = await cluster.addHelmRelease('podinfo', 'podinfo', { repo: 'https://stefanprodan.github.io/podinfo', version: '6.7.1', namespace: 'podinfo',});
// Wait for the chart install to complete before starting the operator.const operator = await builder.addProject('operator', '../MyOperator/MyOperator.csproj');await operator.waitForCompletion(podinfo);await operator.withReference(cluster);Values are applied in this order, with the last one winning:
WithHelmValuesFile— in declaration orderWithHelmValue(--setflags) — always override files
Use WithHelmValuesFile for structured overrides (values with commas, braces, or backslashes). WithHelmValue is convenient for individual scalar overrides.
Apply Kubernetes manifests
Section titled “Apply Kubernetes manifests”AddK8sManifest runs kubectl apply --server-side inside an alpine/kubectl container. The apply mode is detected automatically from the path:
| Path | Mode |
|---|---|
Single .yaml / .yml file | kubectl apply -f <file> |
Directory (no kustomization.yaml) | kubectl apply -f <dir> (all YAML files, lexicographic order) |
Directory containing kustomization.yaml | kubectl apply -k <dir> (Kustomize) |
// Plain YAMLvar appConfig = cluster.AddK8sManifest("app-config", "./k8s/app-config.yaml") .WaitForCompletion(podinfo);
// Kustomize overlay — auto-detected; directory is bind-mounted to preserve base referencesvar monitoring = cluster.AddK8sManifest("monitoring-config", "./k8s/monitoring") .WaitForCompletion(podinfo) .WaitForCompletion(appConfig);// Plain YAMLconst appConfig = await cluster.addK8sManifest('app-config', './k8s/app-config.yaml');await appConfig.waitForCompletion(podinfo);
// Kustomize overlay — auto-detected; directory is bind-mounted to preserve base referencesconst monitoring = await cluster.addK8sManifest('monitoring-config', './k8s/monitoring');await monitoring.waitForCompletion(podinfo);await monitoring.waitForCompletion(appConfig);Expose Kubernetes services
Section titled “Expose Kubernetes services”AddServiceEndpoint starts an in-process WebSocket port-forward bound to 0.0.0.0:{allocatedPort} — no NodePort or LoadBalancer configuration is required. The endpoint transitions to Running only after the target service has a ready pod.
var podinfoWeb = cluster .AddServiceEndpoint("podinfo-web", "podinfo", servicePort: 9898, @namespace: "podinfo") .WaitForCompletion(podinfo);
// Host processes receive http://localhost:{port}builder.AddProject<Projects.MyApi>("api") .WaitFor(podinfoWeb) .WithReference(podinfoWeb);
// DCP-network containers receive http://host.docker.internal:{port}// --add-host=host.docker.internal:host-gateway is injected automaticallybuilder.AddContainer("sidecar", "myorg/sidecar") .WaitFor(podinfoWeb) .WithReference(podinfoWeb);const podinfoWeb = await cluster .addServiceEndpoint('podinfo-web', 'podinfo', 9898, { namespace: 'podinfo' }) .waitForCompletion(podinfo);
// Host processes receive http://localhost:{port}await builder .addProject('api', '../MyApi/MyApi.csproj') .withReference(podinfoWeb);
// DCP-network containers receive http://host.docker.internal:{port}// --add-host=host.docker.internal:host-gateway is injected automaticallyawait builder .addContainer('sidecar', 'myorg/sidecar') .withReference(podinfoWeb);The injected environment variable follows the Aspire service-discovery convention: services__{name}__url=http(s)://{host}:{port}.
Scheme is inferred from the port: 443 and 8443 resolve to https, all others to http. Override it with the scheme parameter, for example AddServiceEndpoint("ep", "svc", 8080, scheme: "https").
Kubeconfig injection
Section titled “Kubeconfig injection”Both K3sClusterResource and K3sServiceEndpointResource implement IResourceWithConnectionString, so the standard WithReference overload handles credential injection automatically:
// Projects and executables receive KUBECONFIG pointing to the host-accessible variantbuilder.AddProject<Projects.MyOperator>("operator") .WithReference(cluster); // KUBECONFIG=…/.k3s/k8s/local/kubeconfig.yaml
// Containers receive a bind-mounted kubeconfig at /tmp/k3s-kubeconfig.yamlbuilder.AddContainer("sidecar", "myorg/sidecar") .WithReference(cluster); // KUBECONFIG=/tmp/k3s-kubeconfig.yaml + file bind-mountAll standard Kubernetes tooling reads KUBECONFIG automatically:
var config = KubernetesClientConfiguration.BuildConfigFromConfigFile( Environment.GetEnvironmentVariable("KUBECONFIG"));using var client = new Kubernetes(config);// Standard withReference — injects KUBECONFIG or services__name__urlconst operator = await builder.addProject('operator', '../MyOperator/MyOperator.csproj');await operator.withReference(cluster);
const api = await builder.addProject('api', '../MyApi/MyApi.csproj');await api.withReference(podinfoWeb);Reach Aspire services from k3s pods
Section titled “Reach Aspire services from k3s pods”k3s pods run on the internal pod network (10.42.0.0/16). Flannel masquerades outbound pod traffic through the k3s container’s DCP network IP, so pods can reach DCP services using host.docker.internal and the host-mapped port:
var postgres = builder.AddPostgres("db");
cluster.AddHelmRelease("my-operator", "my-operator-chart") .WithHelmValue("database.host", "host.docker.internal") .WithHelmValue("database.port", "5432");const postgres = await builder.addPostgres('db');
const helmRelease = await cluster.addHelmRelease('my-operator', 'my-operator-chart') .withHelmValue('database.host', 'host.docker.internal') .withHelmValue('database.port', '5432');