# Set up Aspire for your services in an afternoon

5 min read
Table of Contents

New developer joins your team. You hand them the README with instructions to get the services running locally.

Half a day later, they’re still fighting port conflicts, connection strings that work on everyone else’s machine but not theirs, and services that start but can’t talk to each other. Eventually, someone senior sits down, kills some processes, changes a few config values, and it magically works. Nobody knows why.

Docker Compose helps, until it doesn’t. Shared dev environments work, until someone breaks them and blocks the entire team.

What you actually need

Hit F5. Services start. Database runs. Migrations apply. Everything talks to each other. You can debug.

That’s what Aspire does. Let me show you how to set it up.

Start simple: three services

Create a new Aspire AppHost project. This is your orchestration layer - it wires everything together:

var builder = DistributedApplication.CreateBuilder(args);
var eventApi = builder
.AddProject<EventApiService>("event-api");
var bookingService = builder
.AddProject<BookingService>("booking-service")
.WithReference(eventApi);
var notificationService = builder
.AddProject<NotificationService>("notification-service")
.WithReference(eventApi);
builder.Build().Run();

Three services. Booking and notification both talk to the event API. Run this, and Aspire starts all three services, wires up service discovery, and gives you a dashboard showing what’s running.

That .WithReference(eventApi) is doing the magic. When you reference another service, Aspire injects the service URL as an environment variable and sets up service discovery automatically. No more hardcoded http://localhost:5001 in your config files.

Add SQL Server

Your services need a database. Here’s how you add SQL Server with persistent storage:

var builder = DistributedApplication.CreateBuilder(args);
var sqlPassword = builder.AddParameter("sql-password", secret: true);
var sqlServer = builder
.AddSqlServer("sql-server", password: sqlPassword)
.WithLifetime(ContainerLifetime.Persistent)
.WithHostPort(14333)
.WithVolume("sqlData", "/var/opt/mssql");
var database = sqlServer.AddDatabase("eventdb");
var eventApi = builder
.AddProject<EventApiService>("event-api")
.WithReference(database);
var bookingService = builder
.AddProject<BookingService>("booking-service")
.WithReference(eventApi)
.WithReference(database);
var notificationService = builder
.AddProject<NotificationService>("notification-service")
.WithReference(eventApi)
.WithReference(database);
builder.Build().Run();

Before running this, set your SQL password using dotnet user secrets:

Terminal window
dotnet user-secrets set Parameters:sql-password "YourStrongPassword123!"

The secret: true parameter means Aspire won’t show this password in the dashboard and expects it from user secrets or environment variables.

What’s happening with persistent containers:

Early versions of Aspire didn’t have WithLifetime(ContainerLifetime.Persistent). You’d boot SQL Server from scratch every single time. Wait a minute or two just to start developing.

Now the container survives restarts, and WithVolume("sqlData", "/var/opt/mssql") keeps your data between runs. No more losing your database every time you hit F5.

The connection string magic:

WithReference(database) injects the connection string into each service automatically. In your service code, grab it like this:

var connectionString = builder.Configuration.GetConnectionString("eventdb");

Or with EF Core:

builder.Services.AddDbContext<EventDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("eventdb")));

The connection string name matches what you used in .AddDatabase("eventdb"). That’s it.

Handle migrations properly

Services need the database schema before they can run. Here’s how you make that reliable:

var migrations = builder
.AddProject<SQLMigrator>("sql-migrator")
.WithReference(database)
.WithParentRelationship(sqlServer)
.WaitFor(database);
var eventApi = builder
.AddProject<EventApiService>("event-api")
.WithReference(database)
.WaitFor(migrations);

The migration project is a background worker that runs EF Core migrations and exits:

public class Worker(
IServiceProvider serviceProvider,
IHostApplicationLifetime hostApplicationLifetime) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
await MigrateDatabase<EventDataContext>(cancellationToken);
await MigrateDatabase<BookingDataContext>(cancellationToken);
await MigrateDatabase<NotificationDataContext>(cancellationToken);
hostApplicationLifetime.StopApplication();
}
private async Task MigrateDatabase<TDbContext>(CancellationToken cancellationToken)
where TDbContext : DbContext
{
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TDbContext>();
var strategy = dbContext.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await dbContext.Database.MigrateAsync(cancellationToken);
});
}
}

The migrator project references your data projects (where your DbContexts live). The worker runs migrations for each context and shuts down. Aspire sees it complete and marks it as finished in the dashboard.

Why .WithParentRelationship(sqlServer)? It tells Aspire’s dashboard to nest the migrator under SQL Server visually. Makes it clear the migrator is part of the database setup, not a standalone service.

Why .WaitFor(migrations) on your services? Ensures migrations finish before services start. No more race conditions where your service boots and crashes because tables don’t exist yet.

Make it reliable

Services should indicate when they’re ready. Add health checks:

var eventApi = builder
.AddProject<EventApiService>("event-api")
.WithReference(database)
.WithHttpHealthCheck("/health")
.WithReplicas(2)
.WaitFor(migrations);

WithHttpHealthCheck("/health") - Aspire polls this endpoint. Dashboard shows green when healthy, red when not. No more guessing if the service actually started correctly.

WithReplicas(2) - Runs two instances of the event API. This proves your service discovery works correctly - if booking service had hardcoded URLs instead of using service discovery, calls would only hit one instance and fail unpredictably.

The complete setup

Here’s everything together:

var builder = DistributedApplication.CreateBuilder(args);
var sqlPassword = builder.AddParameter("sql-password", secret: true);
var sqlServer = builder
.AddSqlServer("sql-server", password: sqlPassword)
.WithLifetime(ContainerLifetime.Persistent)
.WithHostPort(14333)
.WithVolume("sqlData", "/var/opt/mssql");
var database = sqlServer.AddDatabase("eventdb");
var migrations = builder
.AddProject<SQLMigrator>("sql-migrator")
.WithReference(database)
.WithParentRelationship(sqlServer)
.WaitFor(database);
var eventApi = builder
.AddProject<EventApiService>("event-api")
.WithHttpHealthCheck("/health")
.WithReference(database)
.WithReplicas(2)
.WaitFor(migrations);
var bookingService = builder
.AddProject<BookingService>("booking-service")
.WithReference(eventApi)
.WithHttpHealthCheck("/health")
.WithReference(database)
.WaitFor(migrations);
var notificationService = builder
.AddProject<NotificationService>("notification-service")
.WithReference(eventApi)
.WithHttpHealthCheck("/health")
.WithReference(database)
.WaitFor(migrations);
builder.Build().Run();

Run this once. Everything starts in the right order. Migrations run. Services boot. Health checks pass. Dashboard shows you everything that’s happening.

New developer onboarding? Clone the repo, run the AppHost project, done. No README with 47 steps. No “did you remember to start the database first?”

ServiceDefaults: the glue that makes it work

This runs everything, but your services need one more piece. Each service calls .AddServiceDefaults() in its startup:

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // This wires up everything
builder.Services.AddControllers();
// ... rest of your service configuration

This one call handles:

  • OpenTelemetry - logs, traces, and metrics flow to the Aspire dashboard
  • Service discovery - your services find each other by name
  • Health checks - the /health endpoint you referenced earlier
  • Resilience - retry policies and circuit breakers for HTTP calls

Without it, your services run but you lose dashboard visibility and service-to-service communication breaks. With it, everything connects.

ServiceDefaults lives in a shared project that all your services reference. Aspire templates create this automatically, or you can set it up yourself.

Does this actually solve the problem?

Aspire isn’t perfect. Some integrations don’t exist yet. The early versions were rough - I fought with it for months before it became usable.

But the setup I showed you? It works. New developers clone the repo and start coding. The “works on my machine” conversations mostly disappeared. Services actually talk to each other locally without the usual pain.

This won’t fit every setup. If your Docker Compose works fine, keep it. But we went from “new devs take half a day to get running” to “clone and go” - that’s worth something.


More Posts

Comments