From Aspire to Production
Choose how an Aspire app model reaches production: hand-written manifests, generated artifacts or aspire deploy, and what the platform must still provide.
if (builder.ExecutionContext.IsPublishMode)
db = builder.AddConnectionString("ordersdb");
else
db = builder.AddPostgres("postgres").AddDatabase("ordersdb");
// aspire publish -o ./artifactsThe AppHost knows every service, every dependency and every configuration key, so it is tempting to make it the deployment tool as well. Aspire supports that, but the app model describes a development environment; production needs things the model does not express. Decide deliberately which of three paths you take.
Run mode and publish mode
The same Program.cs runs in two modes. Run mode (aspire run or dotnet run) starts containers and processes. Publish mode (aspire publish) evaluates the model and writes deployment artifacts instead. builder.ExecutionContext.IsPublishMode lets the model differ: a Postgres container locally, AddConnectionString("ordersdb") in production so the deployment asks for the connection string of the managed database instead of shipping a database in a container.
The three paths
| Path | How | Choose when |
|---|---|---|
| Aspire for development only | Build images with dotnet publish /t:PublishContainer; deploy with your own Kubernetes manifests, Helm or Terraform | A platform team and pipelines already exist. The most common choice |
| Generate artifacts, then own them | aspire publish with a compute environment integration (Docker Compose, Kubernetes Helm chart, Azure Container Apps) added to the AppHost | Bootstrapping a new system; review and commit the output, then maintain it like any manifest |
| Deploy from the model | aspire deploy, or the Azure Developer CLI reading the model | Small teams on Azure Container Apps that accept the generated topology |
Older tooling consumes the JSON manifest, produced with dotnet run --project Orders.AppHost -- --publisher manifest --output-path aspire-manifest.json. It lists every resource, its image or project, bindings, environment variables with placeholders such as {ordersdb.connectionString}, and the parameters a deployment must supply. Even on path one it is a useful inventory of required configuration. Publisher and compute environment packages move fast; check the status of the one you choose before a production pipeline depends on it.
What still needs a real platform
- Data. A container with a volume is not a database service: no backups, point-in-time restore, failover or patching. Use a managed Postgres and Redis, and model them as connection strings in publish mode.
- Secrets. Secret parameters become required inputs; something must supply them from a vault or Kubernetes Secrets and rotate them.
- Traffic. TLS certificates, DNS, ingress, WAF and rate limits at the edge.
- Rollouts. Probes on
/aliveand/health, resource requests, autoscaling, preStop delays and a migration Job that runs before new pods. - Telemetry. The dashboard keeps data in memory and has no alerting. Point
OTEL_EXPORTER_OTLP_ENDPOINTat a collector feeding a real backend.
Failure modes
Generated artifacts drift from hand edits, and the next publish overwrites the fix. A published Postgres container quietly becomes the production database. A secret parameter is left empty and the service starts with an empty API key. Health endpoints mapped only in Development make every pod fail its readiness probe. Each one is avoided by reviewing the generated output like code and keeping production configuration explicit.
Example
var builder = DistributedApplication.CreateBuilder(args);
IResourceBuilder<IResourceWithConnectionString> ordersDb;
if (builder.ExecutionContext.IsPublishMode)
{
// Production uses the managed Postgres the platform runs; the connection
// string becomes a required secret input of the deployment.
ordersDb = builder.AddConnectionString("ordersdb");
}
else
{
ordersDb = builder.AddPostgres("postgres")
.WithDataVolume()
.WithLifetime(ContainerLifetime.Persistent)
.AddDatabase("ordersdb");
}
// Locally a container; in published output only a connection string parameter.
var cache = builder.AddRedis("cache")
.PublishAsConnectionString();
var paymentsApiKey = builder.AddParameter("payments-api-key", secret: true);
var migrator = builder.AddProject("migrator", "../Orders.Migrator/Orders.Migrator.csproj")
.WithReference(ordersDb)
.WaitFor(ordersDb);
builder.AddProject("api", "../Orders.Api/Orders.Api.csproj")
.WithReference(ordersDb).WaitFor(ordersDb)
.WaitForCompletion(migrator)
.WithReference(cache).WaitFor(cache)
.WithEnvironment("Payments__ApiKey", paymentsApiKey)
.WithHttpHealthCheck("/health")
.WithExternalHttpEndpoints()
.WithReplicas(builder.ExecutionContext.IsPublishMode ? 3 : 1);
builder.Build().Run();When to use it
- Run Postgres in a container locally but require the managed database's connection string as a secret input when the same AppHost is published.
- Generate a Helm chart with aspire publish to bootstrap a new orders system, then commit it and maintain it as the team's own deployment code.
- Fail a pull request when a new WithReference adds an environment variable that the production Helm values do not provide.
More examples
Publishing and inspecting the model
Even a team that never deploys with Aspire benefits from the manifest: the <code>jq</code> query lists every parameter (<code>ordersdb</code>, <code>cache</code>, <code>payments-api-key</code> here) that the production pipeline must provide, and a CI step can diff that list against the keys in your Helm values so a new <code>WithReference</code> cannot reach production unnoticed. The container image is produced by the SDK, not by Aspire.
# Run mode: containers and processes on this machine, dashboard included.
aspire run --project src/Orders.AppHost
# Publish mode: evaluate the model and write artifacts for the compute
# environment the AppHost declares; review them before committing.
aspire publish --project src/Orders.AppHost --output-path ./artifacts
# The JSON manifest: an inventory of resources, bindings and required inputs.
dotnet run --project src/Orders.AppHost -- --publisher manifest --output-path "$PWD/aspire-manifest.json"
# Which inputs must a deployment supply?
jq -r '.resources | to_entries[] | select(.value.type == "parameter.v0") | .key' aspire-manifest.json
# Images for path one are built without the AppHost.
dotnet publish src/Orders.Api -c Release /t:PublishContainer \
-p ContainerRepository=orders/api -p ContainerImageTags=1.14.2What the manifest says about the API
This trimmed excerpt shows the contract the deployment must honour. <code>AddConnectionString</code> produced a secret <code>parameter.v0</code> resource rather than a container, and the API's <code>env</code> block is exactly the set of variables your Kubernetes manifest or Container App must define. Placeholders in braces are resolved by the deployment tool; a hand-written pipeline resolves them from its own secret store.
{
"$schema": "https://json.schemastore.org/aspire-8.0.json",
"resources": {
"ordersdb": {
"type": "parameter.v0",
"connectionString": "{ordersdb.value}",
"value": "{ordersdb.inputs.value}",
"inputs": {
"value": { "type": "string", "secret": true }
}
},
"api": {
"type": "project.v0",
"path": "../Orders.Api/Orders.Api.csproj",
"env": {
"OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES": "true",
"ConnectionStrings__ordersdb": "{ordersdb.connectionString}",
"ConnectionStrings__cache": "{cache.connectionString}",
"Payments__ApiKey": "{payments-api-key.value}"
},
"bindings": {
"http": { "scheme": "http", "protocol": "tcp", "transport": "http", "external": true },
"https": { "scheme": "https", "protocol": "tcp", "transport": "http", "external": true }
}
}
}
}A CI step that keeps keys in sync
This is path one done well: Aspire stays a development tool, the Helm chart stays the source of truth for production, and the pull request fails when someone adds <code>WithReference(paymentsDb)</code> without adding <code>ConnectionStrings__paymentsdb</code> to the chart. The <code>jq</code> filter keeps only the key prefixes the team owns, because the manifest also carries Aspire-generated variables such as <code>OTEL_DOTNET_EXPERIMENTAL_OTLP_EMIT_EXCEPTION_LOG_ATTRIBUTES</code> and <code>HTTP_PORTS</code> that the chart does not need to copy. It catches the most common Aspire production incident, a key that existed only on developer machines, before it ships.
name: aspire-config-contract
on:
pull_request:
paths:
- "src/Orders.AppHost/**"
- "deploy/helm/orders/**"
jobs:
check-keys:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Generate the manifest
run: dotnet run --project src/Orders.AppHost -- --publisher manifest --output-path "$GITHUB_WORKSPACE/aspire-manifest.json"
- name: Every env var the API needs must exist in the Helm values
run: |
# Only the keys the team owns; Aspire also emits OTEL_* and port variables.
jq -r '.resources.api.env | keys[] | select(startswith("ConnectionStrings__") or startswith("services__") or startswith("Payments__"))' aspire-manifest.json | sort > needed.txt
yq -r '.api.env | keys | .[]' deploy/helm/orders/values.yaml | sort > provided.txt
missing=$(comm -23 needed.txt provided.txt)
if [ -n "$missing" ]; then echo "Missing in Helm values:"; echo "$missing"; exit 1; fi
Discussion