The AppHost

Model an API, Postgres, Redis and a migrator in an AppHost with AddPostgres, AddRedis, AddProject, WithReference and WaitFor.

Syntaxvar builder = DistributedApplication.CreateBuilder(args); var db = builder.AddPostgres("postgres").WithDataVolume().AddDatabase("ordersdb"); builder.AddProject("api", "../Api/Api.csproj").WithReference(db).WaitFor(db); builder.Build().Run();

The AppHost is a console project built on the Aspire.AppHost.Sdk. Its Program.cs calls DistributedApplication.CreateBuilder(args), adds resources, and ends with builder.Build().Run(). Every Add* call returns an IResourceBuilder<T> whose fluent methods attach endpoints, environment variables, volumes and relationships. Nothing starts until Run; until then you are building a model.

Resources and names

The name you pass is load-bearing. AddDatabase("ordersdb") becomes the configuration key ConnectionStrings:ordersdb in every project that references it; AddProject("inventory", ...) becomes the service discovery name http://inventory. Names must be unique, and a client that asks for "orders-db" when the AppHost said "ordersdb" fails at start-up. Pick names once and treat them as a contract.

The methods you use daily

CallEffect
AddPostgres("postgres")A Postgres server container; user postgres, generated password, random host port
.AddDatabase("ordersdb")A child resource whose connection string adds Database=ordersdb; the database is created when the server starts
AddRedis("cache")A Redis container with a generated password
AddProject("api", path)Runs a .NET project as a process with endpoints from its launch settings
WithReference(r)Injects ConnectionStrings__name or services__name__scheme__0
WaitFor(r)Delays the start until r is running and its health checks pass
WaitForCompletion(r)Delays the start until r exits with code 0, for one-shot jobs
WithDataVolume()A named volume so data survives container restarts
WithLifetime(ContainerLifetime.Persistent)Keeps the container running after the AppHost stops

WithReference is not WaitFor

WithReference only passes configuration. Without WaitFor the API starts at the same moment as the Postgres container, which needs several seconds to initialise, and start-up work such as cache warming or migrations fails with connection refused. Add WaitFor for every dependency the service touches during start-up; a resource that waits shows as Waiting in the dashboard rather than crashing.

Lifetimes and data

By default containers belong to the session: stopping the AppHost removes them, and without a volume the data goes too. WithDataVolume() keeps the data; ContainerLifetime.Persistent also keeps the container, so the next run starts in a second instead of re-initialising. The trap: Postgres sets its superuser password only when it initialises an empty data directory. Change the password parameter later and the existing volume still has the old one, so every connection fails authentication until you delete the volume.

Parameters

AddParameter("payments-api-key", secret: true) declares an input read from the AppHost's configuration key Parameters:payments-api-key, normally set with dotnet user-secrets. Secret parameters are masked in the dashboard and become required inputs when you publish.

Example

Example · csharp
var builder = DistributedApplication.CreateBuilder(args);

// Set with: dotnet user-secrets set "Parameters:payments-api-key" "<from-secret-store>"
var paymentsApiKey = builder.AddParameter("payments-api-key", secret: true);

var postgres = builder.AddPostgres("postgres")
    .WithDataVolume("orders-pgdata")
    .WithLifetime(ContainerLifetime.Persistent)
    .WithPgAdmin();
var ordersDb = postgres.AddDatabase("ordersdb");

var cache = builder.AddRedis("cache")
    .WithLifetime(ContainerLifetime.Persistent);

// A local SMTP catcher so order confirmation emails never reach real customers.
var mail = builder.AddContainer("mailpit", "axllent/mailpit", "v1.27")
    .WithHttpEndpoint(targetPort: 8025, name: "ui")
    .WithEndpoint(targetPort: 1025, name: "smtp", scheme: "tcp");

// Applies EF Core migrations and exits; the API waits for it to finish successfully.
var migrator = builder.AddProject("migrator", "../Orders.Migrator/Orders.Migrator.csproj")
    .WithReference(ordersDb)
    .WaitFor(ordersDb);

var inventory = builder.AddProject("inventory", "../Inventory.Api/Inventory.Api.csproj")
    .WithReference(cache)
    .WaitFor(cache)
    .WithHttpHealthCheck("/health");

builder.AddProject("api", "../Orders.Api/Orders.Api.csproj")
    .WithReference(ordersDb).WaitFor(ordersDb)
    .WaitForCompletion(migrator)
    .WithReference(cache).WaitFor(cache)
    .WithReference(inventory).WaitFor(inventory)
    .WithEnvironment("Payments__ApiKey", paymentsApiKey)
    .WithEnvironment("Smtp__Endpoint", mail.GetEndpoint("smtp"))
    .WithHttpHealthCheck("/health")
    .WithExternalHttpEndpoints();

builder.Build().Run();

When to use it

  • Start Postgres with a persistent volume and a migrator so the orders API only starts once the schema is current, mirroring the production deploy order.
  • Run a Mailpit container beside the API so order confirmation emails are caught locally and inspected in a browser instead of reaching customers.
  • Inject a sandbox payment provider key from the AppHost's user secrets so no developer ever puts it in appsettings.Development.json.

More examples

The AppHost project file

The SDK brings in <code>Aspire.Hosting.AppHost</code> and the implicit <code>Aspire.Hosting</code> usings. Hosting integrations are plain package references, versioned with the SDK. The project references let the AppHost build the services and generate typed <code>Projects.*</code> classes; the string-path overload of <code>AddProject</code> used in this course does the same job without them. <code>UserSecretsId</code> is where secret parameters and generated passwords live.

Example · xml
<Project Sdk="Aspire.AppHost.Sdk/13.6.3">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>orders-apphost-6f3b1c2e</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.6.3" />
    <PackageReference Include="Aspire.Hosting.Redis" Version="13.6.3" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Orders.Api\Orders.Api.csproj" />
    <ProjectReference Include="..\Inventory.Api\Inventory.Api.csproj" />
    <ProjectReference Include="..\Orders.Migrator\Orders.Migrator.csproj" />
  </ItemGroup>

</Project>

A one-shot migrator the API waits for

Running migrations from the API's start-up code breaks as soon as there are two replicas racing for the same schema. A separate migrator is the same shape you use in production (a Kubernetes Job or init step), so the AppHost rehearses the real deploy order. If the migrator exits non-zero, the API fails to start (FailedToStart in the dashboard), and the migrator's logs show why, which is exactly where you want to look.

Example · csharp
using Microsoft.EntityFrameworkCore;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddDbContext<OrdersDb>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("ordersdb")));

using var host = builder.Build();
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OrdersDb>();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<OrdersDb>>();

var pending = (await db.Database.GetPendingMigrationsAsync()).ToList();
logger.LogInformation("Applying {Count} migrations: {Migrations}", pending.Count, pending);
await db.Database.MigrateAsync();
// Exit code 0 releases every resource that called WaitForCompletion(migrator).

public sealed class Order
{
    public int Id { get; set; }
    public required string CustomerId { get; set; }
    public decimal Total { get; set; }
}

public sealed class OrdersDb(DbContextOptions<OrdersDb> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

AppHost configuration for parameters

<code>AddParameter("payments-mode")</code> reads <code>Parameters:payments-mode</code> from the AppHost's own configuration, so a committed <code>appsettings.json</code> suits non-secret switches and user secrets suit keys. A parameter with no configured value is reported as missing, and the dashboard asks for it before the dependent resource starts, which beats a payments client failing with a 401 halfway through a test checkout. An empty string counts as a value and is passed through as is, so never add a placeholder key for a secret.

Example · json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Aspire.Hosting.Dcp": "Warning"
    }
  },
  // Non-secret parameters can live here and be committed.
  "Parameters": {
    "payments-mode": "sandbox",
    // Secret values belong in user secrets, never in this file, not even as an
    // empty placeholder: dotnet user-secrets set "Parameters:payments-api-key" "<from-secret-store>"
  }
}

Discussion

  • Be the first to comment on this lesson.