30 minute read

41 min read 8384 words

You already have a working solution: a Blazor Web App, a Web API, a Data project with your ApplicationDbContext, and a Models project with your entities. Running it locally means opening a few terminals, starting SQL Server, copying connection strings into appsettings.json, and hoping every port lines up. Aspire fixes that. You press one button (or type one command), and everything starts in the right order with logs, traces, and health in one dashboard.

This guide walks through adding Aspire to that kind of solution, one small step at a time. Each step leaves the solution in a working state, so you can stop at any point and still be better off than before.

In this post you’ll learn:

  • What Aspire is, and what it is not, so you know what you’re signing up for
  • How to add an AppHost and ServiceDefaults to an existing solution
  • How to connect your Blazor app to your API without hardcoded URLs
  • How to give the AppHost a database: SQL Server in a container, or a SQL Server you already have (LocalDB, SQL Express, or a shared dev server) when Docker isn’t allowed at work
  • How to apply EF Core migrations automatically, handle secrets, and troubleshoot common problems
  • What happens to your appsettings.{Environment}.json files, tenant-specific settings files, and Visual Studio launch profiles

This guide targets Aspire 13.6 and .NET 10, the versions current when I wrote it (October 2026). I verified every step on a fresh solution with Aspire CLI 13.6.3 and .NET SDK 10.0.402, with both database options (LocalDB without Docker, and the SQL Server container). Aspire moves quickly, so check the official docs if a command or API looks different for you.


1. What Aspire Is (and What It Isn’t)

Aspire is a set of tools for building, running, and observing multi-project apps. For an existing solution, it comes down to four pieces:

Piece What it is What it does for you
AppHost A small C# project that describes your app as code Starts every project and container in the right order and injects connection strings and URLs
ServiceDefaults A shared class library with one AddServiceDefaults() method Turns on OpenTelemetry, health checks, service discovery, and HTTP resilience in every project
Dashboard A web UI that opens when the AppHost runs Shows every resource with its console logs, structured logs, traces, and metrics in one place
Integrations NuGet packages like Aspire.Hosting.SqlServer Run infrastructure (SQL Server, Redis, and so on) as containers, plus client packages with sensible defaults

What Aspire is not:

  • It is not a framework you rewrite your app into. Your Blazor app and Web API stay normal ASP.NET Core projects. You add about two lines to each Program.cs.
  • It is not required in production. The AppHost is mainly a development-time orchestrator. Deploying with Aspire is optional and something you can explore later.
  • It does not replace Docker Compose for everything. But for a .NET-centric solution, it covers most of what people use Compose for locally, in C# instead of YAML.
  • It does not require Docker. The AppHost, the dashboard, and your projects all run as plain processes. A container runtime is only needed for resources you choose to run as containers, such as SQL Server or Redis. If your workplace doesn’t allow Docker, skip the container and point Aspire at a database you already have (Step 5, Option B).

Here’s the before and after for a typical day:

Task Before Aspire With Aspire
Start the app Start SQL Server, then the API, then Blazor, in separate terminals aspire run (or F5 on the AppHost)
Point Blazor at the API Hardcode https://localhost:7123 in appsettings.json https+http://api, resolved automatically
Connection strings Copy into each project’s appsettings or user secrets Injected by the AppHost
Find why a request failed Search through several console windows Open the trace in the dashboard and follow it from Blazor to API to SQL
Onboard a new developer A page of setup steps Install the prerequisites, clone the repo, and run one command

2. Prerequisites

You need two things installed, and a third that’s optional:

  1. .NET 10 SDK. The AppHost requires it. Your existing projects can keep targeting .NET 8 or later, so you don’t have to upgrade them first.
  2. The Aspire CLI. Installation steps are below.
  3. A container runtime (optional). You only need one if you want the AppHost to run infrastructure such as SQL Server as a container (Step 5, Option A). Docker Desktop is the recommended default. Podman also works (set the ASPIRE_CONTAINER_RUNTIME environment variable to podman).

No Docker at work? That’s common in corporate environments, and it’s fine. Everything in this guide except Step 5, Option A works without a container runtime, and Option B shows how to use a SQL Server you already have (LocalDB, SQL Express, or a shared dev server). The dashboard, service discovery, telemetry, and the migration worker all work the same.

Install the CLI with one of these commands:

# Windows (PowerShell)
irm https://aspire.dev/install.ps1 | iex
# macOS / Linux
curl -sSL https://aspire.dev/install.sh | bash

# Or, on any OS, as a .NET global tool
dotnet tool install -g Aspire.Cli

Then check that it worked:

aspire --version

If nothing prints, close and reopen your terminal so it picks up the updated PATH.

IDE: Visual Studio, VS Code (with the C# and Aspire extensions), and JetBrains Rider all work. You can run the AppHost like any other startup project.


3. Where You’re Starting and Where You’re Going

This guide uses the following names. Replace MyApp with your own solution name.

BEFORE                                 AFTER
MyApp.sln                              MyApp.sln
├── MyApp.Web          (Blazor)        ├── MyApp.AppHost          ← new: orchestrator
├── MyApp.Web.Client   (optional,      ├── MyApp.ServiceDefaults  ← new: shared defaults
│                      WebAssembly)    ├── MyApp.MigrationService ← new (optional): applies migrations
├── MyApp.Api          (Web API)       ├── MyApp.Web
├── MyApp.Data         (DbContext)     ├── MyApp.Web.Client
└── MyApp.Models       (entities)      ├── MyApp.Api
                                       ├── MyApp.Data
                                       └── MyApp.Models

And this is the runtime picture you’re building toward:

                    ┌──────────────────────────────┐
                    │        MyApp.AppHost         │
                    │  (describes and starts all)  │
                    └───────┬──────────┬───────────┘
            starts & wires  │          │  starts & wires
                            ▼          ▼
   ┌────────────┐  https+http://api  ┌────────────┐  ConnectionStrings:appdb  ┌────────────────┐
   │ MyApp.Web  │ ─────────────────▶ │ MyApp.Api  │ ────────────────────────▶ │ SQL Server     │
   │  (Blazor)  │                    │            │                           │ (container)    │
   └────────────┘                    └────────────┘                           └────────────────┘
         │                                 │                                          ▲
         └──────── logs, traces, metrics ──┴──────────▶  Aspire Dashboard             │
                                                                                      │
                                     MyApp.MigrationService ── runs migrations first ─┘

Notice what’s not in the picture: MyApp.Data and MyApp.Models. They are class libraries and stay exactly as they are. Only projects that run (Web, Api, MigrationService) are part of the orchestration.

The SQL Server box is drawn as a container, but it can just as well be a SQL Server that already exists on your machine or network. The AppHost hands the API a connection string either way, and nothing else in the picture changes.

Before you start, create a branch (git checkout -b add-aspire) and make sure the solution builds. Every step below is small, so commit after each one.


4. Step 1: Add the AppHost

The AppHost is the heart of Aspire. It’s a console project whose AppHost.cs lists your resources and how they connect.

From the folder that contains your .sln:

aspire init

When it finds a solution file, aspire init creates a project-based AppHost (a normal .csproj) and an aspire.config.json file that tells the CLI which AppHost to use. The first time you run it, it asks which language the AppHost should use (pick C#) and remembers the answer.

Check the result before moving on. When I ran it with Aspire 13.6.3, it created MyApp.AppHost/ with AppHost.cs, MyApp.AppHost.csproj, and aspire.config.json inside that folder, but it did not add the project to the solution and did not create ServiceDefaults. If that happens to you:

dotnet sln add MyApp.AppHost

It’s also worth having an aspire.config.json at the solution root, so aspire run from that folder always finds the right AppHost (see Troubleshooting for why this matters):

{
  "appHost": {
    "path": "MyApp.AppHost/MyApp.AppHost.csproj"
  }
}

aspire init also installs an “aspireify” skill for AI coding agents. If you use an agent such as Claude Code or Copilot, you can ask it to finish the wiring for you. It’s still worth doing the steps below by hand once, so you understand what it changes.

Option B: Use the project template

If you prefer dotnet new:

dotnet new install Aspire.ProjectTemplates
dotnet new aspire-apphost -n MyApp.AppHost
dotnet sln add MyApp.AppHost

Reference your runnable projects

The AppHost needs a project reference to every project it should start:

dotnet add MyApp.AppHost reference MyApp.Api/MyApp.Api.csproj
dotnet add MyApp.AppHost reference MyApp.Web/MyApp.Web.csproj

Don’t reference MyApp.Data or MyApp.Models from the AppHost. They aren’t processes. MyApp.Api already references them.

Describe your app in AppHost.cs

Open MyApp.AppHost/AppHost.cs and replace its contents with:

var builder = DistributedApplication.CreateBuilder(args);

var api = builder.AddProject<Projects.MyApp_Api>("api");

builder.AddProject<Projects.MyApp_Web>("web")
    .WithExternalHttpEndpoints()
    .WithReference(api)
    .WaitFor(api);

builder.Build().Run();

What each line does:

Code Meaning
Projects.MyApp_Api A class generated from your project reference. Dots in the project name become underscores.
"api" The resource name. It’s used in the dashboard and for service discovery (https+http://api). Keep it short and lowercase.
WithReference(api) Gives the Web project the API’s URLs as configuration (more in Step 4).
WaitFor(api) Doesn’t start Web until the API is running and healthy.
WithExternalHttpEndpoints() Marks Web as public-facing. This doesn’t change local runs, but it matters if you deploy with Aspire later.

Aspire still uses each project’s Properties/launchSettings.json, so your existing ports and profiles keep working.


5. Step 2: Add ServiceDefaults

ServiceDefaults is a small shared project with one extension method, AddServiceDefaults(), that turns on the things every service should have.

If aspire init didn’t already create it:

dotnet new aspire-servicedefaults -n MyApp.ServiceDefaults
dotnet sln add MyApp.ServiceDefaults
dotnet add MyApp.Api reference MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj
dotnet add MyApp.Web reference MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj

Then add two lines to Program.cs in both MyApp.Api and MyApp.Web:

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();          // 1. right after creating the builder

// ... your existing service registrations ...

var app = builder.Build();

app.MapDefaultEndpoints();             // 2. before app.Run()

// ... your existing middleware and endpoints ...

app.Run();

What AddServiceDefaults() gives you:

Feature What it means in practice
OpenTelemetry Logs, traces, and metrics flow to the dashboard. ASP.NET Core and HttpClient calls are traced automatically.
Health checks /health (ready: all checks pass) and /alive (live: only checks tagged live must pass).
Service discovery HttpClient can resolve names like https+http://api.
Resilience Every HttpClient gets retries, timeouts, and a circuit breaker by default.

Here’s the core of the generated Extensions.cs, so it doesn’t feel like magic:

public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder)
    where TBuilder : IHostApplicationBuilder
{
    builder.ConfigureOpenTelemetry();
    builder.AddDefaultHealthChecks();
    builder.Services.AddServiceDiscovery();

    builder.Services.ConfigureHttpClientDefaults(http =>
    {
        http.AddStandardResilienceHandler();   // retries, timeouts, circuit breaker
        http.AddServiceDiscovery();            // resolve "https+http://api"
    });

    return builder;
}

Tips:

  • ServiceDefaults is your code. It’s a template, not a locked package, so edit it freely. Add your own trace sources, filter out noisy endpoints, or add Serilog here so every service logs the same way.
  • Health endpoints are mapped only in Development. The template wraps them in if (app.Environment.IsDevelopment()), because exposing health details publicly has security implications. Leave it that way until you have a reason to change it, and read the guidance first.
  • Keep ServiceDefaults small. Don’t put your models or helpers in it. Those belong in MyApp.Models or another class library.

6. Step 3: Run It and Tour the Dashboard

Make the AppHost your startup project and press F5, or from the terminal:

aspire run

aspire run builds the AppHost, starts every resource, and prints a dashboard URL with a login token (something like https://localhost:17244/login?t=...). Open it. Press Ctrl+C to stop everything.

What to look at in the dashboard:

Page Use it for
Resources Every project and container, its state, endpoints (clickable), and environment variables
Console logs Each resource’s stdout, without juggling terminals
Structured logs Searchable and filterable logs with properties (for example, show all Warning+ logs from api)
Traces One request followed across Web → API → database, with timings
Metrics Request rates, durations, GC, and more, per resource

At this point you already have one-command startup, ordered startup, and full observability. If you stopped here, your development environment would already be better.

Useful CLI commands for later:

aspire run --detach     # run in the background
aspire start            # same idea: start detached, return when it's stable
aspire ps               # list running AppHosts
aspire logs             # read logs from a detached AppHost
aspire stop             # stop a detached AppHost
aspire run --isolated   # random ports + isolated secrets (run two copies side by side)

By default aspire run doesn’t watch files. After changing AppHost.cs, stop and start it again. Changes to your Blazor or API code follow your normal hot reload and rebuild workflow.


7. Step 4: Connect Blazor to the API with Service Discovery

Right now your Blazor app probably has something like this:

// MyApp.Web/appsettings.Development.json  (BEFORE)
{
  "ApiBaseUrl": "https://localhost:7123"
}

With Aspire, the AppHost already gave the Web project the API’s address through WithReference(api). Under the hood, it sets environment variables like services__api__https__0 (for the https endpoint of the resource named api), and service discovery reads them.

So in MyApp.Web/Program.cs, register a typed HttpClient that uses the resource name instead of a URL:

builder.Services.AddHttpClient<TodoApiClient>(client =>
{
    // "https+http" means: prefer https, fall back to http.
    // "api" must match the resource name in AppHost.cs.
    client.BaseAddress = new Uri("https+http://api");
});

And a minimal typed client:

public class TodoApiClient(HttpClient httpClient)
{
    public async Task<TodoItem[]> GetTodosAsync(CancellationToken ct = default) =>
        await httpClient.GetFromJsonAsync<TodoItem[]>("/api/todos", ct) ?? [];
}

TodoItem can come straight from MyApp.Models. Then delete ApiBaseUrl from your settings, or keep it as a fallback (see the tip below).

A note on Interactive WebAssembly (MyApp.Web.Client)

This trips up a lot of people. Code in a Blazor Web App runs in two places:

Where the code runs Can use https+http://api? Why
Server (MyApp.Web: SSR, Interactive Server, prerendering) ✅ Yes It runs in a .NET process that the AppHost started and configured
Browser (MyApp.Web.Client: Interactive WebAssembly) ❌ Not directly It runs in the user’s browser, which doesn’t have the AppHost’s environment variables

If your .Client project calls the API, here are your options, simplest first:

  1. Call the API from the server. Keep API calls in MyApp.Web (Interactive Server components, or services used during prerendering).
  2. Use a backend-for-frontend (BFF). Add small endpoints in MyApp.Web that the WebAssembly code calls on the same origin, and have those endpoints call the API with the service-discovery HttpClient. This also keeps API tokens off the browser.
  3. Look at Aspire.Hosting.Blazor. Aspire has a newer integration (AddBlazorWasmProject, AddBlazorGateway) for hosting WebAssembly apps behind a gateway. It’s a prerelease package whose APIs may change, and its docs focus on standalone WebAssembly apps, so treat it as something to evaluate, not the default.

Tip: keep the project runnable without Aspire. Service discovery also reads normal configuration. If a teammate runs MyApp.Web on its own, you can add a fallback to appsettings.Development.json with the same key shape Aspire uses:

{
  "Services": {
    "api": {
      "https": [ "https://localhost:7123" ]
    }
  }
}

8. Step 5: Give the AppHost a Database

Right now your API still reads a connection string from its own appsettings.json. The goal of this step is to have the AppHost own that connection string and inject it into the API as ConnectionStrings:appdb. There are two ways to get there. Pick the one that fits your environment:

  Option A: SQL Server in a container Option B: A SQL Server you already have
Needs Docker or Podman Yes No
Good for Personal projects, and teams that already use containers Workplaces that don’t allow Docker, or that already run a shared dev SQL Server
Who starts and stops the server The AppHost You, your IT team, or Windows (as a service)
Password Generated and stored in user secrets Windows authentication, or whatever your server uses
What the API receives ConnectionStrings:appdb ConnectionStrings:appdb (identical)

Everything after this step (the EF Core client package, the migration worker, parameters) is the same for both options, because your projects only ever see the injected connection string.

Option A: Run SQL Server in a container

Stop installing SQL Server on every machine and let the AppHost run it in a container. This is the path the official templates assume.

Add the hosting integration to the AppHost (run this from the solution folder):

aspire add sqlserver

That adds the Aspire.Hosting.SqlServer package to the AppHost. The friendly name has no hyphen (sqlserver, not sql-server). If the AppHost is running, aspire add stops it so it can change the project file, so run aspire run again afterwards. Then update AppHost.cs:

var builder = DistributedApplication.CreateBuilder(args);

var sql = builder.AddSqlServer("sql")
    .WithLifetime(ContainerLifetime.Persistent)   // keep the container between runs
    .WithDataVolume();                            // keep the data between runs

var db = sql.AddDatabase("appdb");                // the name is the connection-string key

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(db)
    .WaitFor(db);

builder.AddProject<Projects.MyApp_Web>("web")
    .WithExternalHttpEndpoints()
    .WithReference(api)
    .WaitFor(api);

builder.Build().Run();

What you get:

  • Container image: Aspire pulls mcr.microsoft.com/mssql/server:2022-latest and starts it. The first run takes a while, because the image is large.
  • Generated password: a strong sa password is generated and stored in the AppHost’s user secrets (Parameters:sql-password). No password lives in your repo.
  • Created database: the appdb database is created once the server is ready.
  • Injected connection string: the API receives ConnectionStrings__appdb (which is ConnectionStrings:appdb in .NET configuration).
  • Health check: the API waits until SQL Server actually accepts connections.

Why Persistent and WithDataVolume()? The SQL Server container is slow to start, so a persistent lifetime keeps it running between aspire run sessions. The data volume keeps your data even if the container is recreated. One catch: the password is stored inside the volume, so if you change the password later, you must delete the volume.

Option B: Use a SQL Server you already have (no Docker)

Many workplaces don’t allow Docker Desktop on developer machines. That’s fine. Aspire only needs a container runtime for container resources, so leave AddSqlServer out and point the AppHost at an existing server instead. The AppHost, the dashboard, service discovery, telemetry, and the migration worker all work exactly the same.

You need a SQL Server you can reach from your machine. The usual choices:

Choice Connection string Notes
SQL Server LocalDB Server=(localdb)\MSSQLLocalDB;Database=appdb;Trusted_Connection=True;TrustServerCertificate=True Comes with Visual Studio (the Data storage and processing workload) or the SQL Server Express installer. Per-user, zero admin, Windows only. Run sqllocaldb info to see if you already have it.
SQL Server Express or Developer edition Server=localhost\SQLEXPRESS;Database=appdb;Trusted_Connection=True;TrustServerCertificate=True Free, runs as a Windows service. Developer edition’s default instance is just Server=localhost.
A shared dev server Server=devsql.corp.local;Database=MyApp_dev_<yourname>;Trusted_Connection=True;TrustServerCertificate=True Whatever your team already uses. Give each developer their own database name so migrations don’t collide.
Azure SQL From the Azure portal Works the same way. Keep the password in user secrets (see below).

No aspire add is needed for this option, because AddConnectionString is built into the core Aspire.Hosting package. Update AppHost.cs:

var builder = DistributedApplication.CreateBuilder(args);

var db = builder.AddConnectionString("appdb");    // the name is still the connection-string key

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(db)
    .WaitFor(db);

builder.AddProject<Projects.MyApp_Web>("web")
    .WithExternalHttpEndpoints()
    .WithReference(api)
    .WaitFor(api);

builder.Build().Run();

Then give the AppHost the actual value. It reads it from its own configuration under ConnectionStrings:appdb:

  • Windows authentication (no password in the string): it’s safe to commit, so put it in MyApp.AppHost/appsettings.Development.json. Every developer with LocalDB gets the same value and has nothing to configure. Note the doubled backslash, because this is JSON:

    {
      "ConnectionStrings": {
        "appdb": "Server=(localdb)\\MSSQLLocalDB;Database=appdb;Trusted_Connection=True;TrustServerCertificate=True"
      }
    }
    
  • SQL authentication (has a password): keep it out of the repo with user secrets:

    dotnet user-secrets set "ConnectionStrings:appdb" "Server=devsql.corp.local;Database=MyApp_dev;User Id=<USER>;Password=<PASSWORD>;TrustServerCertificate=True" --project MyApp.AppHost
    

What you get:

  • Injected connection string: the API receives ConnectionStrings__appdb, exactly as with Option A. The API code doesn’t know or care which option you picked.
  • A resource in the dashboard: appdb appears on the Resources page as a connection string resource, so you can confirm what was injected.
  • No image pull and no slow first start. The server is already running.
  • The database is created on first migration. EF Core’s MigrateAsync() creates appdb if it doesn’t exist, so an empty LocalDB instance is enough. Step 7 covers this.

What you give up: Aspire doesn’t start, stop, or health-check a server it doesn’t own. If the server is down, you’ll find out from the API’s EF Core health check (/health) or the migration worker’s console logs instead of from the appdb resource itself.

Supporting both on one team

If some teammates have Docker and others don’t, let a configuration switch decide. Both branches produce a resource with a connection string, so nothing else in the AppHost changes:

bool.TryParse(builder.Configuration["UseSqlContainer"], out var useContainer);   // false when the key is missing

IResourceBuilder<IResourceWithConnectionString> db = useContainer
    ? builder.AddSqlServer("sql")
             .WithLifetime(ContainerLifetime.Persistent)
             .WithDataVolume()
             .AddDatabase("appdb")
    : builder.AddConnectionString("appdb");

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(db)
    .WaitFor(db);

Developers who want the container set UseSqlContainer to true in their AppHost user secrets (dotnet user-secrets set "UseSqlContainer" "true" --project MyApp.AppHost). Everyone else gets Option B by default. The Aspire.Hosting.SqlServer package still has to be referenced by the AppHost for the code to compile, but the package itself doesn’t need Docker. Only a running container does.


9. Step 6: Wire ApplicationDbContext to the Database

Your ApplicationDbContext lives in MyApp.Data, but it’s registered in MyApp.Api. So the Aspire client package goes into the API project:

dotnet add MyApp.Api package Aspire.Microsoft.EntityFrameworkCore.SqlServer

You have two ways to register the context. Pick one.

Option A: Let Aspire register it (simplest)

// MyApp.Api/Program.cs
builder.AddSqlServerDbContext<ApplicationDbContext>("appdb");

The name "appdb" must match the resource name in the AppHost: AddDatabase("appdb") if you chose Step 5, Option A, or AddConnectionString("appdb") if you chose Step 5, Option B. On top of registering the context, this adds:

  • Retries: transient-failure retries (6 attempts by default).
  • Health check: a database health check (it calls CanConnectAsync) that feeds /health.
  • Telemetry: OpenTelemetry tracing and metrics for EF Core, so every SQL query appears in the dashboard traces.

Option B: Keep your existing registration and enrich it

If you already have an AddDbContext call with custom options (interceptors, a migrations assembly, special SQL options), keep it and add Aspire’s extras on top:

// MyApp.Api/Program.cs
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("appdb")
            ?? throw new InvalidOperationException("Connection string 'appdb' not found."),
        sql => sql.MigrationsAssembly("MyApp.Data")));

builder.EnrichSqlServerDbContext<ApplicationDbContext>();

Which should I choose? If your current registration is a plain UseSqlServer(...), use Option A. If it has custom configuration you want to keep, use Option B. Option B is also the gentler change for an existing codebase, because it doesn’t touch your current setup.

Does your Blazor app use the DbContext too? Blazor Web App templates with Individual Accounts use EF Core for ASP.NET Core Identity. If MyApp.Web also uses ApplicationDbContext, add .WithReference(db).WaitFor(db) to the web resource as well, and register the context in MyApp.Web the same way.


10. Step 7: Apply EF Core Migrations Automatically

A fresh SQL Server container, or an empty LocalDB instance, has no schema yet. You need your migrations applied before the API starts. Here are three ways, from simplest to most automated:

Approach How Good for
A. Manual Copy the connection string from the dashboard (Resources → api → environment variables) and run dotnet ef database update --connection "<connection string>" Getting started, small projects
B. Migration worker (official pattern) A small worker project that runs MigrateAsync(), and the API waits for it to finish Most teams. Automatic, explicit, and easy to debug
C. Aspire.Hosting.EntityFrameworkCore AddEFMigrations(...) with RunDatabaseUpdateOnStart() in the AppHost, plus dashboard commands such as Update Database Trying the newest tooling. Check the package’s current status first, because its earlier releases were previews

I recommend B. Here’s how.

Create the worker

dotnet new worker -n MyApp.MigrationService
dotnet sln add MyApp.MigrationService
dotnet add MyApp.MigrationService reference MyApp.Data/MyApp.Data.csproj
dotnet add MyApp.MigrationService reference MyApp.ServiceDefaults/MyApp.ServiceDefaults.csproj
dotnet add MyApp.MigrationService package Aspire.Microsoft.EntityFrameworkCore.SqlServer
dotnet add MyApp.AppHost reference MyApp.MigrationService/MyApp.MigrationService.csproj

MyApp.MigrationService/Program.cs:

using MyApp.Data;
using MyApp.MigrationService;

var builder = Host.CreateApplicationBuilder(args);

builder.AddServiceDefaults();
builder.Services.AddHostedService<Worker>();
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddSource(Worker.ActivitySourceName));

builder.AddSqlServerDbContext<ApplicationDbContext>("appdb");

builder.Build().Run();

MyApp.MigrationService/Worker.cs:

using System.Diagnostics;
using Microsoft.EntityFrameworkCore;
using MyApp.Data;

namespace MyApp.MigrationService;

public class Worker(
    IServiceProvider serviceProvider,
    IHostApplicationLifetime hostApplicationLifetime) : BackgroundService
{
    public const string ActivitySourceName = "Migrations";
    private static readonly ActivitySource s_activitySource = new(ActivitySourceName);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var activity = s_activitySource.StartActivity("Migrating database", ActivityKind.Client);

        try
        {
            using var scope = serviceProvider.CreateScope();
            var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

            // Run inside the execution strategy so transient SQL errors are retried.
            var strategy = dbContext.Database.CreateExecutionStrategy();
            await strategy.ExecuteAsync(() => dbContext.Database.MigrateAsync(stoppingToken));
        }
        catch (Exception ex)
        {
            activity?.AddException(ex);
            throw;
        }

        // Done: stop the worker so the AppHost sees it as "finished".
        hostApplicationLifetime.StopApplication();
    }
}

Wire it in the AppHost

var migrations = builder.AddProject<Projects.MyApp_MigrationService>("migrations")
    .WithReference(db)
    .WaitFor(db);

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(db)
    .WaitFor(db)
    .WaitForCompletion(migrations);   // don't start the API until migrations have finished

Now every aspire run starts SQL Server (Option A), applies any pending migrations, and only then starts the API. The migration also shows up as a span in the dashboard’s Traces page.

This works unchanged with Option B. The worker receives the same ConnectionStrings:appdb, and MigrateAsync() creates the appdb database on LocalDB or your dev server if it doesn’t exist yet, then applies the migrations. The only difference is that nobody has to start SQL Server first, because it’s already running.

Creating new migrations (dotnet ef migrations add)

Adding a migration doesn’t connect to a database, but the EF tools still need to build your ApplicationDbContext. Outside the AppHost, there’s no injected connection string. The most reliable fix is a design-time factory in MyApp.Data (add the Microsoft.EntityFrameworkCore.Design package to MyApp.Data if it isn’t there):

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace MyApp.Data;

// Used only by the dotnet-ef tools at design time. Never at runtime.
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
    public ApplicationDbContext CreateDbContext(string[] args)
    {
        var options = new DbContextOptionsBuilder<ApplicationDbContext>()
            .UseSqlServer("Server=localhost;Database=DesignTimeOnly;Trusted_Connection=True;TrustServerCertificate=True")
            .Options;

        return new ApplicationDbContext(options);
    }
}

The connection string here is a placeholder: migrations add never opens it. Then:

dotnet ef migrations add AddPriorityToTodo --project MyApp.Data

Then run aspire run, and the worker applies the new migration.


11. Step 8: Secrets and Parameters

Your API probably needs other secrets too, such as an email API key or a payment key. Instead of putting them in every project’s user secrets, declare them once in the AppHost as parameters:

var emailApiKey = builder.AddParameter("email-api-key", secret: true);

var api = builder.AddProject<Projects.MyApp_Api>("api")
    .WithReference(db)
    .WaitFor(db)
    .WithEnvironment("Email__ApiKey", emailApiKey);   // becomes Email:ApiKey in the API's config

Where the value comes from:

  • Local: the AppHost’s configuration under Parameters, ideally its user secrets:
    dotnet user-secrets set "Parameters:email-api-key" "<YOUR_API_KEY>" --project MyApp.AppHost
    
  • CI: an environment variable Parameters__email-api-key.
  • Missing value: the dashboard shows Unresolved parameters with an Enter values button, and can save what you type to user secrets. That’s very convenient for a new teammate.

Your API code doesn’t change. It still reads builder.Configuration["Email:ApiKey"] or binds an options class.


12. Configuration: Environments, Tenant Files, and Launch Profiles

Most existing solutions have more than appsettings.json and appsettings.Development.json. This section covers what Aspire changes about configuration and what it leaves alone. I verified everything here on the test solution, except where I say otherwise.

How Aspire’s values reach your projects

The AppHost passes everything (connection strings, service discovery URLs, parameters) to your projects as environment variables. In the default .NET configuration stack, environment variables outrank every JSON file:

Source Priority
appsettings.json lowest
appsettings.{Environment}.json  
User secrets (only when the environment is Development)  
Environment variables (this is where Aspire’s values land)  
Command-line arguments highest

Two consequences:

  • Only the keys the AppHost sets are overridden: ConnectionStrings:appdb, the services__api__* entries behind service discovery, and anything you pass with WithEnvironment. Everything else in your JSON files is untouched.
  • A connection string in your project’s own appsettings.Development.json is ignored under the AppHost, but still works as a fallback when you run that project on its own. In my test the API’s file pointed at one database and the AppHost’s value won every time.

appsettings.Development.json, appsettings.Staging.json, appsettings.Production.json

Nothing changes. The AppHost starts each project through its launchSettings.json profile, which sets ASPNETCORE_ENVIRONMENT=Development, so projects load appsettings.json plus appsettings.Development.json exactly as before. The Staging and Production files aren’t read, and Aspire never edits them.

In Staging and Production there is no AppHost process. Your projects read their appsettings.{Environment}.json plus whatever the host supplies (App Service settings, Container Apps secrets, Key Vault). Your code reads the same keys either way, so it doesn’t change. If you later deploy with aspire publish, the generated manifests use the same environment variable names, so that path lines up with your JSON files too.

If you want to run the AppHost against Staging-like settings, you have two ordinary options. Add .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Staging") to a project resource and it loads appsettings.Staging.json (the AppHost’s injected values still win). Or, since the AppHost is a normal .NET host, give it its own appsettings.Staging.json with a different ConnectionStrings:appdb and launch it with DOTNET_ENVIRONMENT=Staging. Be careful with the second one: it points your machine at a staging database.

Tenant-specific files like appsettings.MHP.Development.json

A common pattern in line-of-business apps: appsettings.MHP.Development.json, appsettings.SUD.Development.json, and a Visual Studio launch profile per tenant that sets ASPNETCORE_ENVIRONMENT to MHP.Development or SUD.Development. This keeps working, because the AppHost uses those same profiles. You choose the profile in the AppHost:

var tenant = builder.Configuration["Tenant"] ?? "MHP";

var api = builder.AddProject<Projects.MyApp_Api>("api", launchProfileName: tenant)
    .WithReference(db)
    .WaitFor(db);

What I verified with Tenant=SUD aspire run:

  • The API started with the SUD profile: its environment was SUD.Development, it loaded appsettings.SUD.Development.json, and it used that profile’s applicationUrl ports.
  • The ConnectionStrings:appdb inside the tenant file was ignored. The AppHost’s value won, as described above.
  • If you omit launchProfileName, Aspire uses the first Project profile in launchSettings.json, so the order of profiles in that file matters.

Where the “pick a profile” step moves to. You no longer choose MHP or SUD on the API and Web projects. You choose it once on the AppHost, and pass the same tenant value to every AddProject call that has tenant profiles, so they all switch together. To keep the Visual Studio dropdown feel, give MyApp.AppHost/Properties/launchSettings.json an MHP profile and a SUD profile that each set a Tenant environment variable. From the terminal it’s $env:Tenant="SUD"; aspire run in PowerShell, or Tenant=SUD aspire run in bash.

Tenants with different databases. Because the AppHost’s connection string overrides the tenant file, the switch has to live in the AppHost too. The simplest way is to let the AppHost load its own tenant file before it reads the connection string (this is standard configuration code, not something I ran in the test):

var tenant = builder.Configuration["Tenant"] ?? "MHP";
builder.Configuration.AddJsonFile($"appsettings.{tenant}.json", optional: true);   // MyApp.AppHost/appsettings.MHP.json holds ConnectionStrings:appdb for MHP

var db = builder.AddConnectionString("appdb");

The IsDevelopment() gotcha

With an environment named SUD.Development, IsDevelopment() returns false. In my test /health returned 404, because ServiceDefaults maps /health and /alive only inside if (app.Environment.IsDevelopment()). The same check guards the developer exception page, MapOpenApi(), and user-secrets loading in the default templates. The AppHost itself doesn’t care (WaitFor doesn’t use that endpoint, and everything still started), but if you want health endpoints for tenant environments, change the check in ServiceDefaults/Extensions.cs to something like app.Environment.EnvironmentName.EndsWith("Development").

A cleaner alternative

Keep ASPNETCORE_ENVIRONMENT=Development everywhere and treat the tenant as a separate setting. In each project’s Program.cs:

var tenant = builder.Configuration["Tenant"];
builder.Configuration.AddJsonFile($"appsettings.{tenant}.{builder.Environment.EnvironmentName}.json", optional: true);
builder.Configuration.AddEnvironmentVariables();   // re-add, so Aspire's injected values still win over the tenant file

Then the AppHost passes .WithEnvironment("Tenant", tenant) to each project. IsDevelopment(), user secrets, and the health endpoints keep working, and appsettings.MHP.Development.json still loads under its existing name. The second AddEnvironmentVariables() matters: a JSON file added after the defaults would otherwise sit above the environment variables and override the AppHost’s connection string. This is a refactor of your existing projects, so the launch-profile approach above is the one that changes nothing in them.


13. Everyday Tips

  • Commit the AppHost, ServiceDefaults, MigrationService, and aspire.config.json. They’re part of your dev environment, so the whole team gets the same setup.
  • Never commit secrets. Parameters and generated passwords live in user secrets, outside the repo.
  • Use resource names consistently. api, web, sql, appdb. The connection-string key and the service-discovery host both come from these names, and a typo here causes most “not found” errors.
  • Keep projects runnable on their own with fallback values in appsettings.Development.json. It helps when you need to debug one project in isolation.
  • Add one resource at a time. Get the dashboard working first, then service discovery, then SQL Server, then migrations. Each step is easy to verify on its own.
  • Want Redis, RabbitMQ, or a local email server next? Same pattern: aspire add <integration>, one Add... line in the AppHost, and WithReference on the consumer.
  • Test against the real thing. The Aspire.Hosting.Testing package lets an integration test start your whole AppHost (including SQL Server) with DistributedApplicationTestingBuilder.CreateAsync<Projects.MyApp_AppHost>(). See the testing docs linked below.

14. Troubleshooting

Symptom Likely cause Fix
SQL Server resource stuck or failing to start (Option A) Docker isn’t running Start Docker Desktop (or Podman), then run again
Container resource fails and you can’t install Docker The AppHost still calls AddSqlServer Switch to AddConnectionString (Step 5, Option B), or use the UseSqlContainer switch so it defaults to off
A network-related or instance-specific error with (localdb)\MSSQLLocalDB (Option B) LocalDB isn’t installed, or the instance is stopped Run sqllocaldb info, then sqllocaldb start MSSQLLocalDB. If sqllocaldb isn’t found, install LocalDB from the SQL Server Express installer or the Visual Studio installer
Login failed for user against a shared dev server (Option B) Wrong credentials, or the string in the AppHost’s config isn’t being picked up Check dotnet user-secrets list --project MyApp.AppHost, and confirm the key is ConnectionStrings:appdb
Connection string 'appdb' not found Name mismatch, or the project is missing WithReference(db) Make AddDatabase("appdb"), AddSqlServerDbContext(..., "appdb"), and GetConnectionString("appdb") all match
Blazor gets No such host / can’t resolve https+http://api Running Web outside the AppHost, the resource name doesn’t match, or the call runs in WebAssembly Run through the AppHost, check the resource name, and see the WebAssembly section
Login failures after changing the SQL password (Option A) The old password is stored in the data volume Delete the volume (Docker Desktop → Volumes) and run again
The first run is very slow Pulling the SQL Server image Wait it out once; the persistent lifetime makes later runs fast
HTTPS certificate warnings The development certificate isn’t trusted Run dotnet dev-certs https --trust
API never starts, waits forever The migration worker failed Open the migrations resource’s console logs in the dashboard; the exception is there
Port already in use Another copy is running, or launchSettings.json uses a busy port aspire ps / aspire stop, or aspire run --isolated
aspire run starts a different AppHost, even one from another repo A stale appHostPath in a parent folder’s .aspire/settings.json (for example in your user profile folder) wins over the AppHost it should find Run aspire config list to see where the value comes from. Add an aspire.config.json at your solution root (Step 1), or remove the stale key from that settings file
aspire add says no exact match for the integration name Friendly names have no hyphen Use aspire add sqlserver, aspire add redis, and so on
/health returns 404 under the AppHost The environment name isn’t Development (for example MHP.Development), so ServiceDefaults doesn’t map it Change the IsDevelopment() check in ServiceDefaults/Extensions.cs, or use a Tenant setting instead of a custom environment name (section 12)
A setting from appsettings.*.json is ignored under the AppHost The AppHost sets the same key as an environment variable, which outranks JSON Expected for ConnectionStrings:appdb, service discovery entries, and WithEnvironment values. Move the per-tenant or per-environment value into the AppHost (section 12)

15. Recap

  • AppHost describes your solution as code. Only runnable projects (Web, Api, workers) go in it. Data and Models stay plain class libraries.
  • ServiceDefaults gives every service telemetry, health checks, service discovery, and resilience with two lines in Program.cs.
  • WithReference + WaitFor replace hardcoded URLs, copied connection strings, and “start this first” instructions.
  • The AppHost owns the database connection. Either it runs SQL Server in a container with a generated password (Option A), or it hands out a connection string for a server you already have, with no Docker involved (Option B). Your ApplicationDbContext connects by name (appdb) either way.
  • A migration worker with WaitForCompletion makes sure the database schema is ready before the API starts.
  • The WebAssembly caveat: code running in the browser can’t use service discovery directly, so call the API from the server or through a BFF endpoint.
  • Your appsettings files keep working. Aspire’s values arrive as environment variables and override only the keys it sets. Tenant launch profiles are chosen with launchProfileName, and a custom environment name such as MHP.Development turns IsDevelopment() off.

If you want to see Aspire in a real project, I wrote about how I used it in my Personal Finance app in Part 4: .NET Aspire and Observability, including a few mistakes I made along the way.


16. References and Further Reading

Getting started

Core concepts

CLI reference

Databases and EF Core

Blazor

Testing and deployment (next steps)

Source and samples

Questions, or stuck on a step? Leave a comment below and I’ll do my best to help.

Leave a comment