Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save kzu/e459c5b353c2560cc805edcd10ff4e20 to your computer and use it in GitHub Desktop.
File-based .NET Aspire AppHost pattern (generic). MCP create attempted (403), created via API then updated for subdir files.

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 �spire run.

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

Quick start

  1. Copy files preserving the api/ and web/ layout.
  2. Add the Aspire wrapper to your .slnx.
  3. dotnet restore
  4. aspire run or F5 the Aspire entry.

apphost.cs

`csharp #: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

`xml Exe net10.0 true

`

example.slnx

`xml

`

aspire.config.json

`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

`xml net10.0

`

api/ServiceDefaults.cs

`csharp 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(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
{
    if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]))
    {
        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

`csharp var builder = WebApplication.CreateBuilder(args); builder.AddServiceDefaults(); var app = builder.Build(); app.MapDefaultEndpoints(); app.Run();

`

web/package.json

`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

` s import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'export', trailingSlash: false }; export default nextConfig;

`

Generalized AGENTS.md (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 (�llowAnonymous: true).
  • Waits for the API readiness before starting dependent apps (frontend).

Run it with: shell 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.
  • AddDevTunnel(name, tunnelId: "stable-id") gives you a persistent public URL during dev.
  • The �aseDir + 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.
  • �spire.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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment