Every time we have talked about Aspire in the past, the same objection always seems to show up: “This is great for local dev, but it doesn’t really fit into our deployment pipeline.”
And in the “early days” of Aspire, that was a fair assessment. It was a tremendous asset for local development, but when it came time to deploy, you were largely left on your own. Aspire ran the app on your machine, gave you a dashboard, and then it largely stopped. But that’s no longer the case.
Your AppHost is already an accurate description of your system’s topology. Across the 13.2 through 13.5 releases, Aspire gained the ability to turn that description into a Compose stack, a Helm release, an AKS cluster, or a set of Azure Container Apps, all without needing to maintain a second description of the same thing in YAML.
So “can Aspire deploy?” isn’t the interesting question anymore. It can. Docker Compose publishing has been stable since 13.2, and the aspire publish and aspire deploy commands have been generally available since 13.4.
What’s interesting is that those four targets sit at four very different stages of maturity, and the release notes won’t tell you which is which. Some of it I’d put a production system on today. Some of it I’d let stew for another release or two.
So let’s sort them out.
Compute Environments
Before any of the target-specific stuff, we need to understand a basic concept that makes the rest of it make sense.
A compute environment is a deployment target that you declare in your app model. You do it the same way you would a Postgres container or a project reference. There are four environments that are worth knowing about:
var compose = builder.AddDockerComposeEnvironment("compose");
var k8s = builder.AddKubernetesEnvironment("k8s");
var aks = builder.AddAzureKubernetesEnvironment("aks");
var aca = builder.AddAzureContainerAppEnvironment("aca");
You bind your resources to them as you would anything else in your AppHost, using the syntax WithComputeEnvironment:
builder.AddProject<Projects.Api>("api")
.WithComputeEnvironment(k8s);
That’s the whole pattern. Every target in this post is those same two ideas with a different environment type. Once you understand that, the rest of this is more of a tour than four separate tutorials.
The other thing to understand early on is the difference between three commands that sound similar at first glance:
aspire publishgenerates deployment artifacts. Adocker-compose.yaml, a Helm chart, Bicep files. It doesn’t touch your infrastructure.aspire deploygenerates them and applies them.aspire destroytears down whataspire deploycreated.
Which of those you use matters a lot. Teams that want Aspire to describe their topology, but keep their existing pipeline, live entirely in publish. Teams that want Aspire to own the deployment adopt deploy. Both are legitimate approaches, and you don’t have to pick now.
Docker Compose
We’ll start with this one. If there’s one thing from this post to adopt first, it’s this one. Compose publishing has been around for a while, and stable since version 13.2. It’s the least risky place to start.
Add the environment:
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDockerComposeEnvironment("compose");
var db = builder.AddPostgres("postgres");
var cache = builder.AddRedis("cache");
builder.AddProject<Projects.Api>("api")
.WithReference(db)
.WithReference(cache);
builder.Build().Run();
Then run aspire publish. Aspire generates a complete docker-compose.yaml from the resources defined in your AppHost, including networking, volumes, and environment variables.
A few things worth knowing.
You can customize individual services - PublishAsDockerComposeService gives you the underlying Compose service object to modify:
builder.AddContainer("netshoot", "nicolaka/netshoot")
.PublishAsDockerComposeService((resource, service) =>
{
service.Privileged = true;
});
Privileged mode arrived in 13.3 for workloads that need it, such as low-level networking tools and nested containers. The service class also picked up the PullPolicy property in 13.2.
Podman works out of the box - Starting in 13.3, Aspire will detect it and generate files that podman-compose wants, giving you the same lifecycle commands. If you’ve been maintaining workarounds for this, you can delete them now.
You will still hand-edit things - Secrets management, resource limits, restart policies and other things will still require hand-editing. Aspire gives you the correct starting point, but it isn’t a finished production artifact.
One last note to think about: The Aspire docs provide a lot of insight on migrating from an existing Docker Compose file to an AppHost setup. If you’re knee-deep in a 400-line compose file and wondering if Aspire is even worth your time, make the effort to go through what they provide. I think you’ll still find it’s worth the investment.
Now we’re going to wade into the waters where Aspire isn’t quite as settled just yet.
Kubernetes
For a Kubernetes environment, running aspire deploy will trigger Aspire to generate a complete Helm chart and apply it to your cluster. No separate helm install, no kustomize, and no hand-rolled manifests needed.
var k8s = builder.AddKubernetesEnvironment("k8s");
builder.AddProject<Projects.Api>("api")
.WithComputeEnvironment(k8s);
Note: Native Kubernetes deployments are still preview, and the routing resources introduced in 13.3 may change. So plan accordingly.
Ingress as a first-class resource
Ingress and Gateway API routing are things you can declare in the app model now:
var api = builder.AddProject<Projects.Api>("api")
.WithComputeEnvironment(k8s)
.WithExternalHttpEndpoints();
var ingress = k8s.AddIngress("public")
.WithIngressClass("nginx")
.WithHostname("api.example.com")
.WithTls("api-cert");
ingress.WithPath("/", api.GetEndpoint("http"));
Two things to watch here if you started on 13.3. The ingress routing method was renamed from WithRoute(...) to WithPath(...) in 13.4, to match Kubernetes path-rule terminology and to disambiguate it from the Gateway API, where gateway.WithRoute(...) is unchanged. The IngressPathType enum was split at the same time into KubernetesIngressPathType and KubernetesGatewayPathType.
And as of 13.4, routing a non-external endpoint through an ingress or gateway throws an InvalidOperationException at publish time rather than quietly generating routes that can’t resolve. That’s why WithExternalHttpEndpoints() is on the project above.
From that, Aspire will emit Ingress, IngressClass, Gateway, HTTPRoute, and, where applicable, a cert-manager Certificate.
In 13.4, they added typed APIs for cert-manager, installing it, declaring ClusterIssuers backed by Let’s Encrypt or a custom ACME server, and wiring TLS into gateways. Your certificate issuer is now added as a C# expression. It’s early days, but it works.
Also added in 13.4 were a Kubernetes manifest resource API that takes arbitrary manifests, and the AddHelmChart syntax, which installs external Helm charts as pipeline steps. These pieces allow your ingress controller, monitoring stack, and any third-party charts you already have to live alongside the resources that Aspire generates on its own. It makes adding Aspire to existing Kubernetes-based applications possible.
13.4 also added the WithHelm(...) syntax, allowing consolidated chart name, version, description, release name, and namespace to be added into one fluent call. This is a breaking change and replaces the property-based KubernetesEnvironmentResource syntax, in case you tried that in 13.3.
Persistent volumes turn Deployments into StatefulSets
13.5 added persistent volume claims as first-class resources:
#pragma warning disable ASPIRECOMPUTE002
var k8s = builder.AddKubernetesEnvironment("k8s");
var data = k8s.AddPersistentVolume("data")
.WithStorageClass("managed-csi")
.WithCapacity("20Gi")
.WithAccessMode(PersistentVolumeAccessMode.ReadWriteOnce);
builder.AddContainer("postgres", "postgres:16")
.WithVolume("data", "/var/lib/postgresql/data")
.WithPersistentVolume(data);
Note the pragma statement. These are still experimental and generate ASPIRECOMPUTE002 warnings in the compiler. So, you’ll need to suppress that if you want to use them. Also, more importantly, note that any workload bound to a persistent volume will render as a StatefulSet rather than as a Deployment. It’s the correct behavior now, but it’s a change to how workloads are scheduled, so be aware of it.
AKS: Kubernetes without the YAML
Aspire frames AKS as “Kubernetes without the YAML”.
var aks = builder.AddAzureKubernetesEnvironment("aks")
.WithSystemNodePool("Standard_D2s_v5", minCount: 1, maxCount: 3);
builder.AddProject<Projects.Api>("api")
.WithComputeEnvironment(aks);
That generates a Bicep + Helm deployment pipeline from code. WithSystemNodePool handles the VM size and autoscaling. Two things to note: If you don’t specify, the cluster defaults to the Free control-plane SKU, and the AksSkuTier enum is no longer part of the public API.
13.4 added in the Application Gateway for Containers. When you call AddLoadBalancer(), Aspire provisions the AGC ingress profile on AKS, assigns the Network Contributor role to the controller identity, and exposes Gateway API routing for the app. This helps simplify what can be a confusing setup to a single call.
Like native Kubernetes deployment, the AKS hosting integration is still preview and may evolve before it stabilizes.
Azure Container Apps
This is probably the most stable one of the bunch as it’s been supported from the early days of Aspire. That can also make it the most immediately useful. But it has also seen a number of improvements in recent versions of Aspire.
Jobs went stable in 13.4 - PublishAsAzureContainerAppJob and PublishAsScheduledAzureContainerAppJob graduated from experimental to stable in 13.4. They turn any project, container, or executable into a finite-duration job for things like batch processing, scheduled work, and event-driven handlers, with no diagnostic to suppress.
builder.AddProject<Projects.NightlyImport>("import")
.WithReference(db)
.PublishAsScheduledAzureContainerAppJob("0 2 * * *");
You can see more about this in the official docs.
WithAcrPullIdentity
By default Aspire emits a new user-assigned managed identity and an AcrPull role so your container apps can pull images. If your deployment principal can’t create identities or role assignments, you supply an existing one instead. The same API works for App Service environments.
WithUniqueResourceNaming()
Added in 13.5, this gives you deterministic, collision-resistant names when you deploy more than one environment into a resource group. Note that this one is experimental and will generate an ASPIREACANAMING002 warning in the compiler. And enabling it on an already-deployed environment will change the environment’s name, causing Azure to re-create it. Use it for new deployments only.
Virtual Networks
You can declare a subnet with WithServiceDelegation(serviceName) and attach it with WithDelegatedSubnet(subnet). This is also experimental and will emit an ASPIREAZURE003 warning in the compiler.
Tearing it down
aspire destroy is the inverse of aspire deploy, and it uses the same compute environments that you have already declared. One command across every target:
- Azure - resources will be deleted via Azure Resource Manager
- Kubernetes - Helm releases and namespaces are uninstalled
- Docker Compose - published stacks are stopped and removed
The obvious use case ties to preview environments. Deploy on PR open, destroy on PR close. You can stop paying for all those preview environments nobody remembered to clean up.
A few details make this work in a pipeline: --non-interactive mode was improved substantially in 13.3 and more commands support it properly now. And a container runtime health check now runs before aspire deploy, so a broken Docker or Podman setup fails fast instead of halfway through the process.
So what’s really usable right now?
Ready Now - Docker Compose publishing and Azure Container Apps, including jobs, are all fully stable and working well. aspire destroy is a viable tool for the pipelines, as are publish and deploy. They’re all stable, all GA, and “ready for prime time”.
Almost there - Kubernetes and AKS are close, but still both preview. Hold off and let them stew a while longer yet. They’re good, and mostly there, but I wouldn’t put any production apps on them just yet.
Hold off - Persistent volumes, unique ACA naming, subnets, and the Dockerfile builder API are all things that are new and you should definitely hold off on doing anything more than experimenting on them, as they’re likely to change before being ready.
There’s a pattern in those three buckets worth naming: the commands went GA before the configuration surface did. aspire deploy is stable and supported. But a meaningful share of the knobs that a real production deployment actually needs - volumes, resource naming, networking - still emit experimental diagnostics. That’s not a criticism, it’s just how shipping works. But it should shape the order in which you adopt this.
Conclusion
So where does that leave our original objection?
If you’re greenfield, building up internal tools, or standing up preview environments, Aspire can own your deployment today and you’ll be just fine. If you’re already on an established pipeline, with charts and YAML files that you’ve tuned over the years, Aspire generates a great starting point that you can adapt to. It’s still valuable, but it’s not the same value proposition. If you’re in a highly regulated environment where the deployment artifact is an audited object, keep what you’ve got. Nothing here changes the value of that.
What has changed is that Aspire AppHost is no longer just a development convenience. It’s an accurate, executable description of your system’s topology, one that can be a valuable part of your CI/CD pipeline process. And that’s a far cry from the Aspire of a year ago.

