Skip to content

Instantly share code, notes, and snippets.

@kzu
Last active June 24, 2026 17:11
Show Gist options
  • Select an option

  • Save kzu/e9a7799a7aa3595cdc5897fd30f263e9 to your computer and use it in GitHub Desktop.

Select an option

Save kzu/e9a7799a7aa3595cdc5897fd30f263e9 to your computer and use it in GitHub Desktop.
File-based .NET Aspire AppHost (generic pattern)

File-based Aspire AppHost (generic pattern)

Reusable extraction of the core orchestration technique using a C# source file as the Aspire DistributedApplication host (via SmallSharp), stable dev tunnels, port pinning, dependency ordering with WaitFor, a JavaScript frontend via AddJavaScriptApp, and a .slnx for one-click F5 in Visual Studio and aspire run.

No project-specific names, IDs, ports, or dependencies are included.

Quick start

  1. Place apphost.cs and Aspire/Aspire.csproj in your tree.
  2. Add the Aspire wrapper to your .slnx.
  3. dotnet restore
  4. aspire run or F5 the Aspire project.

Files

apphost.cs

#: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 emulator = builder.AddExecutable("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(emulator);

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();

Aspire.csproj

<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>

example.slnx

<Solution>
  <Project Path="src/Api/Api.csproj" />
  <Project Path="src/Aspire/Aspire.csproj" Id="e7383135-52f8-449d-b48e-21c789085d9b" />
</Solution>

aspire.config.json

{
  "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"
  }
}

api/Api.csproj

<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>

api/ServiceDefaults.cs

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;
    }
}

api/Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

var app = builder.Build();

app.MapDefaultEndpoints();

app.Run();

web/package.json

{
  "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"
  }
}

web/next.config.ts

import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  output: 'export',
  trailingSlash: false,
};

export default nextConfig;

Generalized Aspire AppHost instructions (for AGENTS.md)

Aspire AppHost (src/apphost.cs)

The file-based Aspire AppHost orchestrates local development for the API and frontend apps:

  • Starts local development dependencies (via AddExecutable) and waits for them with WaitFor.
  • 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 run

Additional notes for this setup

  • WithEndpoint + explicit Port + UriScheme pins the HTTP listener for tunnel stability.
  • AddJavaScriptApp("web", path).WithHttpEndpoint(port: NNNN, env: "PORT", name: "http") wires the PORT convention used by most JS frameworks.
  • AddDevTunnel(name, tunnelId: "stable-id") gives you a persistent public URL during dev.
  • The baseDir + ThisAssembly.Project + SmallSharp dance resolves the location of sibling projects correctly when the AppHost entrypoint is a loose .cs file.
  • Include the Aspire wrapper .csproj (SmallSharp) in your .slnx for IDE launch.
  • aspire.config.json next to the solution (or at repo root) tells Aspire where the file-based host lives and configures profiles/features.
  • For production, the same API and web projects are deployed without the AppHost (the AppHost is dev-time only).
<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>
#: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 emulator = builder.AddExecutable("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(emulator);
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();
{
"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="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>
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
trailingSlash: false,
};
export default nextConfig;
{
"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"
}
}
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
var app = builder.Build();
app.MapDefaultEndpoints();
app.Run();
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;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment