DEV Community

Cover image for Getting Started with .NET Aspire for Azure Functions
Martin Oehlert
Martin Oehlert

Posted on

Getting Started with .NET Aspire for Azure Functions

.NET Aspire for Azure Functions Developers
Prerequisite: Azure Functions for .NET Developers (Parts 1-9)

  • Part 1: Getting Started with .NET Aspire for Azure Functions (you are here)
  • Part 2: Azure Services as Aspire Resources: Service Bus, Storage, and Redis (coming)
  • Part 3: Deploying .NET Aspire Apps to Azure: AZD, ACA, and What Aspire Generates (coming)

A new developer joins, hits F5, and the Function fails on startup because their local.settings.json names the storage emulator differently from yours. The question isn't "what should they have typed" but "why is the configuration source of truth a per-machine JSON file in the first place." The .NET Aspire AppHost moves that source of truth into a project you check into source control, so the storage emulator, queues, and the Functions app itself all start from one dotnet run.

The drift surface

If you've ever helped a teammate get the ProjectOrganizationDemo sample running, you've seen the surface. Two Function Apps (OrderProcessor.Http and OrderProcessor.Queue) share one core library. Each app ships a local.settings.json.example with two values:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
  }
}
Enter fullscreen mode Exit fullscreen mode

To run the sample end to end you start three processes: Azurite for the emulator, func start on the HTTP app, func start on the queue worker. Each developer rebuilds the same configuration on their machine. The two local.settings.json files are git-ignored, so the source of truth is whatever lives in two text files on every developer's laptop. Multiply by the number of joiners and the time spent on "why isn't my function starting" stops being a one-off.

The [QueueTrigger("orders")] OrderMessage message attribute in ProcessOrderFunction.cs doesn't name a Connection. It falls back to AzureWebJobsStorage, which is the connection string both apps duplicate. Every storage account, queue, and cache the Functions runtime needs has to be present, by name, in the JSON file the developer remembers to keep up to date.

The AppHost isn't here to make local.settings.json shorter. It's here to make it stop being the source of truth.

Why Aspire, and why for Functions

A plain ASP.NET API is one dotnet run. A Functions app isn't. Before any of your business logic executes it needs three things that aren't your code: the func host (the worker is launched behind it, not as a bare executable), an emulator standing in for AzureWebJobsStorage, and every trigger's connection resolved by name out of configuration. For a single web service that overhead barely registers. For the two-worker sample above it's the entire local-dev surface, and it's exactly what .NET Aspire is built to absorb. Aspire is three things at once: an orchestration model (the AppHost), a set of typed integration packages, and a local dashboard for logs and traces. The orchestration is the part that earns its place here.

The honest comparison is Aspire against the two things teams already reach for, not against nothing. The first is a shell of terminals. Today you open three: Azurite in one, func start on the HTTP app in a second, func start on the queue worker in a third, started in that order because the workers need the emulator already listening. Three log streams in three windows, three local.settings.json files feeding them, and no view of a message as it crosses from the HTTP app into the queue worker. One dotnet run on the AppHost replaces all three processes, and because both workers report to the same dashboard you get the thing the three-terminal setup structurally can't produce: a single trace that follows one POST /api/orders through the HTTP app, across the queue, and into the worker that dequeues it.

The second is docker-compose. It can model the same set: an Azurite service, two Function containers, a shared network. What it can't do is stay in .NET. Each Function project needs a Dockerfile and an image rebuild (or a mounted volume) on every change; connection strings live as literal strings in YAML or an .env file, the same drift surface in a different format; service wiring is container DNS, which has nothing to do with the Connection name the Functions runtime actually resolves; and the dashboard, traces, and health checks aren't part of the deal. The AppHost is a .NET project that references your Functions projects directly. The resource graph is C# the compiler checks, the connection a worker reads is computed from a container Aspire owns, and the same description is what later drives deployment. You trade a YAML file the build can't verify for a project it can.

The AppHost as composition root

The Aspire AppHost is a separate .NET project that boots your distributed app. It declares the resources (storage accounts, queues, Service Bus, your Functions projects) and wires them together. One dotnet run on the AppHost starts the whole set.

You don't install a workload. Aspire dropped the dotnet workload install aspire step in 9.0 and hasn't brought it back in 13.x. You install the project templates once:

dotnet new install Aspire.ProjectTemplates
Enter fullscreen mode Exit fullscreen mode

The two templates you need are aspire-apphost (the orchestration project) and aspire-servicedefaults (a class library with the OpenTelemetry and health-check wiring you call from your Functions project). The AppHost does the orchestration; the service-defaults library is the one line of worker code that routes telemetry to the dashboard, and a later section wires it in.

Running dotnet new aspire-apphost -n AspireDemo.AppHost gives you a project file with no Microsoft.NET.Sdk base and the Aspire SDK pinned:

<Project Sdk="Aspire.AppHost.Sdk/13.3.5">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <IsAspireHost>true</IsAspireHost>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Aspire.Hosting.Azure.Functions" Version="13.3.5" />
  </ItemGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

Three notes. First, Aspire's project SDK replaces the .NET SDK base. There's no Microsoft.NET.Sdk row; the Aspire.AppHost.Sdk brings the build targets. Second, the only package reference you need is Aspire.Hosting.Azure.Functions. The AppHost runtime pieces come transitively from the SDK. Third, IsAspireHost is what marks this project for the source generator that produces the strongly-typed Projects.* references you use in the next section. (In the companion sample the committed csproj is shorter still: central package management drops the explicit Version, and a Directory.Build.props supplies the TargetFramework, so what's left is the SDK line, IsAspireHost, and one versionless PackageReference.)

The entry point file is AppHost.cs (the AppHost's entry point, not Program.cs). The generated default is two lines:

var builder = DistributedApplication.CreateBuilder(args);
builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

That's the empty composition. Adding the Functions project is one more line.

The Functions app as an Aspire resource

The API is AddAzureFunctionsProject<TProject>(name). The generic parameter is the strongly-typed project reference (Aspire's source generator produces it once the project is referenced from the AppHost). The string is the name that shows up on the dashboard:

var builder = DistributedApplication.CreateBuilder(args);

builder.AddAzureFunctionsProject<Projects.OrderProcessor_Queue>("orders-queue");

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

Don't use the generic AddProject<T> for a Functions project. The Microsoft Learn integration page is blunt about it: the Functions project "can't start properly" if you do. AddAzureFunctionsProject is the one that knows how to launch the func host instead of treating the project as a vanilla executable.

That one line is enough to run a single Functions project. The call provisions host storage (an Azurite emulator container) automatically and sets the AzureWebJobsStorage environment variable on the worker so existing [QueueTrigger("orders")] attributes resolve without changes.

For a multi-project AppHost (the ProjectOrganizationDemo shape: two Functions projects sharing one storage backend), naming the host storage explicitly is the recommended pattern:

var builder = DistributedApplication.CreateBuilder(args);

var hostStorage = builder.AddAzureStorage("host-storage").RunAsEmulator();

builder.AddAzureFunctionsProject<Projects.OrderProcessor_Http>("orders-http")
    .WithHostStorage(hostStorage);

builder.AddAzureFunctionsProject<Projects.OrderProcessor_Queue>("orders-queue")
    .WithHostStorage(hostStorage);

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

WithHostStorage(...) is GA in Aspire.Hosting.Azure.Functions 13.1+. It tells Aspire to skip the implicit per-project storage and share the resource you pass in. Locally that collapses to one Azurite container; in publish mode, one storage account.

Without WithHostStorage, each AddAzureFunctionsProject<T> call spins up its own implicit emulator. The Functions still run, but the dashboard shows two storage-emulator rows for what should be one logical concern. For one project that's fine; for two or more, share.

How Aspire starts the worker

Aspire never shells out to func start. The AppHost hands every resource to DCP (the Developer Control Plane), the local orchestrator bundled with the Aspire SDK, and DCP is what actually launches processes and pulls containers. For a Functions project it runs, in effect:

dotnet run --project OrderProcessor.Queue --no-build --no-launch-profile --port <dcp-assigned>
Enter fullscreen mode Exit fullscreen mode

Two details that otherwise look like trivia follow directly from that command.

The first is why AddAzureFunctionsProject<T> exists at all. dotnet run on a Functions project produces a console executable, not a running Functions host. AddAzureFunctionsProject<T> is the resource type that knows to boot the isolated worker behind the func host and inject AzureWebJobsStorage; the generic AddProject<T> launches the assembly directly and the worker never starts. That's the concrete reason behind the "can't start properly" warning above.

The second is --no-build. DCP runs the project from whatever is already in its output folder; it doesn't compile first. So wherever dotnet build put the worker DLL is exactly where DCP expects it. If the csproj sets <RuntimeIdentifier> unconditionally (the "Honest scope" section walks through this), the DLL lands a directory deeper than DCP looks and the worker fails on launch. func start papered over that; dotnet run --no-build does not.

Service discovery: two cases

Host storage and user-defined connections behave differently. Host storage is auto-wired. User connections you wire yourself.

Case 1: host storage. AddAzureFunctionsProject<T>(...) injects the env var AzureWebJobsStorage into the worker process, set to the full Azurite connection string for local runs (the literal account-key form, not UseDevelopmentStorage=true). The worker reads that env var directly:

[Function(nameof(ProcessOrder))]
public Task Run(
    [QueueTrigger("orders", Connection = "AzureWebJobsStorage")] string body)
{
    // worker code unchanged from the non-Aspire setup
}
Enter fullscreen mode Exit fullscreen mode

The trigger attribute didn't change. The Connection = "AzureWebJobsStorage" string still names an env var; the AppHost is just the thing setting it now. Trigger attributes that omit Connection entirely also fall back to AzureWebJobsStorage and resolve the same way.

Case 2: user-defined resources. A queue that belongs to your application (not to the host) gets a name you choose, and the trigger has to match:

var ordersStorage = builder.AddAzureStorage("orders-storage").RunAsEmulator();
var ordersQueue = ordersStorage.AddQueues("queues");

builder.AddAzureFunctionsProject<Projects.OrderProcessor_Queue>("orders-queue")
    .WithReference(ordersQueue, "OrdersConnection");
Enter fullscreen mode Exit fullscreen mode

The second argument to WithReference is the literal env var the Functions runtime resolves. The trigger then names that same string:

[Function(nameof(ProcessOrder))]
public Task Run(
    [QueueTrigger("orders", Connection = "OrdersConnection")] string body)
{
    // ...
}
Enter fullscreen mode Exit fullscreen mode

The contract between AppHost and worker is exactly that string. No magic mapping happens; a resource called orders-storage is not auto-injected as OrdersConnection. If the names don't match, the Functions runtime can't find the connection and the trigger fails to start.

Auto-wiring (where the second argument to WithReference is the only thing you need) covers four integrations: Azure Blob Storage, Azure Queue Storage, Azure Event Hubs, Azure Service Bus. For other resources, you wire one env var manually:

var redis = builder.AddRedis("cache");

builder.AddAzureFunctionsProject<Projects.OrderProcessor_Http>("orders-http")
    .WithEnvironment("RedisConnection", redis.Resource.ConnectionStringExpression);
Enter fullscreen mode Exit fullscreen mode

One extra line per resource. Not free, but localised.

What the worker actually receives

The claim that the AppHost is "just the thing setting the env var now" is checkable. Open the dashboard, select the orders-queue row, and look at its environment variables (the Resources page lists them per resource). For the shared-host-storage setup above, AzureWebJobsStorage is set to the full Azurite connection string, account key and all:

AzureWebJobsStorage=DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8.../...;BlobEndpoint=http://127.0.0.1:<port>/devstoreaccount1;QueueEndpoint=http://127.0.0.1:<port>/devstoreaccount1;TableEndpoint=http://127.0.0.1:<port>/devstoreaccount1
Enter fullscreen mode Exit fullscreen mode

Note what it is not: UseDevelopmentStorage=true. Aspire resolves the running Azurite container's mapped ports and writes the literal account-key form. That is the clearest single sign the connection string no longer lives in a file you maintain; the worker reads a value the AppHost computed at startup from a container it owns.

A second injected variable is the one that makes host logs show up:

AzureFunctionsJobHost__telemetryMode=OpenTelemetry
Enter fullscreen mode Exit fullscreen mode

This is the configuration equivalent of setting "telemetryMode": "OpenTelemetry" in host.json, and Aspire sets it on the worker for you. It's why the Functions host's own logs reach the dashboard with no host.json edit.

Aspire also writes a set of hierarchical keys for its typed storage clients (Aspire__Azure__Storage__Queues__AzureWebJobsStorage__ConnectionString and siblings). The default Functions binding extensions ignore them; they matter only if the Functions project adds the matching Aspire.Azure.Storage.* client package. You can leave them unread.

The one change inside the Functions project

Everything so far lived in the AppHost; the worker code was untouched. The dashboard's logs, traces, and metrics are the exception. They need one line in each Functions project's Program.cs:

var builder = FunctionsApplication.CreateBuilder(args);

builder.AddServiceDefaults();

builder.ConfigureFunctionsWebApplication();
builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

AddServiceDefaults() comes from the aspire-servicedefaults project, a small class library you reference from each Functions project. It registers the OpenTelemetry exporter that targets the dashboard, plus health checks and HttpClient resilience. The call has to land before Build(), and the integration doc is specific that it goes on the IHostApplicationBuilder that FunctionsApplication.CreateBuilder returns, not the older HostBuilder().ConfigureFunctionsWorkerDefaults() style.

It's a no-op when you run the project standalone with func start. The OTLP exporter only activates when OTEL_EXPORTER_OTLP_ENDPOINT is set, which the AppHost does and a bare func start does not. So the same code runs both ways: under Aspire it lights up the dashboard, outside it stays quiet.

This is also where the App Insights cleanup belongs. If Program.cs still calls AddApplicationInsightsTelemetryWorkerService(), drop it (the "Honest scope" section explains why) and let AddServiceDefaults own telemetry.

The dashboard

dotnet run --project AspireDemo.AppHost boots everything and prints a line that looks like this:

Login to the dashboard at https://localhost:17281/login?t=<token>
Enter fullscreen mode Exit fullscreen mode

The port is randomised by Properties/launchSettings.json (pin it there if you want a stable URL); the token is regenerated every run and persists as a browser cookie for three days. From Visual Studio or VS Code with the Aspire extension the browser opens automatically; from the CLI you ctrl-click the URL.

The dashboard has four pages worth knowing about for a Functions app:

  • Resources. Every resource you declared (your Functions projects, the host storage emulator, any queues or Service Bus namespaces) shows up as a row. Each row has a state (Running, Starting, Failed), endpoints, and a per-resource log tab.
  • Logs. Structured logs from the worker flow to the dashboard because of the AddServiceDefaults() call from the previous section, combined with the auto-injected AzureFunctionsJobHost__telemetryMode=OpenTelemetry. No host.json edits.
  • Traces. ASP.NET Core, HttpClient, and the Azure SDK Azure.* ActivitySources participate by default, so calls that cross those boundaries link up into one span tree.
  • Metrics. Microsoft.AspNetCore.*, System.Net.Http, and the .NET runtime meters are wired in by AddServiceDefaults. Per-invocation Functions metrics under a Microsoft.Azure.Functions.* meter still require explicit wiring in the worker.

Tracing is the page that pays off the multi-project setup. The sample gives CreateOrderFunction in orders-http a [QueueOutput("orders")] binding and ProcessOrderFunction in orders-queue the matching [QueueTrigger("orders")]. A single POST /api/orders then produces one connected trace that spans both apps: the inbound HTTP server span in orders-http, the Azure Storage Queue send span beneath it, and the queue-trigger span in orders-queue that fires when the message is dequeued. Two processes, one span tree, because the Azure SDK propagates W3C trace context through the queue message. That cross-process link is the thing three separate func start terminals could never show you.

The dashboard isn't a replacement for production Application Insights. It's the same OTLP data your Functions app would send to App Insights in production, except routed to a local UI you see in the first ten seconds of dotnet run.

Before and after

The migration from the original sample to the AppHost is almost entirely additive. You add two projects (the AppHost and the small service-defaults library), share the host storage, and trim the local.settings.json files. The only edit inside the existing Functions projects is the single AddServiceDefaults() line from earlier, plus a reference to the service-defaults project. The DX shift:

Before and after: the developer-experience shift from per-machine local.settings.json to a shared Aspire AppHost

The "untracked config files" row is the one most teams underestimate. The file doesn't disappear; it shrinks to a single setting (FUNCTIONS_WORKER_RUNTIME). Removing it entirely is a future enhancement on the Functions team's roadmap; today's GA story has you keep it minimal but not absent.

Honest scope

A few caveats that don't fit the marketing slide.

Trigger auto-wiring is four integrations. Blob, Queue, Event Hubs, Service Bus. Anything else (Cosmos DB, Redis, SignalR, SQL, custom HTTP services) needs the WithEnvironment("Name", resource.ConnectionStringExpression) form. One extra line per resource. Real, but bounded.

local.settings.json must lose the AzureWebJobsStorage line. Leaving "AzureWebJobsStorage": "UseDevelopmentStorage=true" in the file (the template default) makes the Functions host try to spin up against Azurite directly, while Aspire is also running its own Azurite container. You end up with two emulators and a port conflict. Trim the file to:

{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
  }
}
Enter fullscreen mode Exit fullscreen mode

Remove direct App Insights wiring from Functions. If your Program.cs calls AddApplicationInsightsTelemetryWorkerService(), drop it and rely on AddServiceDefaults (which configures the OTLP exporter the dashboard reads). Microsoft.ApplicationInsights.WorkerService 2.22.0 had a runtime conflict against Aspire that was fixed in 2.23.0; if you can't upgrade, the safer move is to remove the App Insights worker package and route everything through OpenTelemetry.

Unconditional <RuntimeIdentifier> breaks dotnet run --no-build. If your Functions csproj has <RuntimeIdentifier>linux-x64</RuntimeIdentifier> and <PublishReadyToRun>true</PublishReadyToRun> set unconditionally (a common copy-paste from a CI sample), dotnet build puts the worker DLL under bin/Debug/net10.0/linux-x64/ instead of bin/Debug/net10.0/. As the "How Aspire starts the worker" section explained, DCP loads from that path without rebuilding, so on macOS arm64 the worker crashes immediately. Scope both properties to Release:

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  <PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

Publishing as a real functionapp,linux App Service is preview. The default Aspire publish target is Azure Container Apps, which is GA. If your team requires App Service plans (Consumption, Premium, Dedicated), Aspire.Hosting.Azure.AppService is the package and it's still 13.x-preview as of May 2026. Part 3 of this series covers publish targets and the trade-offs.

The local-dev story this article sells (AppHost, dashboard, Azurite + Service Bus emulators, structured logs, traces, the host-storage and user-trigger patterns) is GA in Aspire 13.1+. Nothing in the sections above is preview.

Still copying local.settings.json.example, or moved to an AppHost?

If you've migrated, what's the friction point in the workflow you didn't expect? If you haven't, what's the blocker: the one extra WithEnvironment line per non-auto-wired resource, the publish path you'd need (Container Apps versus App Service), or the trim-not-delete shape of local.settings.json?

The companion sample for this article lives at AspireDemo/ in azure-functions-samples. It reuses the existing ProjectOrganizationDemo projects, adds the AppHost and a service-defaults library above them, and trims both local.settings.json files. The migration is nearly additive: the two Functions projects are project references from the AppHost, and the only worker-code change is one AddServiceDefaults() line each, the line that routes their telemetry to the dashboard.

Part 2 of the series takes the same AppHost and adds Service Bus, additional storage, and a Redis cache as Aspire resources. Part 3 walks through the publish path with azd and Container Apps, and shows what Aspire generates under the hood.

Top comments (0)