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.

Syntaxif (builder.ExecutionContext.IsPublishMode) db = builder.AddConnectionString("ordersdb"); else db = builder.AddPostgres("postgres").AddDatabase("ordersdb"); // aspire publish -o ./artifacts

The 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

PathHowChoose when
Aspire for development onlyBuild images with dotnet publish /t:PublishContainer; deploy with your own Kubernetes manifests, Helm or TerraformA platform team and pipelines already exist. The most common choice
Generate artifacts, then own themaspire publish with a compute environment integration (Docker Compose, Kubernetes Helm chart, Azure Container Apps) added to the AppHostBootstrapping a new system; review and commit the output, then maintain it like any manifest
Deploy from the modelaspire deploy, or the Azure Developer CLI reading the modelSmall 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 /alive and /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_ENDPOINT at 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

Example · csharp
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.

Example · bash
# 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.2

What 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.

Example · json
{
  "$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.

Example · yaml
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

  • Be the first to comment on this lesson.