Mert Özen Further With Every Line
Backend Distributed Applications with Aspire — #02

Orchestration with the AppHost: Resources, WithReference, WaitFor, Parameters and Persistent Containers

Mert Özen Nov 14, 2026 12 min 13 views
Orchestration with the AppHost: Resources, WithReference, WaitFor, Parameters and Persistent Containers

In the AppHost every part is a resource: a project, a container or an executable; WithReference passes connection strings and connection properties as environment variables, WaitFor waits for health and WaitForCompletion for a job to exit with code 0. The post adds SQL Server, Redis and a migration worker to Bookstore and shows, with real output, the trap of a throwing worker exiting with code 0 and starting the API, secret parameters and persistent containers.

Version note: The code in this post was built and run with .NET 10, Aspire 13.6, Entity Framework Core 10 and Angular 22; the containers are SQL Server 2025, Redis 8.6 and Mailpit 1.33.

Guess First

In this post we run the database migrations in a separate worker process and tie the API to that process finishing with WaitForCompletion(migrations). What happens if the worker's ExecuteAsync method throws an unhandled exception? The worker stops, no doubt about that. But does the API start? How does Aspire tell that the worker failed? The answer is a bug that stays hidden in many projects that copy the migration example from Aspire's documentation.

Bookstore's New AppHost

In 01 we connected the API and the Angular app of Bookstore, the online bookstore used as the shared example across this site's series, to Aspire; the books lived in memory. In this post the books move to SQL Server, list responses go to an output cache in Redis, and a migration worker sets up the schema. Let's look at the whole model first and then go through it line by line:

IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);

IResourceBuilder<ParameterResource> sqlPassword = builder.AddParameter("sql-password", secret: true);

IResourceBuilder<SqlServerServerResource> sql = builder.AddSqlServer("sql", sqlPassword)
    .WithImageTag("2025-latest")
    .WithContainerName("aspire-sql")
    .WithDataVolume("aspire-sql-data")
    .WithLifetime(ContainerLifetime.Persistent);

IResourceBuilder<SqlServerDatabaseResource> bookstoreDb = sql.AddDatabase("bookstoredb", "BookstoreDb");

IResourceBuilder<RedisResource> redis = builder.AddRedis("redis")
    .WithContainerName("aspire-redis");

IResourceBuilder<ProjectResource> migrations = builder.AddProject<Projects.Bookstore_MigrationService>("migrations")
    .WithReference(bookstoreDb)
    .WaitFor(bookstoreDb);

IResourceBuilder<ProjectResource> catalog = builder.AddProject<Projects.Bookstore_Api>("catalog")
    .WithReference(bookstoreDb)
    .WithReference(redis)
    .WaitForCompletion(migrations)
    .WaitFor(redis)
    .WithHttpHealthCheck("/health");

// Catches order e-mails locally; started from the dashboard only when needed
builder.AddContainer("mailpit", "axllent/mailpit", "v1.33")
    .WithContainerName("aspire-mailpit")
    .WithHttpEndpoint(targetPort: 8025, name: "http")
    .WithEndpoint(targetPort: 1025, name: "smtp")
    .WithExplicitStart();

builder.AddJavaScriptApp("web", "../../web/bookstore-web", "start")
    .WithHttpEndpoint(env: "PORT")
    .WithExternalHttpEndpoints()
    .WithReference(catalog)
    .WaitFor(catalog);

builder.Build().Run();

Two hosting integration packages were added to the AppHost: Aspire.Hosting.SqlServer and Aspire.Hosting.Redis. On the service side there are client integrations: the API and the worker use Aspire.Microsoft.EntityFrameworkCore.SqlServer, and the API also uses Aspire.StackExchange.Redis.OutputCaching. What the client integrations add is the topic of 05; here we only look at the AppHost side.

Resource Types: Project, Container and Executable

The model has nine resources, but each falls into one of three basic types; the Type column of aspire describe shows this too.

Project: .NET projects added with AddProject<T>. Aspire starts them with dotnet run --project ... --no-build, gives the port from the launch profile to the proxy and passes settings to the process through environment variables. catalog and migrations are of this type.

Container: images run by Docker (or Podman). AddSqlServer and AddRedis are ready-made methods that produce container resources; the integration knows the image, the port, the password, the health check and the shape of the connection string. For an image without an integration there's AddContainer(name, image, tag). That's how we added Mailpit (a local SMTP server with a web UI): we described the image, the tag and two endpoints (8025 for the UI, 1025 for SMTP) ourselves. WithExplicitStart() keeps it from starting with the AppHost; you start it whenever you want from the dashboard or with aspire resource mailpit start. It's a good option for a container you don't need every day, so it doesn't use memory.

Executable: a resource that runs any program on disk. AddExecutable(name, command, workingDirectory, args) is the general form; AddJavaScriptApp also produces an executable resource behind the scenes (the command is npm, the arguments are run start). The web-installer we saw in 01 is an executable too: it runs npm install and finishes.

sql-password and bookstoredb aren't processes. A parameter carries a value; the database is a child resource of the SQL Server container and has its own connection string. In the dashboard, bookstoredb appears indented under sql.

Endpoints and When You Need a Fixed Port

By default Aspire gives every endpoint a random host port. Redis was on localhost:60601 in one run and on localhost:62075 in the next. Because services read the address from an environment variable, the port changing doesn't break anything. The places that really need a fixed port are few: an address saved in an external tool (WithHostPort(14330) for a connection in SQL Server Management Studio), a redirect URI registered with an identity provider (WithHttpEndpoint(port: 4200, env: "PORT") for Angular), or cookies tied to a port. For persistent containers Aspire writes the port it assigned to user secrets (Resources:sql:tcp:port) and reuses it on the next start; that's why SQL Server was on 29586 in both runs.

WithExternalHttpEndpoints() doesn't change the local run. It says that this endpoint will be public when deployed, while the others stay on the internal network; we'll see what it turns into in the Container Apps and Kubernetes output in 07.

WithReference: What Reaches the Service?

These are the variables the WithReference(bookstoreDb) and WithReference(redis) lines produce in catalog (aspire describe catalog --format Json, passwords hidden):

ConnectionStrings__bookstoredb = Server=127.0.0.1,29586;User ID=sa;Password=<password>;TrustServerCertificate=true;Initial Catalog=BookstoreDb
BOOKSTOREDB_HOST = localhost
BOOKSTOREDB_PORT = 29586
BOOKSTOREDB_USERNAME = sa
BOOKSTOREDB_PASSWORD = <password>
BOOKSTOREDB_DATABASENAME = BookstoreDb
BOOKSTOREDB_URI = mssql://sa:<password>@localhost:29586/BookstoreDb
BOOKSTOREDB_JDBCCONNECTIONSTRING = jdbc:sqlserver://localhost:29586;databaseName=BookstoreDb;trustServerCertificate=true
ConnectionStrings__redis = localhost:62075,password=<password>
REDIS_HOST = localhost
REDIS_PORT = 62075
REDIS_PASSWORD = <password>
REDIS_URI = redis://:<password>@localhost:62075

The first line is the shape .NET expects: the name in builder.AddSqlServerDbContext<BookstoreContext>("bookstoredb") reads ConnectionStrings:bookstoredb. The rest are the connection properties that came with 13.0: host, port, user and password separately, plus URI and JDBC forms. A service outside .NET (Python, Node.js, Java) doesn't need to parse a connection string. The prefix of the variable names comes from the resource name (bookstoredb → BOOKSTOREDB_). The name of the database itself is the second parameter of AddDatabase: the resource is bookstoredb, the database in SQL Server is BookstoreDb.

We gave the migration worker a reference to bookstoredb only; it doesn't see Redis. That's intentional: the WithReference lines are a dependency list that shows which part can reach which.

WaitFor, WaitForCompletion and an Exit Code Trap

WaitFor(x) waits for x to reach the "Running" state and, if it has a health check, to become "Healthy". The SQL Server container becomes "Running" within seconds but takes longer to accept connections; the integration's health check covers that gap. WaitForCompletion(x) waits for x to finish and exit with code 0; that's the right method for short-lived jobs. The waiting leaves a trace in the resource's console log:

[migrations] Waiting for resource 'sql' to enter the 'Running' state.
[migrations] Waiting for resource 'bookstoredb' to enter the 'Running' state.
[migrations] Waiting for resource 'sql' to become healthy.
[migrations] Waiting for resource 'bookstoredb' to become healthy.
[migrations] Finished waiting for resource 'sql'.
[migrations] Finished waiting for resource 'bookstoredb'.

The start times we read from aspire describe --format Json on the first start (empty volume) show the order clearly: the SQL Server container started at 15:19:22.03, the worker started 17 seconds later, at 15:19:39.08 once SQL Server was healthy, and finished at 15:19:40.82; catalog started at 15:19:41.34, and web at 15:19:43.06, after catalog became "Healthy". The order is right. Now let's look at the worker's code. Its first version, following the structure of Aspire's EF Core migration example, looked like this:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using Activity? activity = activitySource.StartActivity("Migrate BookstoreDb");

    await using AsyncServiceScope scope = services.CreateAsyncScope();
    BookstoreContext db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();

    string[] pending = [.. await db.Database.GetPendingMigrationsAsync(stoppingToken)];
    await db.Database.MigrateAsync(stoppingToken);
    logger.LogInformation("Applied {Count} migration(s)", pending.Length);

    int added = await SampleBooks.SeedAsync(db, stoppingToken);
    logger.LogInformation("Seeded {Added} book(s)", added);

    lifetime.StopApplication();
}

To try the question from Guess First, we put a temporary throw new InvalidOperationException("Simulated seed failure") before the seed step. The worker's log is as expected:

[migrations] fail: Microsoft.Extensions.Hosting.Internal.Host[9]
[migrations]       BackgroundService failed
[migrations]       System.InvalidOperationException: Simulated seed failure
[migrations]       The HostOptions.BackgroundServiceExceptionBehavior is configured to StopHost. A BackgroundService has thrown an unhandled exception, and the
IHost instance is stopping.

But in aspire describe, catalog was "Running" and "Healthy". We ran the worker outside Aspire with the same connection string and looked at the exit code:

dotnet run --project src/Bookstore.MigrationService --no-build
echo $?
0

The reason is how the .NET Generic Host behaves: an unhandled exception in a BackgroundService stops the host, but builder.Build().Run() in Program.cs returns normally and the process exits with code 0. Since WaitForCompletion only looks at the exit code, the migration counted as "successful" and the API opened against a half-done database. The fix is to catch the error and set the exit code ourselves:

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    using Activity? activity = activitySource.StartActivity("Migrate BookstoreDb");

    try
    {
        await using AsyncServiceScope scope = services.CreateAsyncScope();
        BookstoreContext db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();

        string[] pending = [.. await db.Database.GetPendingMigrationsAsync(stoppingToken)];
        await db.Database.MigrateAsync(stoppingToken);
        logger.LogInformation("Applied {Count} migration(s)", pending.Length);

        int added = await SampleBooks.SeedAsync(db, stoppingToken);
        logger.LogInformation("Seeded {Added} book(s)", added);
    }
    catch (Exception ex)
    {
        // An unhandled exception in a BackgroundService still ends the process with exit code 0
        activity?.AddException(ex);
        logger.LogError(ex, "Database migration failed");
        Environment.ExitCode = 1;
    }
    finally
    {
        lifetime.StopApplication();
    }
}

When we ran again with the same simulated failure, the process exited with code 1 and catalog never started:

[catalog] Waiting for resource 'migrations' to complete.
[catalog] Resource 'migrations' has entered the 'Finished' state with exit code '1' expected '0'.
[catalog] Failed to create resource catalog

aspire wait catalog --timeout 15
Waiting for resource 'catalog' to be healthy...
❌ Resource 'catalog' entered a failed state: Stopped waiting for resource 'catalog' to become healthy because it failed to start.
echo $?
18

In the dashboard catalog was "FailedToStart" and web stayed "Waiting" because it waits for catalog. aspire wait exiting with code 18 is useful in scripts and CI: one command tells you whether the environment really came up. This trap isn't specific to Aspire; a migration running as an init container or a Job in Kubernetes also counts as successful when its exit code is 0.

Parameters and Secrets

AddParameter("sql-password", secret: true) defines a parameter whose value comes from configuration, not code. Aspire looks for the value under the Parameters:sql-password key: the AppHost's user secrets, appsettings.json or the Parameters__sql-password environment variable. secret: true makes the value masked in the dashboard and in aspire describe. On the first run we hadn't written the value anywhere:

aspire describe

Name           Type                       State          Health
bookstoredb    SqlServerDatabaseResource  Unknown        -
catalog        Project                    Waiting        -
migrations     Project                    Waiting        -
redis          Container                  Running        Healthy
sql            Container                  Unknown        -
sql-password   Parameter                  ValueMissing   -
web            Executable                 Waiting        -

The AppHost didn't fail and exit. Redis, which doesn't depend on the parameter, started; everything that depends on SQL Server waited. An "Unresolved parameters" warning with an "Enter values" button appeared at the top of the dashboard; the button opens a window with an input for sql-password and a "Save to user secrets" option. Once we entered the value, the SQL Server container started and the chain moved on in the order above. To do the same from the CLI:

aspire secret set Parameters:sql-password "<password>"
aspire secret list

Key                                 Value
AppHost:DashboardApiKey             ...
AppHost:OtlpApiKey                  ...
Parameters:redis-password           ...
Parameters:sql-password             ...
Resources:sql:tcp:port              29586

The list also has keys we didn't write. We didn't give Redis a password; the integration generated one and saved it to Parameters:redis-password. Had we not given SQL Server a parameter, it would have done the same for it. We set the password ourselves to manage the admin password that stays in the persistent container's volume on purpose. The user secrets file isn't in the repo but in the user profile (on Windows %APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json); every developer gets a different password and none of them is committed. The general strategy for managing secrets is in the Configuration ve Secret Management series.

Persistent Containers, Volumes and Cleanup

By default a container lives as long as the AppHost (session): aspire stop removes it. Setting up SQL Server from scratch on every start took 17 seconds on the first start. WithLifetime(ContainerLifetime.Persistent) keeps the container alive independently of the AppHost, and WithDataVolume("aspire-sql-data") puts the data on a named volume. After stopping the AppHost:

docker ps --filter name=aspire- --format "{{.Names}} {{.Status}}"
aspire-sql Up 4 minutes

Redis is gone, SQL Server is still there. We started the second run in the background and waited until all resources were ready:

aspire run --detach -lp http
✅ AppHost started successfully.

aspire wait web --timeout 120
✅ Resource 'web' is healthy. (3.7s)

The two commands took 9 seconds in total. This time the worker wrote Applied 0 migration(s) and Seeded 0 book(s): the schema and the books were already on the volume. --detach (or its shortcut aspire start) gives you the terminal back; aspire ps shows running AppHosts, aspire describe the resources, aspire logs catalog the logs, and you can stop and start a single resource with aspire resource redis stop and start. When we stopped Redis this way, catalog's /health returned 503 and /api/books waited 11.5 seconds before returning 200; we'll look at how client integrations behave on failure in 05.

Persistent containers have a cost: if you forget them they use memory until you shut the machine down. The option that came in 13.6 removes them together with their volumes:

aspire stop --force --volumes
✅ src/Bookstore.AppHost/Bookstore.AppHost.csproj stopped successfully.
⚙️ Cleaning up persistent resources for Bookstore.AppHost.csproj...
✅ Persistent resources for Bookstore.AppHost.csproj cleaned up.

Afterwards docker ps -a and docker volume ls showed nothing belonging to this AppHost. --force alone removes the containers and leaves the volumes; --volumes can only be used together with --force. The password and port entries in user secrets remain, and on the next start the container is set up from scratch with the same password.

A Short Note on the TypeScript AppHost

Writing the AppHost in TypeScript arrived as a preview in 13.2 and became GA with 13.4, released on June 1, 2026. The entry file is apphost.mts; aspire init --language typescript can add one to an existing JavaScript project, and the methods build the same model with camelCase counterparts such as addRedis and withReference. Worth considering if most of the team works on the Angular side. The examples in this series are in C#; since the resource model is the same in both languages, what we cover applies to a TypeScript AppHost too.

In Production

The migration worker's exit code is also the basis of the deployment order in production. Aspire's WaitForCompletion, service_completed_successfully in Docker Compose and a Kubernetes Job's completion condition all look at the same signal: 0 means success, anything else means failure. Breaking the worker on purpose once and seeing that the next step doesn't start is the quickest way to be sure the signal works.

Parameters in the AppHost are parameters at deployment time too. aspire publish leaves sql-password as a placeholder in the output and asks for the value at deployment time (07). The value in local user secrets doesn't go to production, and it shouldn't.

Don't mistake a persistent container for a production-style way of storing data. The volume exists to keep development data while the AppHost restarts. Backups, upgrades and password rotation are the production database's job.

Common Pitfalls

Not checking the worker's exit code. As we saw above, an exception in a BackgroundService can end the process with code 0. Set Environment.ExitCode explicitly on the error path.

Using WaitFor for a short-lived job. WaitFor(migrations) starts the API as soon as the worker is "Running"; the migration isn't done yet. For a job that's supposed to finish, use WaitForCompletion.

Putting business logic in the AppHost. Jobs like "calculate discounted books on startup" or "create the admin user" written in the AppHost only run locally, and nobody does them in production. The AppHost should only describe what runs and how it's connected; jobs such as seeding and migrations belong in a separate resource, in code that can also run in production.

Leaving the database in session lifetime without a volume. Every aspire run means an empty SQL Server: the data is gone and you pay the first-time setup cost every time. Sometimes that's what you want (clean test data on every run), but it should be a conscious choice.

Changing a persistent container's password and forgetting the volume. SQL Server writes the admin password to the volume on first start. If you change the parameter, Aspire tries to connect with the new password and you get a login error; changing the password means aspire stop --force --volumes.

A Small Experiment

★☆☆ Start Mailpit with aspire resource mailpit start and look at the addresses of its two endpoints in aspire describe mailpit. Why does the SMTP endpoint's URL start with tcp:// instead of http://?

★★☆ Change WaitForCompletion(migrations) on catalog to WaitFor(migrations), delete the volume (aspire stop --force --volumes) and start again. Compare the start and finish times of catalog and the worker from aspire describe --format Json; what does catalog's first /api/books request return?

★★★ Add a second job to the worker: after the migration, check the book count and fail if it's 0. Set Environment.ExitCode to 2 and find the exit code '2' expected '0' line in catalog's log. If you ran the same worker as a Job in Kubernetes, where would this code show up?

Bookstore's data layer is ready, but there's still a single API and Angular reads its address from CATALOG_HTTP. In the next post we add a second service: Ordering calls Catalog at the address https+http://catalog. We'll see how that address resolves, what's inside ServiceDefaults and how to set up a gateway resource with YARP.