Skip to content

Instantly share code, notes, and snippets.

@kzu
Created June 24, 2026 16:54
Show Gist options
  • Select an option

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

Select an option

Save kzu/8a9feb7906f0babe1784c14e04f6fbef to your computer and use it in GitHub Desktop.
File-based .NET Aspire AppHost pattern (generic, reusable): apphost.cs + SmallSharp + pinned ports + dev tunnels + AddJavaScriptApp + .slnx for F5. Includes API and web boilerplate + generalized AGENTS instructions.

File-based Aspire AppHost (generic pattern)

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.

Quick start

  1. 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
    
  2. Add the Aspire wrapper project to your .slnx.

  3. dotnet restore

  4. Run with aspire run or set the Aspire project as startup and F5.

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

Generalized AGENTS.md guidance (Aspire AppHost section)

Aspire AppHost (src/apphost.cs)

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

  • 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

  • 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 a persistent public URL during dev.
  • The baseDir + ThisAssembly.Project + SmallSharp dance resolves sibling project locations correctly when the AppHost is a loose .cs file.
  • Include the Aspire wrapper .csproj (SmallSharp) in your .slnx for IDE launch.
  • aspire.config.json tells Aspire where the file-based host lives.
  • The AppHost is for local dev only.

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

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