NashTech Blog

Aspire from Zero to Production: Building Your First Distributed App with Next.js, Docker, and .NET 10

Table of Contents
Table Of Contents
  1. 1. Before Aspire: the pain nobody talks about
  2. 2. Why Microsoft built Aspire — the short version
  3. 3. What is Aspire, actually?
  4. 4. Aspire vs Docker Compose — the question everyone asks first
  5. 5. Core concepts before you write any code
  6. 6. Why use Aspire — and where it falls short
  7. 7. When should you actually use Aspire?
  8. 8. Prerequisites
  9. 9. The architecture we're building
  10. 10. Scaffold the solution
  11. 11. Add PostgreSQL and Redis (running in Docker, orchestrated by Aspire)
  12. 12. Wire up the API project to Postgres and Redis (with EF Core)
  13. 13. Add the Next.js frontend as an Aspire-managed resource
  14. 14. Run everything — and take the full Dashboard tour
  15. 15. Debugging inside VS Code
  16. 16. Production Dockerfiles
  17. 17. Production architecture: the AppHost does not run in production
  18. 18. Deploying: Azure Container Apps (and cost-conscious alternatives)
  19. 19. Configuration and secrets: how Aspire merges everything
  20. 20. Testing your Aspire application
  21. 21. Beyond Postgres and Redis: the integration catalog
  22. 22. Aspire + AI: a quick look
  23. 23. Organizing large AppHosts
  24. 24. Practical tips for real projects
  25. 25. Troubleshooting checklist
  26. 26. Where to go from here
  27. 27. Source code & further reading

A hands-on guide for developers who want to go from “why does this exist?” to a running, multi-service, observable app on their own machine — and understand what changes when it ships.

Stack used in this guide

  • OS: Windows 11
  • IDE: Visual Studio Code
  • .NET SDK: .NET 10
  • Frontend: Next.js (running on Node.js v26.2.0)
  • Backend: ASP.NET Core Minimal API
  • Infrastructure: PostgreSQL + Redis, both running as Docker containers via Docker Desktop
  • Orchestration: Aspire — formerly branded “.NET Aspire,” renamed simply Aspire starting with the Aspire 13 release, reflecting its growth into a multi-language platform (C#, TypeScript, Python, JavaScript, Java, Go, and more)

A note on versions: Aspire ships fast — this article was verified against Aspire 13.4+, the current stable line as of mid-2026. Commands like aspire new, aspire add, aspire publish, and aspire deploy reflect the current CLI-first workflow. If a command below doesn’t match what you see, run aspire --version and check the official release notes — this is one of the fastest-moving parts of the .NET ecosystem.


1. Before Aspire: the pain nobody talks about

Picture a normal week for “Developer A,” joining a team with a fairly ordinary distributed app:

apiservice   (ASP.NET Core)
worker       (background jobs)
web          (React/Next.js)
postgres
redis
rabbitmq

To run this locally, Developer A needs to, in roughly this order:

  1. docker compose up -d for Postgres, Redis, RabbitMQ
  2. Copy a .env.example to .env and fill in secrets someone pasted in Slack
  3. dotnet run the API in one terminal, dotnet run the worker in another
  4. npm install && npm run dev for the frontend, in a third terminal
  5. Manually check four different terminal windows to see if anything crashed
  6. Repeat steps 1-5 the next time they pull main and something changed

Multiply this by every teammate, and by every environment (local, CI, staging), and you get a README that looks like:

Step 1 ...
Step 2 ...
...
Step 17: if it still doesn't work, ask in #dev-help

This is the actual problem Aspire was built to solve — not “how do I run a container,” but how do I describe an entire distributed application, once, in a way that’s runnable, debuggable, and observable without a 17-step README.


2. Why Microsoft built Aspire — the short version

Aspire didn’t appear in a vacuum. It’s the next step in a fairly recognizable evolution of how teams have tried to tame local development for distributed systems:

Single app on localhost
        ↓
Docker Compose (containers, but no app-level awareness)
        ↓
Kubernetes (production-grade, but heavy for local dev)
        ↓
Service mesh (solves service-to-service concerns, adds complexity)
        ↓
OpenTelemetry (standardizes observability, but is just a data format)
        ↓
Aspire (a code-first model that ties orchestration + service discovery + observability together, for local dev first)

Each earlier tool solved one layer of the problem — Compose gave you containers, Kubernetes gave you production orchestration, OpenTelemetry gave you a common language for telemetry. None of them were designed specifically to make the inner dev loop — the thing you do fifty times a day — fast and observable. That gap is what Aspire targets.


3. What is Aspire, actually?

Aspire is an opinionated, code-first orchestration layer that solves local dev-time orchestration (and gives you a running start on cloud deployment). Instead of YAML or shell scripts, you describe your whole application graph in a project called the AppHost:

var postgres = builder.AddPostgres("postgres").AddDatabase("appdb");
var cache = builder.AddRedis("cache");
var api = builder.AddProject<Projects.ApiService>("apiservice")
    .WithReference(postgres)
    .WithReference(cache);
var web = builder.AddNpmApp("web", "../web", "dev")
    .WithReference(api)
    .WithExternalHttpEndpoints();

Run that one file, and Aspire will:

  1. Spin up Postgres and Redis as Docker containers
  2. Start your ASP.NET Core API with the right connection strings injected automatically
  3. Start your Next.js dev server with the API’s URL injected as an environment variable
  4. Open the Aspire Dashboard — a live view of logs, traces, metrics, and environment variables for every single resource

That’s the whole pitch: one command, one dashboard, zero manually-copied connection strings.

Official resources worth bookmarking before you start:


4. Aspire vs Docker Compose — the question everyone asks first

If you already use docker-compose.yml, the obvious question is: why would I need Aspire on top of that? Here’s the honest comparison:

Docker ComposeAspire
Definition formatYAMLC# (or TypeScript) — a real programming language
What it can runContainers onlyContainers and plain executables/processes (dotnet run, npm run dev, Python, etc.)
Service discoveryManual — you hardcode hostnames/portsBuilt-in —WithReference() injects connection info automatically
Dashboard / observabilityNone built-inFull dashboard: logs, traces, metrics, resource health
OpenTelemetryNot included — you wire it yourselfBuilt-in by default via ServiceDefaults
Debugging in IDEAttach manually per containerNative IDE debugging (F5) across the whole graph
Environment variablesHardcoded per service in YAMLComputed and injected per resource reference
Deployment storyYou write separate deploy configsSame graph can publish to Azure Container Apps, Kubernetes, or generate a Docker Compose file for you
Multi-language orchestrationYes, natively (any image)Yes, as of Aspire 13 — first-class Python/JS/TS support alongside .NET

The honest takeaway: Compose is a container runner; Aspire is an application model. Aspire can even emit a Compose file as one of its publish targets — so you’re not choosing one over the other forever, you’re choosing what authors your local dev-time graph.


5. Core concepts before you write any code

These four ideas are the actual foundation of Aspire. Understanding them up front will save you from a lot of confusion later.

5.1 The Resource Graph

Every builder.AddX(...) call in AppHost.cs adds a node. Every .WithReference(...) or .WaitFor(...) call adds an edge. Together they form a dependency graph that Aspire uses to compute start order automatically.

You never write “start postgres, then redis, then the API, then the frontend” — you just declare the relationships, and Aspire figures out the order.

5.2 Service Discovery — and why WithReference() is not WaitFor()

This is the single most misunderstood part of Aspire. They solve two different problems:

  • WithReference(resource)only wires configuration. It injects an environment variable (typically a connection string, or a services__<name>__http__0-style URL) into the dependent resource. It does not wait for anything.
  • WaitFor(resource)only blocks startup. It delays a resource’s start until the referenced resource reports healthy. It does not inject any configuration.

You almost always want both together for a hard dependency like a database:

var apiService = builder.AddProject<Projects.AspireDemo_ApiService>("apiservice")
    .WithReference(appDb)   // inject the connection string
    .WaitFor(appDb);        // don't start until Postgres is healthy

Crucially: Aspire never copies a literal connection string into a config file. It injects it as an environment variable at process-start time, every run, computed fresh — so there’s nothing stale to go out of sync.

5.3 ServiceDefaults — the project people accidentally delete

Every Aspire starter template includes a ServiceDefaults project, referenced by every other .NET project in the solution. It’s easy to assume it’s boilerplate and remove it — don’t. builder.AddServiceDefaults() wires up, in one call:

  • OpenTelemetry — logs, metrics, and traces, exported to the dashboard
  • Health checks/health and /alive endpoints
  • Resilience — standard retry/circuit-breaker policies for outgoing HttpClient calls
  • Service discovery — resolving services__* style URLs into usable HttpClient base addresses

5.4 Aspire is built on OpenTelemetry — the Dashboard is just the viewer

A common misconception: “Aspire is the dashboard.” In reality:

Aspire
  ↓
OpenTelemetry (the actual data: logs, metrics, traces — an open, vendor-neutral standard)
  ↓
Aspire Dashboard (one viewer for that data, built for local dev)

Because the underlying data is standard OTLP, you can point the same telemetry at Application Insights, Grafana, Jaeger, or any other OpenTelemetry-compatible backend in production — the dashboard is a convenience for local dev, not a proprietary lock-in.


6. Why use Aspire — and where it falls short

Benefits

  • One file describes your whole architecture. The AppHost becomes the single source of truth — a new teammate can understand your entire system by reading one file instead of piecing together Compose files, READMEs, and tribal knowledge.
  • Zero manually-copied connection strings, as explained above.
  • Observability by default, via OpenTelemetry and the dashboard, with no extra setup.
  • Not .NET-only. As of Aspire 13, Python and JavaScript/TypeScript are first-class citizens alongside .NET — not just “supported as a container.”
  • The same model targets local dev and deployment. The graph can describe a Docker container locally and an Azure Postgres Flexible Server in the cloud, without a rewrite.
  • Free and open source, maintained by Microsoft with an active public GitHub repo (6,000+ stars, hundreds of contributors) and community integrations.

Limitations

  • It’s a dev-time orchestrator first. The dashboard is designed for local development; you’ll still want real production-grade monitoring (Application Insights, Grafana, Datadog) for a live system — see Section 17.
  • You still need to understand real infrastructure. Aspire helps you describe and publish to Kubernetes, Azure Container Apps, or Docker Compose — it doesn’t replace understanding those platforms.
  • Requires Docker Desktop (or Podman) locally. Check Docker Desktop’s commercial licensing terms with IT before rolling this out broadly at a larger company.
  • The tooling moves fast. The CLI and publish/deploy commands have changed shape more than once as Aspire matured — pin your package/CLI versions in a real project and read release notes before upgrading.
  • Learning curve. Understanding resource references, WaitFor vs WithReference, service discovery variable naming, and the publish/deploy pipeline is a real onboarding investment for a team new to the model.

7. When should you actually use Aspire?

Being direct about this makes the rest of the article more credible.

Probably skip it if:

  • You’re building a small CRUD app with one database
  • You’re shipping a monolith with no separate services
  • It’s an internal tool used by a handful of people
  • It’s a console app or a single script
  • It’s a console app or a single script

Reach for it if:

  • You’re building microservices or a distributed system
  • You have multiple APIs/services that depend on each other
  • You’re doing event-driven architecture (queues, workers, pub/sub)
  • You’re building an AI system with multiple moving parts (API + vector DB + model provider + cache)
  • Onboarding new developers to your local setup currently takes more than an hour

Bottom line: if your team already juggles multiple services and dependencies locally, Aspire’s time-to-value is high. If you’re shipping a single monolith with one database, you probably don’t need it yet — and that’s a perfectly fine answer.


8. Prerequisites

Install these before you touch any code. Everything below targets Windows 11.

ToolVersionNotes
.NET SDK.NET 10 (10.0.100+)Required by the Aspire CLI as of Aspire 13. Run dotnet --version to confirm
Visual Studio CodelatestOur IDE for this guide
C# Dev Kit extensionlatestMarketplace ID ms-dotnettools.csdevkit; the Aspire VS Code extension builds on this
Docker Desktop for Windows
or Rancher Desktop – Open-source
(WSL…)
latest,WSL 2 backend enabledAspire uses this to run Postgres/Redis containers
Node.jsv26.2.0For the Next.js frontend — run node -v to confirm
Aspire CLIlatest (13.4+)Installed below

Open PowerShell and run:

# Confirm .NET 10 is installed
dotnet --version

# Install the Aspire CLI (choose ONE method)

# Option 1: WinGet (community package, simplest on Windows)
winget install Aspire.AspireCli

# Option 2: official install script
irm https://aspire.dev/install.ps1 | iex

# Option 3: as a .NET global tool
dotnet tool install -g Aspire.Cli

# Verify it worked (restart the terminal first if the command isn't found)
aspire --version

# Confirm Docker Desktop is running and using the WSL2 engine
docker version

# Confirm Node.js version
node -v

In VS Code, install these two extensions from the Extensions panel (Ctrl+Shift+X):

  • C# Dev Kit — also installs the Aspire extension, which adds a dedicated Aspire panel to the Activity Bar
  • Docker (ms-azuretools.vscode-docker) — lets you inspect the containers Aspire spins up

9. The architecture we’re building

  • AppHost — the orchestrator project; you run this, nothing else, in development
  • apiservice — an ASP.NET Core Minimal API (.NET 10)
  • web — a Next.js app, started via npm run dev, wired to the API through Aspire’s service discovery
  • postgres / cache — real Postgres and Redis, running in Docker, with zero manual setup
  • Aspire Dashboard — auto-launched in your browser, showing logs/traces/metrics for every resource above

10. Scaffold the solution

Aspire 13’s CLI-first workflow uses aspire new (interactive, recommended) instead of the older dotnet new aspire-starter template invocation directly — though the underlying templates are the same:

mkdir aspire-demo
cd aspire-demo

# Interactive — prompts you for template, name, and language
aspire new

# Or non-interactive, if you already know what you want:
aspire new aspire-starter -n AspireDemo -o .

This generates:

aspire-demo/
├── AspireDemo.AppHost/          ← the orchestrator (run this one)
├── AspireDemo.ApiService/       ← ASP.NET Core Minimal API
├── AspireDemo.ServiceDefaults/  ← shared telemetry/health-check config (Section 5.3)
└── AspireDemo.sln

Open the folder in VS Code:

code .

11. Add PostgreSQL and Redis (running in Docker, orchestrated by Aspire)

You can add hosting integrations either with the Aspire CLI or plain dotnet add package — both do the same thing:

cd AspireDemo.AppHost

# Aspire CLI way (interactive picker if you omit the name)
aspire add postgresql
aspire add redis

# ...or the classic NuGet way
dotnet add package Aspire.Hosting.PostgreSQL
dotnet add package Aspire.Hosting.Redis

Open AppHost.cs and update it. The full final version is available as a standalone file — AppHost.cs in the code samples — but here’s the core:

var builder = DistributedApplication.CreateBuilder(args);

// Postgres, running as a Docker container, with a persisted data volume
var postgres = builder.AddPostgres("postgres")
    .WithDataVolume()
    .WithPgAdmin(); // optional: adds a pgAdmin UI resource too

var appDb = postgres.AddDatabase("appdb");

// Redis, running as a Docker container
var cache = builder.AddRedis("cache")
    .WithDataVolume();

var apiService = builder.AddProject<Projects.AspireDemo_ApiService>("apiservice")
    .WithReference(appDb)
    .WithReference(cache)
    .WaitFor(appDb)
    .WaitFor(cache);

builder.Build().Run();

A few things worth calling out for beginners (see Section 5.2 for the full explanation of WithReference vs WaitFor):

  • .WithDataVolume() persists container data across restarts, so you don’t lose your dev data every time you stop the AppHost.
  • .WithReference(appDb) injects the correct connection string into the API project’s configuration automatically.
  • .WaitFor(...) tells Aspire not to start the API until Postgres/Redis report healthy — no more “connection refused” races on first boot.

12. Wire up the API project to Postgres and Redis (with EF Core)

In AspireDemo.ApiService, add the client integration packages:

cd ../AspireDemo.ApiService
dotnet add package Aspire.Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Aspire.StackExchange.Redis
dotnet add package Microsoft.EntityFrameworkCore.Design

Create the DbContext and entity. The full file — including detailed migration instructions — is provided separately as AppDbContext.cs; the shape looks like this:

using Microsoft.EntityFrameworkCore;

namespace AspireDemo.ApiService.Data;

public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<TodoItem> Todos => Set<TodoItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<TodoItem>(entity =>
        {
            entity.HasKey(t => t.Id);
            entity.Property(t => t.Title).IsRequired().HasMaxLength(200);
            entity.Property(t => t.CreatedAtUtc).HasDefaultValueSql("now()");
        });

        base.OnModelCreating(modelBuilder);
    }
}

public class TodoItem
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public bool Done { get; set; }
    public DateTime CreatedAtUtc { get; set; }
}

Creating your first EF Core migration

Install the EF Core CLI tool once, globally:

dotnet tool install --global dotnet-ef

⚠️ Important — don’t point dotnet ef at the AppHost. You might expect
--startup-project should be AspireDemo.AppHost, since that’s the project
you actually run. It isn’t, and doing so will fail with an error like
File '...\AppHost\bin\Debug\net10.0\AspireDemo.ApiService.dll' not found.

The reason: when the AppHost references the API via
builder.AddProject<Projects.AspireDemo_ApiService>(...), that’s a special
Aspire-only reference used purely for orchestration metadata — it does
not copy AspireDemo.ApiService.dll into the AppHost’s bin folder the
way a normal ProjectReference would. dotnet ef expects that copy to
exist, so it can’t find it. This is a known, currently-unsupported scenario
in Aspire’s tooling (tracked as dotnet/aspire#2090).

The supported approach — and the one Microsoft’s own Aspire EF Core samples
use — is to run dotnet ef directly against the API project itself,
with no AppHost involved at all.

Because AppDbContext normally gets its connection string from Aspire at runtime (via AddNpgsqlDbContext<AppDbContext>("appdb")), and no AppHost is running during this command, add a design-time-only fallback connection string to appsettings.Development.json. EF only needs this string to be syntactically valid to build the model for a migration — it does not need to actually connect to a running database:

{
  "ConnectionStrings": {
    "appdb": "Host=localhost;Port=5432;Database=appdb;Username=postgres;Password=postgres"
  }
}

This value is only ever used by design-time tooling. At runtime, Aspire always overrides it with the real connection string of the Postgres container it started — so it’s safe to commit a non-secret placeholder like this one.

Now update Program.cs. The complete, working version — all five CRUD endpoints — is provided as a separate file, Program.cs; here’s the essential wiring:

using AspireDemo.ApiService;
using AspireDemo.ApiService.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Logging, health checks (/health, /alive), OpenTelemetry, resilient HttpClient defaults.
// This one call is what makes the project show up correctly in the Aspire Dashboard.
builder.AddServiceDefaults();

// Reads the connection string Aspire injected via WithReference(appDb) in AppHost.cs.
// The name "appdb" here MUST match the name used when the AppHost referenced the resource.
builder.AddNpgsqlDbContext<AppDbContext>("appdb");

// Reads the connection string Aspire injected via WithReference(cache) in AppHost.cs.
builder.AddRedisClient("cache");

// Add services to the container.
builder.Services.AddProblemDetails();

builder.Services.AddEndpointsApiExplorer();

// Allow the Next.js dev server (and its dynamic Aspire-assigned port) to call this API locally.
builder.Services.AddCors(options =>
{
    options.AddDefaultPolicy(policy =>
    {
        policy.SetIsOriginAllowed(_ => true) // relaxed for local dev only — tighten before shipping
              .AllowAnyHeader()
              .AllowAnyMethod();
    });
});

var app = builder.Build();

app.MapDefaultEndpoints(); // health checks, from ServiceDefaults

app.UseCors();

// Simple, beginner-friendly migration-at-startup for local development.
// For production, prefer a dedicated migration worker — see Data/AppDbContext.cs for why.
if (app.Environment.IsDevelopment())
{
    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await db.Database.MigrateAsync();
}

app.MapGet("/api/todos", async (AppDbContext db) =>
    await db.Todos.OrderByDescending(t => t.CreatedAtUtc).ToListAsync());

app.MapGet("/api/todos/{id:int}", async (int id, AppDbContext db) =>
    await db.Todos.FindAsync(id) is { } todo
        ? Results.Ok(todo)
        : Results.NotFound());

app.MapPost("/api/todos", async (CreateTodoRequest request, AppDbContext db) =>
{
    var todo = new TodoItem { Title = request.Title, Done = false };
    db.Todos.Add(todo);
    await db.SaveChangesAsync();
    return Results.Created($"/api/todos/{todo.Id}", todo);
});

app.MapPut("/api/todos/{id:int}/toggle", async (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return Results.NotFound();

    todo.Done = !todo.Done;
    await db.SaveChangesAsync();
    return Results.Ok(todo);
});

app.MapDelete("/api/todos/{id:int}", async (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return Results.NotFound();

    db.Todos.Remove(todo);
    await db.SaveChangesAsync();
    return Results.NoContent();
});

app.Run();

public record CreateTodoRequest(string Title);

Notice there’s no connection string in appsettings.json — "appdb" and "cache" are just the resource names you gave them in the AppHost. Aspire resolves the rest at run time.

Now generate the migration, running the command from inside the AspireDemo.ApiService folder (no --project / --startup-project flags
needed, since the current directory already is both):

cd AspireDemo.ApiService
dotnet ef migrations add InitialCreate

This creates a Migrations/ folder with two files
(<timestamp>_InitialCreate.cs and AppDbContextModelSnapshot.cs) — commit
both to source control, they’re part of your schema history.

There are two supported ways to actually apply migrations to the database:

  • Option A — apply at API startup (simplest, fine for demos): call
    await db.Database.MigrateAsync() once, guarded by
    if (app.Environment.IsDevelopment()). This is what our Program.cs already does — the moment you run aspire run again, Aspire injects the real connection string and the migration is applied automatically to the real Postgres container.
  • Option B — a dedicated migration worker (recommended for real projects): a small worker-service project that runs the migration once and exits, registered in AppHost.cs with .WaitForCompletion(migrator) so the API never races another replica to apply the same migration. Full explanation and code are in AppDbContext.cs.

13. Add the Next.js frontend as an Aspire-managed resource

This is the part most tutorials skip: Aspire isn’t .NET-only. It can orchestrate any process, including a Node.js dev server, right alongside your .NET services.

First, scaffold the Next.js app next to your other projects (make sure you’re using Node.js v26.2.0 — check with node -v):

cd ..
npx create-next-app@latest web --typescript --app --eslint --tailwind --src-dir --import-alias "@/*"

The structure now is:

aspire-demo/
├── AspireDemo.AppHost/
├── AspireDemo.ApiService/
├── AspireDemo.ServiceDefaults/
└── web/         

Now tell the AppHost about it. Add the Node hosting package:

cd .\aspire-demo\AspireDemo.AppHost\ 
aspire add nodejs
# or: dotnet add package Aspire.Hosting.NodeJs

Update AppHost.cs:

var web = builder.AddNpmApp("web", "../web", "dev")
    .WithReference(apiService)
    .WaitFor(apiService)
    .WithEnvironment("BROWSER", "none")     // stop it from trying to auto-open a browser tab
    .WithHttpEndpoint(env: "PORT", port: 3000)
    .WithExternalHttpEndpoints();

builder.Build().Run();

WithReference(apiService) injects an environment variable into the Next.js process (something like services__apiservice__http__0) that resolves to the API’s actual local URL — which changes every run, since Aspire assigns dynamic ports.

In your Next.js code, read it through a small helper instead of hardcoding localhost:5000:

// web/src/lib/api.ts
export interface Todo {
  id: number;
  title: string;
  done: boolean;
  createdAtUtc: string;
}

export function getApiBaseUrl(): string {
  // Aspire injects this env var at process-start time — the value (and the
  // port) changes every run. Falls back to a sane default for `next build`.
  return process.env.services__apiservice__http__0 ?? "http://localhost:5150";
}

export async function getTodos(): Promise<Todo[]> {
  const res = await fetch(`${getApiBaseUrl()}/api/todos`, { cache: "no-store" });
  if (!res.ok) {
    throw new Error(`Failed to load todos (status ${res.status})`);
  }
  return res.json();
}

export async function createTodo(title: string): Promise<Todo> {
  const res = await fetch(`${getApiBaseUrl()}/api/todos`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ title }),
  });
  if (!res.ok) {
    throw new Error(`Failed to create todo (status ${res.status})`);
  }
  return res.json();
}

export async function toggleTodo(id: number): Promise<Todo> {
  const res = await fetch(`${getApiBaseUrl()}/api/todos/${id}/toggle`, {
    method: "PUT",
  });
  if (!res.ok) {
    throw new Error(`Failed to toggle todo (status ${res.status})`);
  }
  return res.json();
}

export async function deleteTodo(id: number): Promise<void> {
  const res = await fetch(`${getApiBaseUrl()}/api/todos/${id}`, {
    method: "DELETE",
  });
  if (!res.ok && res.status !== 204) {
    throw new Error(`Failed to delete todo (status ${res.status})`);
  }
}

Server actions call to API

// src/app/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { createTodo, toggleTodo, deleteTodo } from "@/lib/api";

export async function addTodoAction(formData: FormData) {
  const title = (formData.get("title") ?? "").toString().trim();
  if (!title) return;

  await createTodo(title);
  revalidatePath("/");
}

export async function toggleTodoAction(id: number) {
  await toggleTodo(id);
  revalidatePath("/");
}

export async function deleteTodoAction(id: number) {
  await deleteTodo(id);
  revalidatePath("/");
}

A simple Home page below:

// web/src/app/page.tsx
import { getTodos } from "@/lib/api";
import { addTodoAction, toggleTodoAction, deleteTodoAction } from "./actions";

export default async function Home() {
  let todos: Awaited<ReturnType<typeof getTodos>> = [];
  let error: string | null = null;

  try {
    todos = await getTodos();
  } catch (err) {
    error = err instanceof Error ? err.message : "Unknown error";
  }

  return (
    <main className="p-8 max-w-xl mx-auto">
      <h1 className="text-2xl font-bold mb-6">Todos</h1>

      {/* Add form — a native <form> posting to a Server Action */}
      <form action={addTodoAction} className="flex gap-2 mb-6">
        <input
          type="text"
          name="title"
          placeholder="What needs to be done?"
          required
          className="flex-1 border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
        <button
          type="submit"
          className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
        >
          Add
        </button>
      </form>

      {error && (
        <p className="text-red-600 mb-4">Could not reach the API: {error}</p>
      )}

      {!error && todos.length === 0 && (
        <p className="text-gray-500">No todos yet — add your first one above.</p>
      )}

      <ul className="space-y-2">
        {todos.map((todo) => (
          <li
            key={todo.id}
            className="flex items-center justify-between border rounded-lg px-4 py-2"
          >
            <div className="flex items-center gap-3">
              {/* Toggle done/undone */}
              <form action={toggleTodoAction.bind(null, todo.id)}>
                <button
                  type="submit"
                  aria-label={todo.done ? "Mark as not done" : "Mark as done"}
                  className={`w-5 h-5 rounded border flex items-center justify-center text-xs ${
                    todo.done
                      ? "bg-green-600 border-green-600 text-white"
                      : "border-gray-400"
                  }`}
                >
                  {todo.done ? "✓" : ""}
                </button>
              </form>

              <span className={todo.done ? "line-through text-gray-400" : ""}>
                {todo.title}
              </span>
            </div>

            <div className="flex items-center gap-3">
              <span className="text-xs text-gray-400">
                {new Date(todo.createdAtUtc).toLocaleDateString()}
              </span>
              <form action={deleteTodoAction.bind(null, todo.id)}>
                <button
                  type="submit"
                  aria-label="Delete todo"
                  className="text-red-500 hover:text-red-700 text-sm"
                >
                  Delete
                </button>
              </form>
            </div>
          </li>
        ))}
      </ul>
    </main>
  );
}

14. Run everything — and take the full Dashboard tour

From the solution root:

cd AspireDemo.AppHost
aspire run

Watch the terminal — Aspire will pull/start the Postgres and Redis Docker images, start the ASP.NET Core API, run npm install + npm run dev for Next.js, and print a dashboard URL it opens automatically.

The dashboard is more than “logs and traces” — here’s the full tour most tutorials skip:

TabWhat it showsWhy it matters
ResourcesEvery resource, its state (Running/Starting/Failed), and its endpoint URLsYour single at-a-glance health check for the whole app
Console logsRaw stdout/stderr per resource — including non-.NET processes like the Next.js dev serverProves a resource is a real running process, not a mock
Structured logsQueryable, filterable ILogger output with severity levels and scopesSearch “show me only Warning+ from apiservice” instead of grepping a terminal
TracesFull distributed traces spanning process boundariesSee a single request cross Next.js → API → EF Core → Postgres → Redis, with timing for every hop
MetricsOpenTelemetry metrics (request rates, durations, custom counters)Spot a slow endpoint before it’s a production incident
Resource details → ConfigurationEvery environment variable and config value a resource actually receivedThe fastest way to debug “why isn’t my connection string showing up”

15. Debugging inside VS Code

Because the AppHost is just a normal .NET project, VS Code’s built-in debugger works on it directly:

  1. Open the Run and Debug panel (Ctrl+Shift+D)
  2. Select C#: AspireDemo.AppHost (C# Dev Kit generates this automatically)
  3. Set a breakpoint inside apiservice‘s /api/todos endpoint
  4. Press F5

Aspire launches the whole graph, and when the Next.js page calls the API, VS Code stops right on your breakpoint — even though the request originated from a Node.js process Aspire started for you.

Choose Aspire or C#

Choose AppHost

Add breakpoint, run and debug

New: You can install the new ‘Aspire’ extension to make running and debugging more convenient

Note: If you got issue “Aspire CLI is not available on PATH. Please install it and restart VS Code.
Follow the page Install Aspire CLI | Aspire to install Aspire CLI via WinGet, or simply by run command in powershell / command line:

winget install Microsoft.Aspire

Then restart Visual studio Code, the extension will work:


16. Production Dockerfiles

Aspire’s publish pipeline can generate Dockerfiles for you automatically for plain .NET and Node/Python projects, but for full control (custom build steps, non-default flags, private feeds), it helps to know what a hand-written production Dockerfile looks like. Three complete, ready-to-use files are provided separately:

  • Root .dockerignore – Create this file in the root directory of aspire-demo (at the same level as AspireDemo.AppHostAspireDemo.ApiService, etc. — the exact location where you are when running the docker build command) to ensure you avoid the following error: “The obj/ and bin/ directories generated on Windows have been included in the Docker image, overwriting the correct Linux restore results.”
  • web/.dockerignore – Create this file as the web/dockerfile is built with its own context—the web/ directory—and does not use the .dockerignore file in the root directory), to avoid the exact same error when building the Next.js image (where the node_modules folder installed on Windows is mistakenly copied, overwriting the Linux version just installed in the container.
  • ApiService/dockerfile — multi-stage build for the ASP.NET Core API: SDK image restores/publishes, runtime-only aspnet image runs as a non-root user, with a HEALTHCHECK wired to the /health endpoint from ServiceDefaults.
  • web/dockerfile — multi-stage build for the Next.js frontend using output: "standalone", producing a minimal runtime image on node:26.2.0-alpine. Because Section 13’s frontend code reads the API’s address server-side, at request time (process.env.services__apiservice__http__0 inside a Server Action), this image needs no build-time API URL — unlike a client-fetching setup, which would need a NEXT_PUBLIC_* build arg baked in before npm run build. One built image can be pointed at a different API URL in every environment just by changing a runtime environment variable, with no rebuild.
  • docker-compose.prod.yml — wires the two images above together with real Postgres and Redis containers, injecting ConnectionStrings__appdb and services__apiservice__http__0 at container-start time the same way Aspire does locally. This is what Section 18’s Option A actually runs — see that section for the full explanation and the command to run it.

Build the two Dockerfiles from the right context:

# From the solution root
docker build -f AspireDemo.ApiService/Dockerfile -t aspiredemo-api:latest .

# From inside the web/ folder
docker build -t aspiredemo-web:latest .

Sample:

1. Root aspire-demo/.dockerignore

# File: aspire-demo/.dockerignore
#
# Place this at the SOLUTION ROOT — the same folder you run
# `docker build -f AspireDemo.ApiService/Dockerfile -t aspiredemo-api:latest .`
# from. Without this file, `COPY . .` in the Dockerfile copies your local,
# host-built bin/ and obj/ folders into the container — which on Windows
# contain Windows-only NuGet paths (e.g. a fallback package folder under
# "C:\Program Files (x86)\..."). That overwrites the clean, Linux-restored
# obj/project.assets.json the container just created, causing
# `dotnet publish` to fail with:
#   "Unable to find fallback package folder 'C:\Program Files (x86)\...'"

# Build outputs — always regenerated inside the container, never copy these
**/bin/
**/obj/

# IDE / editor folders
**/.vs/
**/.vscode/
**/.idea/

# Node (the web/ project has its own separate .dockerignore too)
**/node_modules/
**/.next/

# Version control
**/.git/
**/.gitignore

# Misc
**/*.user
**/*.suo

2. web/.dockerignore

# File: web/.dockerignore
#
# Place this INSIDE the web/ folder — that's the build context for
# `docker build -t aspiredemo-web:latest .` (run from inside web/).
# Without this, `COPY . .` would copy your host's Windows-installed
# node_modules over the Linux ones the container just installed with
# `npm ci`, which can break native dependencies.

node_modules
.next
.git
.vscode

npm-debug.log*
*.local

3. AspireDemo.ApiService/Dockerfile

# File: AspireDemo.ApiService/Dockerfile
#
# Production, multi-stage Dockerfile for the ASP.NET Core API service.
# Aspire's `aspire publish` / `azd` pipelines can generate a Dockerfile for you
# automatically for plain .NET projects — but when you need custom build steps
# (native deps, private NuGet feeds, non-default publish flags), hand-writing
# one like this gives you full control.
#
# Build from the SOLUTION ROOT so the build context can see every project
# referenced by AspireDemo.ApiService.csproj:
#   docker build -f AspireDemo.ApiService/Dockerfile -t aspiredemo-api:latest .

# ---- Stage 1: restore + build ----------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

# Copy csproj files first and restore, so Docker can cache this layer
# as long as your dependencies don't change.
COPY ["AspireDemo.ApiService/AspireDemo.ApiService.csproj", "AspireDemo.ApiService/"]
COPY ["AspireDemo.ServiceDefaults/AspireDemo.ServiceDefaults.csproj", "AspireDemo.ServiceDefaults/"]
RUN dotnet restore "AspireDemo.ApiService/AspireDemo.ApiService.csproj"

# Now copy the rest of the source and publish.
COPY . .
WORKDIR /src/AspireDemo.ApiService
RUN dotnet publish "AspireDemo.ApiService.csproj" \
      -c Release \
      -o /app/publish \
      --no-restore \
      /p:UseAppHost=false

# ---- Stage 2: runtime only (small final image) -----------------------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app

# curl is needed for the HEALTHCHECK below — the slim runtime image doesn't
# ship it (or wget) by default, to keep the image small. Installed while
# still root, before switching to the non-root user.
RUN apt-get update \
    && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

COPY --from=build /app/publish .

ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080

# .NET's official runtime images (8.0+) already ship a built-in non-root
# user named "app" (UID 64198) — no need to create one with adduser/useradd.
# Those commands aren't guaranteed to exist across every base OS Microsoft
# publishes these images on (Debian, Azure Linux, chiseled Ubuntu), which is
# exactly what causes "adduser: not found" on some tags. USER app works
# identically on all of them.
USER app

# Aspire's ServiceDefaults wires up /health and /alive automatically —
# this HEALTHCHECK lets any orchestrator (Docker, Kubernetes, ACA) probe it.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD curl -f http://localhost:8080/health || exit 1

ENTRYPOINT ["dotnet", "AspireDemo.ApiService.dll"]

Notice when build: It must be run from the root directory of the solution (aspire-demo/), not from within AspireDemo.ApiService/, as the Dockerfile requires COPY . . to include AspireDemo.ServiceDefaults

cd aspire-demo
docker build -f AspireDemo.ApiService/Dockerfile -t aspiredemo-api:latest .

4. web/dockerfile

# File: web/Dockerfile
#
# Production, multi-stage Dockerfile for the Next.js frontend.
# Requires next.config.ts to have `output: "standalone"` (see below) so the
# build produces a minimal, self-contained server bundle.
#
# Build from inside the `web/` folder:
#   docker build -t aspiredemo-web:latest .

# ---- Stage 1: install dependencies -----------------------------------------
FROM node:26.2.0-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# ---- Stage 2: build ----------------------------------------------------------
FROM node:26.2.0-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ---- Stage 3: runtime only (small final image) -----------------------------
FROM node:26.2.0-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# `output: "standalone"` produces a trimmed server + only the files it needs.
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

CMD ["node", "server.js"]

# ─────────────────────────────────────────────────────────────────────────────
# Add this to next.config.ts for the standalone build:
#
#   import type { NextConfig } from "next";
#   const nextConfig: NextConfig = { output: "standalone" };
#   export default nextConfig;
#
# IMPORTANT — no build-time API URL is needed here. src/lib/api.ts reads
# `process.env.services__apiservice__http__0` at REQUEST time, inside a
# Server Component / Server Action, which runs on the Node server started by
# `node server.js` above — not in the browser. So the API's address only
# needs to exist as a runtime environment variable on the running container
# (see docker-compose.prod.yml), never baked into the image at build time.
# This also means the exact same image can be pointed at different API URLs
# in different environments (staging vs. production) without rebuilding it.
# ─────────────────────────────────────────────────────────────────────────────

5. docker-compose.prod.yml (not a separate Dockerfile, but included so that both images can be run)

# File: docker-compose.prod.yml
#
# A no-cost alternative to cloud deployment: run the whole stack with plain
# Docker Compose on any machine (a spare PC, a $5/mo VPS, your own server) —
# no Azure account, no credit card, no cloud billing required.
#
# Aspire can generate a file like this for you automatically:
#   cd AspireDemo.AppHost
#   dotnet add package Aspire.Hosting.Docker
#   aspire publish --publisher docker-compose -o ../publish-artifacts
#
# (Check `aspire publish --help` for the exact flag names on your installed
# CLI version — the publisher pipeline has changed shape a few times as
# Aspire has matured, most recently with the `aspire do` pipeline commands.)
#
# The file below is a hand-written illustration of what that output looks
# like conceptually, trimmed down for readability. Treat it as a reference,
# not a drop-in replacement for the generated file.

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: appdb
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set a real password in .env}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 10

  cache:
    image: redis:7
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 10

  apiservice:
    build:
      context: .
      dockerfile: AspireDemo.ApiService/Dockerfile
    environment:
      ConnectionStrings__appdb: "Host=postgres;Database=appdb;Username=appuser;Password=${POSTGRES_PASSWORD}"
      ConnectionStrings__cache: "cache:6379"
      ASPNETCORE_ENVIRONMENT: Production
    depends_on:
      postgres:
        condition: service_healthy
      cache:
        condition: service_healthy
    ports:
      - "8080:8080"

  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    environment:
      NODE_ENV: production
      # src/lib/api.ts reads this exact variable name server-side, at request
      # time — it's the same naming convention Aspire itself injects locally,
      # kept consistent here so no frontend code needs to change between
      # `aspire run` and a plain Docker Compose deployment.
      services__apiservice__http__0: "http://apiservice:8080"
    depends_on:
      apiservice:
        # apiservice's Dockerfile ships a HEALTHCHECK hitting /health — this
        # waits for that to pass, not just for the container process to start,
        # so the first request from web doesn't race a still-migrating API.
        condition: service_healthy
    ports:
      - "3000:3000"

volumes:
  postgres-data:
  redis-data:

# Run it with:
#   docker compose -f docker-compose.prod.yml --env-file .env up --build -d
#
# This gives you a real, working deployment of the exact same containers
# Aspire built for local dev — just without the Aspire dashboard/orchestrator,
# since that piece is a dev-time tool, not meant to ship to production.

Why is this file important alongside the two Dockerfiles above: Each dockerfile on its own only builds a single, independent image; docker-compose.prod.yml is what links them to the actual Postgres/Redis instances and injects the correct environment variables (ConnectionStrings__appdb, services__apiservice__http__0) — in line with ‘Option A’ (free, no Azure required) in section 18 of the article.

Note: If you got error when run docker build for web like below:

PS C:\Users\AnhDoNgoc\practice\aspire-demo> cd .\web\
PS C:\Users\AnhDoNgoc\practice\aspire-demo\web> docker build -t aspiredemo-web:latest .
[+] Building 52.8s (14/15)                                                                                                                                                                                                                                                                                                                                  docker:default
 => [internal] load build definition from dockerfile                                                                                                                                                                                                                                                                                                                  0.1s
 => => transferring dockerfile: 2.63kB                                                                                                                                                                                                                                                                                                                                0.0s
 => [internal] load metadata for docker.io/library/node:26.2.0-alpine                                                                                                                                                                                                                                                                                                 3.0s
 => [internal] load .dockerignore                                                                                                                                                                                                                                                                                                                                     0.0s
 => => transferring context: 463B                                                                                                                                                                                                                                                                                                                                     0.0s
 => [deps 1/4] FROM docker.io/library/node:26.2.0-alpine@sha256:7c6af15abe4e3de859690e7db171d0d711bf37d27528eddfe625b2fe89e097f8                                                                                                                                                                                                                                      8.1s
 => => resolve docker.io/library/node:26.2.0-alpine@sha256:7c6af15abe4e3de859690e7db171d0d711bf37d27528eddfe625b2fe89e097f8                                                                                                                                                                                                                                           0.0s
 => => sha256:f8f3dfba4a9d386c9a8b04039708b6b8c641bd1bec9294d519bd027b36f8fb88 453B / 453B                                                                                                                                                                                                                                                                            0.3s
 => => sha256:99fcdb14935484e47368c0ccb984abae25c5d920c94071555a93da54c1e44835 58.50MB / 58.50MB                                                                                                                                                                                                                                                                      6.6s
 => => extracting sha256:99fcdb14935484e47368c0ccb984abae25c5d920c94071555a93da54c1e44835                                                                                                                                                                                                                                                                             1.4s
 => => extracting sha256:f8f3dfba4a9d386c9a8b04039708b6b8c641bd1bec9294d519bd027b36f8fb88                                                                                                                                                                                                                                                                             0.0s
 => [internal] load build context                                                                                                                                                                                                                                                                                                                                     0.1s
 => => transferring context: 279.61kB                                                                                                                                                                                                                                                                                                                                 0.1s
 => [deps 2/4] WORKDIR /app                                                                                                                                                                                                                                                                                                                                           1.7s
 => [deps 3/4] COPY package.json package-lock.json ./                                                                                                                                                                                                                                                                                                                 0.1s
 => [runner 3/6] RUN addgroup --system --gid 1001 nodejs &&     adduser --system --uid 1001 nextjs                                                                                                                                                                                                                                                                    0.4s
 => [deps 4/4] RUN npm ci                                                                                                                                                                                                                                                                                                                                            24.6s
 => [builder 3/5] COPY --from=deps /app/node_modules ./node_modules                                                                                                                                                                                                                                                                                                   3.9s
 => [builder 4/5] COPY . .                                                                                                                                                                                                                                                                                                                                            0.1s
 => [builder 5/5] RUN npm run build                                                                                                                                                                                                                                                                                                                                   9.3s
 => CACHED [runner 4/6] COPY --from=builder /app/public ./public                                                                                                                                                                                                                                                                                                      0.0s
 => ERROR [runner 5/6] COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./                                                                                                                                                                                                                                                                             0.0s
------
 > [runner 5/6] COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./:
------
dockerfile:33
--------------------
  31 |     # `output: "standalone"` produces a trimmed server + only the files it needs.
  32 |     COPY --from=builder /app/public ./public
  33 | >>> COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
  34 |     COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
  35 |     
--------------------
ERROR: failed to build: failed to solve: failed to compute cache key: failed to calculate checksum of ref vehgxgy65szohit03x77fmtco::yxumm5336mltjgw5eanz47i1s: "/app/.next/standalone": not found

Root cause: The log shows that npm run build ran successfully (9.3s, no errors), but did not generate a .next/standalone folder — meaning the next.config.ts file in your project currently does not have an output line: “standalone”. This is a required setting for Next.js to build a compact, self-contained server for Docker.

Solution: Open web/next.config.ts and make sure content should like below:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Enable when building Docker image production — not affected when running `npm run dev`.
  output: "standalone",
};

export default nextConfig;

Then run build the image again, this issue already be resolved.

docker build -t aspiredemo-web:latest .

17. Production architecture: the AppHost does not run in production

This trips up almost everyone coming from Docker Compose, so it deserves its own section: the AppHost project is a local dev-time and build-time tool — it does not exist as a running process in your production environment.

What aspire publish / aspire deploy actually do is read your AppHost.cs graph and translate it into real infrastructure definitions (Bicep for Azure, manifests for Kubernetes, or a plain docker-compose.yml) — then those artifacts are what actually runs in production. The Aspire Dashboard itself can optionally be deployed as one more resource in some targets, but the orchestrator process that ran on your dev machine never ships.


18. Deploying: Azure Container Apps (and cost-conscious alternatives)

This is usually where beginner tutorials either hand-wave or assume you have a company Azure subscription. Since not everyone has a paid Azure account, here’s an honest, cost-aware walkthrough — pick the option that matches your situation.

Option A (recommended if you just want to prove the concept, $0, no account needed): export to Docker Compose

cd AspireDemo.AppHost
aspire add docker-compose-publisher   # or: dotnet add package Aspire.Hosting.Docker
aspire publish --publisher docker-compose -o ../publish-artifacts

(As of Aspire 13, both aspire publish and aspire deploy are GA — check aspire publish --help for the exact current flag names on your installed version.)

A reference example of what that output conceptually looks like is provided separately as docker-compose.prod.yml, using the two Dockerfiles from Section 16.

Before running it, create a .env file next to docker-compose.prod.yml (same folder as AspireDemo.AppHost) — the compose file deliberately refuses to start without it, via POSTGRES_PASSWORD:?set a real password in .env:

"POSTGRES_PASSWORD=YourStrongPassword123!" | Out-File -Encoding utf8 -NoNewline .env

Then run it:

docker compose -f docker-compose.prod.yml --env-file .env up --build -d

Two things need adjusting before this container will report healthy, both caused by the same root cause: ASPNETCORE_ENVIRONMENT: Production in the compose file means app.Environment.IsDevelopment() is now false everywhere it’s checked — and two places in our code use it as a guard:

  1. AspireDemo.ServiceDefaults/Extensions.cs wraps MapDefaultEndpoints()‘s health check registration in if (app.Environment.IsDevelopment()). In Production, /health is never mapped — so the HEALTHCHECK in ApiService.Dockerfile (which calls curl -f http://localhost:8080/health) always fails, apiservice never reports healthy, and web — which waits on it — times out and never starts.
  2. Program.cs wraps the db.Database.MigrateAsync() call the same way. In Production, migrations never run, so the Todos table is never created — the same relation "Todos" does not exist error from Section 15 resurfaces here.

For this $0, single-instance, not-public-facing demo, the simplest fix is to drop both guards:

// AspireDemo.ServiceDefaults/Extensions.cs
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
    app.MapHealthChecks("/health");
    app.MapHealthChecks("/alive", new HealthCheckOptions
    {
        Predicate = r => r.Tags.Contains("live")
    });
    return app;
}
// AspireDemo.ApiService/Program.cs — run unconditionally instead of only in Development
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();

This shortcut is fine for this tutorial, but not for a real production deployment — see the callout box below for what to do instead once real users are involved.

Rebuild and start again:

docker compose -f docker-compose.prod.yml --env-file .env up --build -d
docker compose -f docker-compose.prod.yml ps

You should see apiservice reach healthy and web reach Running. This proves the “same shape locally and in production” story without touching any cloud billing at all.

If you’re shipping this for real, don’t leave it like this. The two shortcuts above trade correctness for tutorial simplicity, and both have real fixes:

  • /health should stay guarded from the public internet, not from itself. An unauthenticated health endpoint that reports dependency status (database down? cache down?) is useful reconnaissance for an attacker and shouldn’t sit on the same public port as your app. Bind it to a separate, internal-only port (.RequireHost("*:8081")) that your orchestrator’s probe can reach but your public load balancer/ingress never exposes — Kubernetes liveness/readiness probes and Azure Container Apps both support probing on a dedicated port this way.
  • Migrating at API startup doesn’t scale past one instance. The moment you run more than one replica for availability, every replica races to run the same migration at once on startup — best case wasted work, worst case a lock conflict or a rolling deploy that migrates the schema mid-traffic. Move MigrateAsync() into a small, separate worker that runs once, applies migrations, and exits before any API replica starts — exactly the “Option B” pattern already described in AppDbContext.cs (Section 12), wired up in Compose via depends_on: migrator: condition: service_completed_successfully or, in Kubernetes, as a Job/init container ahead of the Deployment.

Option B: a real Azure Container Apps deployment

If you do want to try a real cloud deployment, Azure Container Apps (ACA) is Aspire’s most mature, first-class deployment target, orchestrated through the Azure Developer CLI (azd).

winget install microsoft.azd

cd AspireDemo.AppHost
azd init
# -> "Use code in the current directory" -> confirm -> give it an environment name

azd auth login
azd up

azd up will ask for your Subscription and Location, then create a resource group, an Azure Container Registry, a Log Analytics workspace, a Container Apps Environment, and deploy each Aspire resource as a Container App — typically in one to two minutes for a small app.

Clean up when you’re done testing — cloud resources bill while they exist, not just while you’re using them:

azd down

Do you actually need a paid Azure account?

  • Azure Free Account200creditfor30days,plusanalwaysfreemonthlygranton55+services(ContainerAppsincluded).Acreditcardisrequiredforidentityverificationasmalltemporaryauthorization( 200creditfor30days,plusanalwaysfreemonthlygranton55+services(ContainerAppsincluded).Acreditcardisrequiredforidentityverificationasmalltemporaryauthorization( 1) may appear and is refunded; you’re not charged further unless you explicitly upgrade.
  • Azure for Students: $100 credit, no credit card required — just an academic email. The easiest zero-friction path if you’re a student.
  • No card at all: use Option A above, or a no-card-required PaaS free tier like Render (deploying your built container images individually, outside Aspire’s ACA publisher).

Further reading on deployment


19. Configuration and secrets: how Aspire merges everything

A common question: “if I have appsettings.json, environment variables, and Aspire-injected values, which one wins?” .NET’s standard configuration precedence still applies — Aspire just adds another provider into the stack:

Practical guidance:

  • Never put real secrets in AppHost.cs. It’s a normal C# file that ends up in source control.
  • Local dev secrets → dotnet user-secrets set "SomeKey" "value" in the consuming project.
  • Production secrets → your target platform’s secret store (Azure Key Vault, Kubernetes Secrets, etc.); Aspire’s Azure integrations can wire a Key Vault resource into the graph with builder.AddAzureKeyVault(...), so the reference pattern stays the same even though the backing store changes between environments.
  • Because environment variables sit above appsettings.json in precedence, anything Aspire injects via WithReference() will correctly override a stale local appsettings.json value — this is by design, not a bug.

20. Testing your Aspire application

Aspire ships first-class testing support via Aspire.Hosting.Testing, which lets you spin up the real resource graph — real Postgres container and all — inside an integration test, then tear it down automatically:

cd ..
dotnet new aspire-nunit -n AspireDemo.Tests   # xunit/mstest templates also available
[Test]
public async Task GetTodos_ReturnsSuccessAndCorrectContentType()
{
    var appHost = await DistributedApplicationTestingBuilder
        .CreateAsync<Projects.AspireDemo_AppHost>();

    await using var app = await appHost.BuildAsync();
    await app.StartAsync();

    var httpClient = app.CreateHttpClient("apiservice");
    await app.ResourceNotifications.WaitForResourceHealthyAsync("apiservice");

    var response = await httpClient.GetAsync("/api/todos");

    Assert.That(response.IsSuccessStatusCode, Is.True);
}

This is fundamentally different from mocking your database — it’s a real integration test against a real, ephemeral Postgres container, orchestrated the same way your local dev environment is. It’s slower than a unit test, but it catches an entire category of “works on my machine, fails in CI” bugs that mocks can’t.


21. Beyond Postgres and Redis: the integration catalog

Postgres and Redis are the “hello world” of Aspire resources, but the hosting integration catalog is much wider. A sample of what’s available via aspire add or dotnet add package Aspire.Hosting.*:

IntegrationPackage (abbreviated)Use case
RabbitMQAspire.Hosting.RabbitMQMessage queues, event-driven architecture
Apache KafkaAspire.Hosting.KafkaEvent streaming at scale
Azure Storage (Blob/Queue/Table)Aspire.Hosting.Azure.StorageFile storage, queues, NoSQL tables
Azure SQL / SQL ServerAspire.Hosting.SqlServer / Aspire.Hosting.Azure.SqlRelational data on the Microsoft stack
Azure OpenAIAspire.Hosting.Azure.CognitiveServicesLLM-backed features
OllamaAspire.Hosting.Ollama (Community Toolkit)Local, offline LLM inference
QdrantAspire.Hosting.Qdrant (Community Toolkit)Vector search / RAG
MilvusAspire.Hosting.Milvus (Community Toolkit)Vector search at larger scale
MongoDBAspire.Hosting.MongoDBDocument database

The point isn’t to memorize this table — it’s to internalize that Aspire is not “the Postgres and Redis tool.” The same WithReference() / WaitFor() pattern you already know applies identically to every resource in this list. See the Aspire Community Toolkit for integrations beyond the officially maintained set.


22. Aspire + AI: a quick look

Because so many teams are now shipping an LLM-backed feature alongside a normal web app, a common Aspire shape looks like:

var vectorDb = builder.AddQdrant("vectordb");
var openAi = builder.AddAzureOpenAI("openai")
    .AddDeployment("chat", "gpt-4o-mini", "2024-07-18");

var apiService = builder.AddProject<Projects.ApiService>("apiservice")
    .WithReference(vectorDb)
    .WithReference(openAi)
    .WaitFor(vectorDb);

The same benefits apply: the API gets its Qdrant connection and Azure OpenAI endpoint/key injected automatically, and both resources show up in the dashboard with their own logs and traces — which matters a lot when you’re debugging why a RAG pipeline returned a weird answer, since you can see the exact vector search call and the exact prompt sent to the model in the same trace. Community integrations also cover local-first setups (Ollama for offline models, Foundry Local) so you can develop against a free local model and swap to Azure OpenAI only in staging/production via configuration, not code changes.


23. Organizing large AppHosts

A single-file AppHost.cs is fine for a demo. It stops being fine once you have 15+ resources. The common pattern for real projects is to split resource wiring into extension methods, grouped by concern:

AspireDemo.AppHost/
├── AppHost.cs                  <- thin: just calls the extensions below
└── Extensions/
    ├── DatabaseExtensions.cs   <- AddPostgres, AddSqlServer, migrations wiring
    ├── MessagingExtensions.cs  <- AddRabbitMQ, AddKafka
    ├── ObservabilityExtensions.cs
    └── ProjectExtensions.cs    <- AddProject calls + their WithReference/WaitFor chains
// Extensions/DatabaseExtensions.cs
public static class DatabaseExtensions
{
    public static IResourceBuilder<PostgresDatabaseResource> AddAppDatabase(
        this IDistributedApplicationBuilder builder)
    {
        return builder.AddPostgres("postgres")
            .WithDataVolume()
            .AddDatabase("appdb");
    }
}

// AppHost.cs stays readable:
var appDb = builder.AddAppDatabase();
var cache = builder.AddCache();
var apiService = builder.AddApiService(appDb, cache);
var web = builder.AddWebFrontend(apiService);
builder.Build().Run();

This keeps AppHost.cs as a readable table of contents for your whole system, instead of a 1,000-line wall of chained method calls.


24. Practical tips for real projects

  • Don’t fight the ports: let Aspire assign dynamic ports for internal service-to-service calls; only pin a port (like we did for Next.js on 3000) when something external — your browser, a mobile app — needs a stable address.
  • Docker Desktop must be running before aspire run, or the Postgres/Redis resources will fail to start. This trips up almost everyone the first time.
  • Pin your Aspire package versions in any real project — aspire update will tell you what’s available, but review release notes before jumping minor versions.
  • Use aspire doctor if something feels wrong with your local environment — it diagnoses common setup issues (missing SDK, cert trust, Docker connectivity) in one command.

25. Troubleshooting checklist

SymptomLikely causeFix
AppHost fails immediately with a Docker-related errorDocker Desktop isn’t running, or WSL2 backend is offStart Docker Desktop, confirm docker version works in PowerShell
Next.js resource shows “Failed to start” in the dashboardWrong Node version, or node_modules missingConfirm node -v is v26.2.0; delete node_modules and let Aspire re-run npm install
API can’t reach PostgresMissing .WaitFor(appDb)Add .WaitFor() so the API waits for the DB health check before starting
Next.js can’t find the API URLReading the wrong environment variable nameCheck the resource’s Configuration tab in the dashboard (Section 14) and confirm the exact services__* key Aspire injected
Dashboard doesn’t open automaticallyBrowser launch is disabled in some environments (e.g., remote/SSH VS Code)Copy the dashboard URL printed in the terminal and open it manually
azd up fails on registry pushAdmin user not enabled on the Container RegistryAzure Portal -> your ACR -> Settings -> Access keys -> enable Admin user
General “something’s wrong with my setup”Any local environment issueRun aspire doctor for automated diagnostics

26. Where to go from here

  • Add authentication to the API and pass the Next.js session token through server-side fetch calls
  • Add a background worker project (builder.AddProject<Projects.Worker>(...)) and watch it show up in the same dashboard
  • Try the testing pattern from Section 20 against your own resource graph
  • Explore the Aspire Community Toolkit for integrations beyond the official set
  • Browse the official Aspire docs and aspire.dev for anything this article didn’t cover

Aspire’s real value isn’t any single feature — it’s that your local dev environment, your debugging story, your tests, and your first deployment target all come from the same resource graph. Once you’ve run one command that starts a database, a cache, an API, and a Next.js app with zero manual wiring, full distributed tracing, and a real integration test against real containers, going back to a stack of terminal windows and a hand-maintained README feels like a downgrade.


27. Source code & further reading

Full source code

The complete, working project from this article — every file shown across all 27 sections, already wired together — is available on GitHub:

ASPIRE_DEMO Repository 
clone it, run aspire run from AppHost/, and you’ll have the exact Postgres + Redis + ASP.NET Core API + Next.js stack described in this guide running locally within a few minutes. Includes the fixed
ApiService.Dockerfile, both .dockerignore files, and docker-compose.prod.yml from Section 16–18.

If you spot something in the repo that’s drifted from this article (Aspire moves fast — see the version note in Section 8), please open an issue or a PR; both are welcome.

Official Aspire resources

Deployment references (Section 18)

Picture of Anh Do Ngoc

Anh Do Ngoc

Suggested Article

Scroll to Top