Skip to content
DocsTry Aspire
DocsTry

k3s integration

⭐ Community Toolkit k3s logo

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.

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:

Terminal window
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.

To start building an Aspire app that uses k3s, install the 📦 CommunityToolkit.Aspire.Hosting.K3s NuGet package:

Terminal
aspire add communitytoolkit-k3s

Learn more about aspire add in the command reference.

Or, choose a manual installation approach:

AppHost.cs
#:package CommunityToolkit.Aspire.Hosting.K3s@*
AppHost.csproj
<PackageReference Include="CommunityToolkit.Aspire.Hosting.K3s" Version="*" />

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.

AppHost.cs
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();

All cluster options are available as fluent builder methods:

AppHost.cs
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);

The available options are:

MethodParameter / 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.
AppHost.cs
var cluster = builder.AddK3sCluster("k8s")
.WithDataVolume()
.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.

AddHelmRelease runs helm upgrade --install --wait inside an alpine/helm container — no host-side helm binary is required:

AppHost.cs
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);

Values are applied in this order, with the last one winning:

  1. WithHelmValuesFile — in declaration order
  2. WithHelmValue (--set flags) — always override files

Use WithHelmValuesFile for structured overrides (values with commas, braces, or backslashes). WithHelmValue is convenient for individual scalar overrides.

AddK8sManifest runs kubectl apply --server-side inside an alpine/kubectl container. The apply mode is detected automatically from the path:

PathMode
Single .yaml / .yml filekubectl apply -f <file>
Directory (no kustomization.yaml)kubectl apply -f <dir> (all YAML files, lexicographic order)
Directory containing kustomization.yamlkubectl apply -k <dir> (Kustomize)
AppHost.cs
// Plain YAML
var appConfig = cluster.AddK8sManifest("app-config", "./k8s/app-config.yaml")
.WaitForCompletion(podinfo);
// Kustomize overlay — auto-detected; directory is bind-mounted to preserve base references
var monitoring = cluster.AddK8sManifest("monitoring-config", "./k8s/monitoring")
.WaitForCompletion(podinfo)
.WaitForCompletion(appConfig);

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.

AppHost.cs
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 automatically
builder.AddContainer("sidecar", "myorg/sidecar")
.WaitFor(podinfoWeb)
.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").

Both K3sClusterResource and K3sServiceEndpointResource implement IResourceWithConnectionString, so the standard WithReference overload handles credential injection automatically:

AppHost.cs
// Projects and executables receive KUBECONFIG pointing to the host-accessible variant
builder.AddProject<Projects.MyOperator>("operator")
.WithReference(cluster); // KUBECONFIG=…/.k3s/k8s/local/kubeconfig.yaml
// Containers receive a bind-mounted kubeconfig at /tmp/k3s-kubeconfig.yaml
builder.AddContainer("sidecar", "myorg/sidecar")
.WithReference(cluster); // KUBECONFIG=/tmp/k3s-kubeconfig.yaml + file bind-mount

All standard Kubernetes tooling reads KUBECONFIG automatically:

var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(
Environment.GetEnvironmentVariable("KUBECONFIG"));
using var client = new Kubernetes(config);

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:

AppHost.cs
var postgres = builder.AddPostgres("db");
cluster.AddHelmRelease("my-operator", "my-operator-chart")
.WithHelmValue("database.host", "host.docker.internal")
.WithHelmValue("database.port", "5432");