This gist demonstrates the reusable core of a file-based .NET Aspire AppHost using a single apphost.cs (via SmallSharp), pinned ports for stable dev tunnels, WaitFor for startup ordering, AddJavaScriptApp for a frontend, and a .slnx for easy F5 in Visual Studio.
No project-specific details are included.
-
Place the files under a structure like:
src/ apphost.cs Aspire/ Aspire.csproj Api/ Api.csproj Program.cs ServiceDefaults.cs Web/ package.json next.config.ts YourApp.slnx -
Add the Aspire wrapper project to your
.slnx. -
dotnet restore -
Run with
aspire runor set the Aspire project as startup and F5.
The tunnels use stable IDs and anonymous access. The web waits for the API.
The file-based Aspire AppHost orchestrates local development for the API and frontend:
- Starts local development dependencies (via
AddExecutable) and waits for them withWaitFor. - Starts the API project (pinned port) and JS frontend together.
- Pins the API to a fixed port and the web app to a fixed port so the persistent dev tunnels remain valid across runs.
- Re-hosts the stable dev tunnels with anonymous access (
allowAnonymous: true). - Waits for the API readiness before starting dependent apps (frontend).
Run it with:
aspire runWithEndpoint+ explicitPort+UriSchemepins the HTTP listener for tunnel stability.AddJavaScriptApp("web", path).WithHttpEndpoint(port: NNNN, env: "PORT", name: "http")wires the PORT convention.AddDevTunnel(name, tunnelId: "stable-id")gives a persistent public URL during dev.- The
baseDir+ThisAssembly.Project+ SmallSharp dance resolves sibling project locations correctly when the AppHost is a loose.csfile. - Include the Aspire wrapper
.csproj(SmallSharp) in your.slnxfor IDE launch. aspire.config.jsontells Aspire where the file-based host lives.- The AppHost is for local dev only.
#:sdk Aspire.AppHost.Sdk@13.3.5
#:package Aspire.Hosting.DevTunnels
#:package Aspire.Hosting.JavaScript
#:package ThisAssembly.Project
var builder = DistributedApplication.CreateBuilder(args);
const int apiPort = 5000;
const int webPort = 3000;
var devEmulator = builder.AddExecutable("dev-emulator", "node", builder.AppHostDirectory,
"-e", "setInterval(() => {}, 3600_000);");
var baseDir = AppContext.GetData("EntryPointFileDirectoryPath") as string ??
Path.GetFullPath(Path.Combine(ThisAssembly.Project.MSBuildProjectDirectory, ".."));
var api = builder.AddProject("api", Path.Combine(baseDir, "Api/Api.csproj"))
.WithEndpoint("http", endpoint =>
{
endpoint.Port = apiPort;
endpoint.UriScheme = "http";
})
.WaitFor(devEmulator);
builder.AddDevTunnel("api-tunnel", tunnelId: "app-api")
.WithReference(api.GetEndpoint("http"), allowAnonymous: true);
var web = builder.AddJavaScriptApp("web", Path.Combine(baseDir, "Web"))
.WithHttpEndpoint(port: webPort, env: "PORT", name: "http")
.WaitFor(api);
builder.AddDevTunnel("web-tunnel", tunnelId: "app-web")
.WithReference(web.GetEndpoint("http"), allowAnonymous: true);
builder.Build().Run();<Project Sdk="SmallSharp/2.3.4">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework Condition="$(TargetFramework) == ''">net10.0</TargetFramework>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\apphost.cs" Link="apphost.cs" />
</ItemGroup>
</Project><Solution>
<Project Path="src/Api/Api.csproj" />
<Project Path="src/Aspire/Aspire.csproj" Id="e7383135-52f8-449d-b48e-21c789085d9b" />
</Solution>{
"appHost": {
"path": "src/apphost.cs"
},
"profiles": {
"https": {
"applicationUrl": "https://localhost:17101;http://localhost:15214",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21231",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23181",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22092"
}
},
"http": {
"applicationUrl": "http://localhost:15214",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19043",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18106",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20036",
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true"
}
}
},
"features": {
"defaultWatchEnabled": "true"
}
}<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
public static class ServiceDefaults
{
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation();
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation();
if (bool.TryParse(builder.Configuration["OTEL_CONSOLE"], out var console) && console)
tracing.AddConsoleExporter();
});
builder.AddOpenTelemetryExporters();
return builder;
}
static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
if (app.Environment.IsDevelopment())
{
app.MapHealthChecks("/health");
app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });
}
return app;
}
}var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
var app = builder.Build();
app.MapDefaultEndpoints();
app.Run();{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^15",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
trailingSlash: false,
};
export default nextConfig;