# Aspire for Local Development: Less Setup, Better Visibility

You open a solution, start the API, start the web application, check whether the database container is running, then realise the cache isnt. One service still points at yesterdays port. Another needs an environment variable that only appears in a colleagues launch profile. By the time the application is running, you've already spent ten minutes managing the environment around it and getting everything setup. Aspire can take a lot of that work away. You can describe the applications and dependencies in an AppHost, start them together, and inspect their logs, traces and metrics through the Aspire dashboard.

Then you might look at deployment. Can Aspire provision your infrastructure? Can it publish to your chosen platform? Would adopting it mean changing the existing pipelines? They're all reasonable questions, but local development can justify adopting it on its own. Your organisation might already have Terraform, Bicep, Helm, established release pipelines, or a platform team that owns production infrastructure. You can keep those arrangements and use Aspire just to make the application easier to run and understand on a developer's machine.

This walkthrough targets **.NET 10 and Aspire 13.6.4**, the latest stable Aspire release at the time of writing. The examples use a project based C# AppHost.

## An executable reference for the application

An AppHost is a useful place to describe how the parts of your application fit together. It records which projects run, which dependencies they need, and which services call each other. You might think of it as a reference project for the development environment. Someone joining the team can open its source and see that the web application calls the orders API, and that the API needs PostgreSQL and Redis. Running that same description starts the local environment.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/8464e96e-b0cf-4952-889f-ce62ea9be165.png align="center")

That gives the diagram a useful companion, code that exercises the relationships it describes. If somebody renames a referenced project or changes how a dependency is configured, the local environment has to account for it. It still needs maintenance. An AppHost can drift from production, particularly when deployments are managed elsewhere. Treat it as a maintained description of the supported development setup, and document intentional differences such as emulators, smaller datasets and stubbed third-party APIs. For an existing application, this can be a gradual addition. Reference the services you want to run locally, add the dependencies they actually need, and expand the model as it becomes useful. You dont need to split a modular monolith into separate services to benefit.

## What goes into each project?

There are three different responsibilities to understand before writing the setup code.

| Component | Responsibility |
| --- | --- |
| AppHost | Describes and launches local projects, containers and their relationships. |
| ServiceDefaults | Shared application startup code for OpenTelemetry, health checks, service discovery and HTTP resilience. Each service explicitly opts in. |
| Aspire integrations | Hosting packages describe resources, client packages register and configure the clients used by application code. |

The application projects dont reference the AppHost. The AppHost references the application projects so it can launch them. The services can reference ServiceDefaults and use client integrations independently of the local orchestrator. That makes local adoption practical. Your existing pipeline can continue publishing the web application and API as separate deployable applications. There is no requirement to run the development AppHost beside them in production.

## Build a small .NET 10 example

We'll use two [ASP.NET](http://ASP.NET) Core applications, an orders API and a very small web-facing application that calls it. PostgreSQL and Redis will run in containers. The .NET applications will run as local processes, which keeps the usual debugging experience. You'll need the .NET 10 SDK and a supported container runtime, such as Docker Desktop configured for Linux containers. Start the container runtime before launching the AppHost.

Install the versioned Aspire templates, then create the solution:

```bash
dotnet new install Aspire.ProjectTemplates::13.6.4

dotnet new sln -n LocalShop

dotnet new web -n LocalShop.Api -o src/LocalShop.Api --framework net10.0
dotnet new web -n LocalShop.Web -o src/LocalShop.Web --framework net10.0

dotnet new aspire-apphost -n LocalShop.AppHost -o dev/LocalShop.AppHost
dotnet new aspire-servicedefaults -n LocalShop.ServiceDefaults -o src/LocalShop.ServiceDefaults

dotnet sln LocalShop.slnx add src/LocalShop.Api/LocalShop.Api.csproj
dotnet sln LocalShop.slnx add src/LocalShop.Web/LocalShop.Web.csproj
dotnet sln LocalShop.slnx add src/LocalShop.ServiceDefaults/LocalShop.ServiceDefaults.csproj
dotnet sln LocalShop.slnx add dev/LocalShop.AppHost/LocalShop.AppHost.csproj
```

Keep the AppHost template's generated SDK and launch configuration. Check that the AppHost and ServiceDefaults projects target `net10.0`, set their `TargetFramework` to that value if necessary. The AppHost SDK version should be `13.6.4`. Aspire 13.6 supports the project form `<Project Sdk="Aspire.AppHost.Sdk/13.6.4">`.

Now add the project references:

```bash
dotnet add dev/LocalShop.AppHost/LocalShop.AppHost.csproj reference src/LocalShop.Api/LocalShop.Api.csproj
dotnet add dev/LocalShop.AppHost/LocalShop.AppHost.csproj reference src/LocalShop.Web/LocalShop.Web.csproj

dotnet add src/LocalShop.Api/LocalShop.Api.csproj reference src/LocalShop.ServiceDefaults/LocalShop.ServiceDefaults.csproj
dotnet add src/LocalShop.Web/LocalShop.Web.csproj reference src/LocalShop.ServiceDefaults/LocalShop.ServiceDefaults.csproj
```

For an existing solution, these are references to your existing application projects. You can also initialise Aspire with `aspire init` if you've installed the Aspire CLI. Here we're using explicit template commands so the setup is visible.

Add the hosting packages to the AppHost, and the client packages to the API:

```bash
dotnet add dev/LocalShop.AppHost/LocalShop.AppHost.csproj package Aspire.Hosting.PostgreSQL --version 13.6.4
dotnet add dev/LocalShop.AppHost/LocalShop.AppHost.csproj package Aspire.Hosting.Redis --version 13.6.4

dotnet add src/LocalShop.Api/LocalShop.Api.csproj package Aspire.Npgsql --version 13.6.4
dotnet add src/LocalShop.Api/LocalShop.Api.csproj package Aspire.StackExchange.Redis --version 13.6.4
```

Pin versions through your normal package management process. The template also supplies framework and OpenTelemetry dependencies whose versions have their own release schedules, don't change every dependency to the Aspire version number.

## Put the local environment in the AppHost

Replace the generated AppHost entry point code, normally in `AppHost.cs` or `Program.cs`, with:

```csharp
var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
    .WithDataVolume()
    .WithPgAdmin();

var ordersDb = postgres.AddDatabase("ordersdb");
var cache = builder.AddRedis("cache")
    .WithRedisInsight();

var api = builder.AddProject<Projects.LocalShop_Api>("orders-api")
    .WithReference(ordersDb)
    .WithReference(cache)
    .WithHttpHealthCheck("/health")
    .WaitFor(ordersDb)
    .WaitFor(cache);

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

builder.Build().Run();
```

The generated `Projects.LocalShop_Api` and `Projects.LocalShop_Web` types come from the AppHost's project references. You don't write those classes yourself. There are two operations here that are easy to confuse. `WithReference` supplies connection or service discovery configuration to the consuming application. `WaitFor` controls startup sequencing, using health information where available. Adding a reference doesn't mean the consumer automatically waits until its dependency is healthy. The health check attached to the API lets the web application wait for its readiness endpoint. In our applications, `MapDefaultEndpoints` will expose that endpoint in Development. If you change those defaults or the environment, update the AppHost check accordingly.

PostgreSQL gets a data volume so its data can survive container replacement. Redis is deliberately disposable in this example. The database exists because it is declared as a resource, application tables and migrations still need their own setup.

## Give each service the shared defaults

Add `builder.AddServiceDefaults()` before building each application, and `app.MapDefaultEndpoints()` afterwards.

ServiceDefaults is generated source code in your solution. Read it. You can customise its telemetry, readiness checks, service discovery and HTTP behaviour to fit the application. Referencing an application from the AppHost doesn't automatically instrument all of its code.

Here's a complete `src/LocalShop.Api/Program.cs` for a small dependency check:

```csharp
using Npgsql;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();
builder.AddNpgsqlDataSource("ordersdb");
builder.AddRedisClient("cache");

var app = builder.Build();
app.MapDefaultEndpoints();

app.MapGet("/diagnostics/dependencies", async (
    NpgsqlDataSource database,
    IConnectionMultiplexer redis,
    ILogger<Program> logger,
    CancellationToken cancellationToken) =>
{
    await using var command = database.CreateCommand("select 1");
    var databaseResult = await command.ExecuteScalarAsync(cancellationToken);

    var cache = redis.GetDatabase();
    var cachedValue = await cache.StringGetAsync("localshop:greeting");
    var cacheHit = cachedValue.HasValue;

    if (!cacheHit)
    {
        cachedValue = "Hello from the local development environment";

        await cache.StringSetAsync(
            "localshop:greeting",
            cachedValue,
            TimeSpan.FromMinutes(5));
    }

    logger.LogInformation(
        "Checked dependencies. Database result {DatabaseResult}; cache hit {CacheHit}",
        databaseResult,
        cacheHit);

    return Results.Ok(new
    {
        Database = databaseResult,
        Greeting = cachedValue.ToString(),
        CacheHit = cacheHit
    });
});

app.Run();
```

This endpoint exercises a database command, a cache lookup and a structured log without requiring an application schema. The client integrations register `NpgsqlDataSource` and `IConnectionMultiplexer`, and provide integration specific health and telemetry support. The names `ordersdb` and `cache` match the AppHost resources. In a real orders application, those clients would be used by your normal handlers and services. If you're using EF Core, Aspire also has a PostgreSQL EF Core integration with `AddNpgsqlDbContext<TContext>`. You can keep your existing data access approach.

## Call another service without hard coding its port

Replace `src/LocalShop.Web/Program.cs` with:

```csharp
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddHttpClient<OrdersClient>(client =>
{
    client.BaseAddress = new Uri("https+http://orders-api");
});

var app = builder.Build();
app.MapDefaultEndpoints();
app.MapGet("/", async (
    OrdersClient orders,
    CancellationToken cancellationToken) =>
{
    var result = await orders.CheckDependenciesAsync(cancellationToken);
    return Results.Content(result, "application/json");
});

app.Run();
public sealed class OrdersClient(HttpClient httpClient)
{
    public Task<string> CheckDependenciesAsync(
        CancellationToken cancellationToken) =>
        httpClient.GetStringAsync(
            "/diagnostics/dependencies",
            cancellationToken);
}
```

The `https+http` URI is understood by the service discovery support configured through ServiceDefaults. It allows HTTPS or HTTP endpoint resolution, preferring HTTPS when available. It isn't a URL for a browser address bar. The AppHost supplies the API endpoint information through `.WithReference(api)`. The web application uses the logical resource name `orders-api`, so a changing local port doesn't require editing its source. ServiceDefaults also configures HTTP resilience. Review those settings when applying the pattern to writes: retrying an operation that creates an order needs different consideration from retrying this read only diagnostic request.

## One startup action, one dashboard

Trust the [ASP.NET](http://ASP.NET) Core development certificate if you haven't already:

```bash
dotnet dev-certs https --trust
```

Then launch the AppHost:

```bash
dotnet run --project dev/LocalShop.AppHost/LocalShop.AppHost.csproj
```

In an Aspire capable IDE, set the AppHost as the startup project and use the normal run or debug action. That's the one button experience, the AppHost starts the declared applications and containers, wires their configuration, and provides the dashboard link. The first run can take longer while container images are downloaded. Open the dashboard using the URL printed during startup, including its login token when present. Use the web resource's endpoint to open the application, then refresh it a few times. The first request should report a cache miss, later requests within five minutes should report a hit. In the dashboard, inspect the resources and console output, then open the structured logs and traces for those requests. The metrics views provide another way to inspect the instrumented applications. At that point, you're working with the behaviour of the whole local application. You can follow a request across the web application and API, rather than manually lining up timestamps from separate terminal windows.

## What "network activity in one place" actually means

The dashboard is particularly useful for seeing instrumented calls between services. [ASP.NET](http://ASP.NET) Core instrumentation records incoming requests, HttpClient instrumentation records outgoing requests, and supported dependency integrations contribute their own telemetry.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/97dd73c6-6076-47d4-ba1d-197637979504.png align="center")

The server side operations can appear as related spans in a trace, with timings and associated logs. The browser's own internal activity isn't automatically instrumented by this setup. Redis profiling and other dependency details depend on the client integration and its configuration. This is observability through instrumentation. Arbitrary socket traffic, every request body, and the internal execution of every container aren't automatically visible. For browser payload inspection, use the browser's Network panel. For packet level investigation, use an appropriate network tool.

A PostgreSQL container appearing in the resource list also doesn't mean all its internal activity is available as OpenTelemetry. The database client can report an operation from the application's perspective, while the container's console output provides a separate view. That scope is still extremely useful. If a page takes two seconds, a trace can help establish whether the time was spent in the API, a dependency call, or application work that needs more instrumentation.

## Add spans for the work you care about

HTTP and dependency instrumentation gets you started. Your own domain operations often need additional context. For example, if the API resolves an underwriting submission through several steps. A span around field resolution makes that work visible inside the request trace. Register a custom source in the API before `builder.Build()`:

```csharp
using OpenTelemetry.Trace;
builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddSource("LocalShop.Orders"));
builder.Services.AddSingleton<OrderWorkflow>();
```

Then instrument the operation:

```csharp
using System.Diagnostics;

public sealed class OrderWorkflow(ILogger<OrderWorkflow> logger)
{
    private static readonly ActivitySource Activities = new("LocalShop.Orders");

    public async Task ValidateAsync(
        string orderId,
        CancellationToken cancellationToken)
    {
        using var activity = Activities.StartActivity("orders.validate");
        activity?.SetTag("order.id", orderId);
        logger.LogInformation("Validating order {OrderId}", orderId);

        await Task.Delay(25, cancellationToken);
    }
}
```

Invoke this service from a handler to generate the span. Registering an `ActivitySource` name and creating activities with that same name are both necessary. The generated ServiceDefaults code is a useful place to review how the OpenTelemetry pipeline is assembled. Choose attributes that help explain behaviour. An operation name, result category or document type may be useful, full customer records and document contents usually aren't. Keep metric labels low cardinality as well, individual order IDs belong in selected trace or log context rather than metric dimensions.

## Make the local dependencies easier to work with

The AppHost can do more than start a database in the background. The small conveniences around those dependencies often save the most time.

### Keep database data, and make it inspectable

The example already includes `.WithDataVolume()` and `.WithPgAdmin()`. The volume preserves database files independently of a particular container instance. pgAdmin gives developers a management interface for checking data and queries. Current dashboard versions expose it through the PostgreSQL resource's Manage link. A volume and a persistent container lifetime solve different problems. A volume keeps data, `WithLifetime(ContainerLifetime.Persistent)` allows a container to remain running when the AppHost stops. You can add persistent lifetime to a frequently used database if avoiding container restarts is useful. Neither mechanism guarantees that a changed database image will accept an old data directory. Treat database upgrades and local reset procedures deliberately.

### Inspect the cache

`.WithRedisInsight()` adds a Redis management interface. It's useful when checking key names, expiry behaviour and serialised values. For a cache, keeping data forever can make debugging confusing. Disposable Redis is a sensible starting point. Add persistence when the particular scenario benefits from it, and give the team a documented way to clear stale state.

### Run storage locally with Azurite

If the application uses Azure Blob Storage, an emulator can remove a cloud dependency from many development tasks.

Add the hosting integration:

```bash
dotnet add dev/LocalShop.AppHost/LocalShop.AppHost.csproj package Aspire.Hosting.Azure.Storage --version 13.6.4
```

Then add this before the AppHost's final `Build().Run()` call:

```csharp
var storage = builder.AddAzureStorage("storage")
    .RunAsEmulator();
var blobs = storage.AddBlobs("documents");
api.WithReference(blobs)
    .WaitFor(storage);
```

`RunAsEmulator` configures the local Azurite resource. In the API, the corresponding `Aspire.Azure.Storage.Blobs` client package supplies `builder.AddAzureBlobServiceClient("documents")` and registers a `BlobServiceClient`. The `documents` resource represents access to the blob service. Create the actual application containers through your normal initialisation code. An emulator also has service differences and supported API limits, so keep integration coverage against Azure for behaviour that depends on the real service.

### Give schema changes their own startup step

A database becoming healthy doesn't mean your schema has been migrated. For a larger application, give migrations a dedicated executable project, for example `LocalShop.Migrations`. After adding its AppHost project reference and configuring it to connect to `ordersdb`, replace the API's direct database wait with a migration dependency:

```csharp
var migrations = builder
    .AddProject<Projects.LocalShop_Migrations>("migrations")
    .WithReference(ordersDb)
    .WaitFor(ordersDb);
api.WaitForCompletion(migrations);
```

This is an optional extension requiring a real migration project. Its job is to apply migrations, optionally seed predictable development data, and exit successfully. Don't leave it running as a permanent web service. The ordering becomes explicit: the database is ready, migration finishes, and the API starts. Keep seeding repeatable so restarting the AppHost doesn't create duplicate records.

### Add a local email inbox or a stub API

A local SMTP inbox is useful for viewing generated emails without delivering them. A stub HTTP API can make awkward provider responses repeatable: timeouts, rate limits, invalid payloads and authentication failures. Use a supported Aspire integration where available, or describe a container directly. For a team setup, pin its image version and configure application endpoints through the AppHost. Include those tools in the same startup path so developers don't have to remember a separate script before testing a feature.

## Use shared infrastructure when local containers aren't possible

Some organisations restrict container runtimes. Some dependencies are too large to reproduce locally. Aspire can still describe the applications you control and reference existing services. For example, this is an alternative AppHost configuration for an existing database and Redis instance:

```csharp
var builder = DistributedApplication.CreateBuilder(args);
var database = builder.AddConnectionString("ordersdb");
var cache = builder.AddConnectionString("cache");
var api = builder.AddProject<Projects.LocalShop_Api>("orders-api")
    .WithReference(database)
    .WithReference(cache)
    .WithHttpHealthCheck("/health");

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

builder.Build().Run();
```

Store the connection strings in the AppHost's user secrets:

```bash
dotnet user-secrets init --project dev/LocalShop.AppHost/LocalShop.AppHost.csproj

dotnet user-secrets set "ConnectionStrings:ordersdb" "Host=dev-db;Database=orders;Username=developer;Password=replace-me" --project dev/LocalShop.AppHost/LocalShop.AppHost.csproj

dotnet user-secrets set "ConnectionStrings:cache" "dev-cache:6379,password=replace-me" --project dev/LocalShop.AppHost/LocalShop.AppHost.csproj
```

Those are illustrative values. The services keep the same client registration names, while the AppHost forwards the configured connection strings. You still get coordinated startup and telemetry from the instrumented local applications. Aspire doesn't manage the lifecycle of the remote database or Redis instance. For an existing HTTP service, `AddExternalService` can provide a named endpoint:

```csharp
var pricing = builder.AddExternalService(
    "pricing",
    "https://pricing.dev.example.com");

api.WithReference(pricing);
```

The API can then use service discovery for `https+http://pricing`. This doesn't grant access to the remote service's logs or internals, that requires its own telemetry and permissions.

## Keep deployment decisions explicit

The local AppHost and your production deployment can have different owners. Make the relationship clear in the repository. Application code still needs its production configuration. If the web application uses the logical name `orders-api`, production needs equivalent service discovery configuration or a production specific client base address. Database credentials and telemetry exporters must also be configured by the production environment.

For an HTTPS API endpoint, one service discovery configuration shape is:

```json
{
  "Services": {
    "orders-api": {
      "https": ["https://orders-api.internal.example.com"]
    }
  }
}
```

The equivalent environment variable is `Services__orders-api__https__0`. Your deployment platform may supply configuration through another mechanism, but the application must receive it somewhere. Also review the HTTP resilience and telemetry settings you're sharing through ServiceDefaults. The generated defaults are application code and can affect production behaviour even when the AppHost is used only locally. For a people adopting Aspire gradually, the first useful milestone is quite small, one AppHost that starts the application, the dependencies required for a common workflow, and enough instrumentation to follow a request through it. Once that works, add the local tools that remove recurring friction.

A new developer should be able to install the prerequisites, supply the required secrets, press Run and begin working. If Aspire delivers that while your deployment process stays exactly where it is today, it's already earning its place in the solution.

1. [Aspire 13.6.4 release notes](https://github.com/microsoft/aspire/releases/tag/v13.6.4)
2. [Add Aspire to an existing application](https://aspire.dev/get-started/add-aspire-existing-app/)
3. [C# Service Defaults](https://aspire.dev/get-started/csharp-service-defaults/)
4. [Aspire SDK templates](https://aspire.dev/get-started/aspire-sdk-templates/) and [Aspire.ProjectTemplates 13.6.4](https://www.nuget.org/packages/Aspire.ProjectTemplates/13.6.4)
5. [Aspire SDK](https://aspire.dev/get-started/aspire-sdk/)
6. [Resource builder APIs](https://aspire.dev/reference/api/csharp/aspire.hosting/resourcebuilderextensions/)
7. [PostgreSQL hosting integration](https://aspire.dev/integrations/databases/postgres/postgres-host/)
8. [PostgreSQL client integration](https://aspire.dev/integrations/databases/postgres/postgres-connect/)
9. [Redis client integration](https://aspire.dev/integrations/caching/redis/redis-connect/)
10. [PostgreSQL EF Core integration](https://aspire.dev/integrations/databases/efcore/postgres/postgresql-client/)
11. [Service discovery](https://aspire.dev/fundamentals/service-discovery/)
12. [Aspire dashboard overview](https://aspire.dev/dashboard/overview/)
13. [OpenTelemetry and distributed tracing](https://aspire.dev/fundamentals/telemetry/)
14. [Redis hosting integration](https://aspire.dev/integrations/caching/redis/redis-host/)
15. [Azure Blob Storage hosting integration](https://aspire.dev/integrations/cloud/azure/azure-storage-blobs/azure-storage-blobs-host/)
16. [Existing resource pattern](https://aspire.dev/hub/glossary/existing-resource-pattern/)
17. [Aspire 9.4: external services](https://aspire.dev/whats-new/aspire-9-4/)
