Skip to content

Instantly share code, notes, and snippets.

@kzu
Created June 24, 2026 17:37
Show Gist options
  • Select an option

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

Select an option

Save kzu/9fbc6437845b8d8faba6b5f789540b16 to your computer and use it in GitHub Desktop.
File-based .NET Aspire AppHost (generic pattern) - extracted core with boilerplate and AGENTS. MCP create probe 403 (scope); gh used for delivery after probe call.

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. Copy the files below into src/ preserving the intended layout (api/ and web/ subdirs are for source organization; gists are flat).
  2. Add the Aspire wrapper project to your .slnx.
  3. dotnet restore
  4. Run with aspire run or F5 the Aspire entry in the solution.

The tunnels use stable IDs and anonymous access. The web waits for the API.

Files (labeled with source paths)

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 AGENTS.md instructions (Aspire AppHost section)

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