diff --git a/samples/README.md b/samples/README.md
index 041479614..4f385476a 100644
--- a/samples/README.md
+++ b/samples/README.md
@@ -27,3 +27,4 @@ The following samples are provided:
| [Lets Encrypt](ReverseProxy.LetsEncrypt.Sample) | Shows how to use a certificate authority such as Lets Encrypt to set up TLS termination in YARP. |
| [Kubernetes Ingress](KubernetesIngress.Sample) | Shows how to use YARP as a Kubernetes ingress controller |
| [Prometheus](Prometheus) | Shows how to consume the YARP telemetry library and export metrics to external telemetry such as Prometheus |
+| [Static Site](StaticSite) | Shows how to use the YARP container application as a static file host with SPA fallback |
diff --git a/samples/StaticSite/README.md b/samples/StaticSite/README.md
new file mode 100644
index 000000000..9646d86eb
--- /dev/null
+++ b/samples/StaticSite/README.md
@@ -0,0 +1,23 @@
+# Static Site Sample
+
+A minimal static site served by the YARP container application.
+
+## Run
+
+```bash
+# From the repo root
+dotnet run --project src/Application/Yarp.Application.csproj -- samples/StaticSite/yarp-config.json
+```
+
+Then open http://localhost:5000
+
+## What's in the box
+
+```text
+wwwroot/
+ index.html # Home page
+ 404.html # Custom error page
+ _astro/main.a1b2c3.css # Hashed asset (simulates Astro build output)
+ docs/getting-started/index.html # Nested page with directory default document
+yarp-config.json # YARP container configuration
+```
diff --git a/samples/StaticSite/wwwroot/404.html b/samples/StaticSite/wwwroot/404.html
new file mode 100644
index 000000000..a7e731166
--- /dev/null
+++ b/samples/StaticSite/wwwroot/404.html
@@ -0,0 +1,13 @@
+
+
+
+
+ 404 - Page Not Found
+
+
+
+ 404 - Page Not Found
+ The page you're looking for doesn't exist.
+ Go home
+
+
diff --git a/samples/StaticSite/wwwroot/_astro/main.a1b2c3.css b/samples/StaticSite/wwwroot/_astro/main.a1b2c3.css
new file mode 100644
index 000000000..4677c9d65
--- /dev/null
+++ b/samples/StaticSite/wwwroot/_astro/main.a1b2c3.css
@@ -0,0 +1,10 @@
+body {
+ font-family: system-ui, -apple-system, sans-serif;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 2rem;
+ color: #333;
+}
+nav { margin-bottom: 2rem; }
+nav a { margin-right: 1rem; }
+h1 { color: #512bd4; }
diff --git a/samples/StaticSite/wwwroot/docs/getting-started/index.html b/samples/StaticSite/wwwroot/docs/getting-started/index.html
new file mode 100644
index 000000000..ad7b143a9
--- /dev/null
+++ b/samples/StaticSite/wwwroot/docs/getting-started/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Getting Started - YARP
+
+
+
+ Getting Started
+ Learn how to use YARP as a static file host and reverse proxy.
+
+
diff --git a/samples/StaticSite/wwwroot/index.html b/samples/StaticSite/wwwroot/index.html
new file mode 100644
index 000000000..3ec5b6a3a
--- /dev/null
+++ b/samples/StaticSite/wwwroot/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+ YARP Static Site
+
+
+
+
+ Home |
+ Getting Started |
+ About
+
+ Welcome to YARP
+ This is a static site served by the YARP container.
+
+
diff --git a/samples/StaticSite/yarp-config.json b/samples/StaticSite/yarp-config.json
new file mode 100644
index 000000000..45a69ca7c
--- /dev/null
+++ b/samples/StaticSite/yarp-config.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "../../src/Application/yarp-config.schema.json",
+
+ "StaticFiles": {
+ "Enabled": true
+ },
+
+ "NavigationFallback": {
+ "Path": "/index.html"
+ }
+}
diff --git a/src/Application/Configuration/NavigationFallbackOptions.cs b/src/Application/Configuration/NavigationFallbackOptions.cs
new file mode 100644
index 000000000..fb724e118
--- /dev/null
+++ b/src/Application/Configuration/NavigationFallbackOptions.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Yarp.Application.Configuration;
+
+public sealed class NavigationFallbackOptions
+{
+ public string? Path { get; set; }
+}
diff --git a/src/Application/Configuration/StaticFilesOptions.cs b/src/Application/Configuration/StaticFilesOptions.cs
new file mode 100644
index 000000000..12d4461f4
--- /dev/null
+++ b/src/Application/Configuration/StaticFilesOptions.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Yarp.Application.Configuration;
+
+public sealed class StaticFilesOptions
+{
+ public bool Enabled { get; set; }
+}
diff --git a/src/Application/Configuration/TelemetryOptions.cs b/src/Application/Configuration/TelemetryOptions.cs
new file mode 100644
index 000000000..56401dda5
--- /dev/null
+++ b/src/Application/Configuration/TelemetryOptions.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Yarp.Application.Configuration;
+
+public sealed class TelemetryOptions
+{
+ public bool UnsafeAcceptAnyCertificate { get; set; }
+}
diff --git a/src/Application/Configuration/TestConfiguration.cs b/src/Application/Configuration/TestConfiguration.cs
new file mode 100644
index 000000000..314e3d104
--- /dev/null
+++ b/src/Application/Configuration/TestConfiguration.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Configuration;
+
+///
+/// Test hook that allows WebApplicationFactory to inject configuration
+/// before the app binds its config model.
+/// Uses AsyncLocal to flow state from test to app without shared mutable state.
+/// See: https://github.com/dotnet/aspnetcore/issues/37680
+///
+internal static class TestConfiguration
+{
+ internal static readonly AsyncLocal?> _current = new();
+
+ public static IConfigurationBuilder AddTestConfiguration(this IConfigurationBuilder configurationBuilder)
+ {
+ if (_current.Value is { } configure)
+ {
+ configure(configurationBuilder);
+ _current.Value = null;
+ }
+
+ return configurationBuilder;
+ }
+
+ public static void Create(Action action)
+ {
+ _current.Value = action;
+ }
+}
diff --git a/src/Application/Configuration/YarpAppConfig.cs b/src/Application/Configuration/YarpAppConfig.cs
new file mode 100644
index 000000000..c55e0439f
--- /dev/null
+++ b/src/Application/Configuration/YarpAppConfig.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Yarp.Application.Configuration;
+
+///
+/// Root configuration for the YARP container application.
+/// Bound from IConfiguration at startup — all application code uses this object model.
+///
+public sealed class YarpAppConfig
+{
+ public StaticFilesOptions StaticFiles { get; set; } = new();
+ public NavigationFallbackOptions NavigationFallback { get; set; } = new();
+ public TelemetryOptions Telemetry { get; set; } = new();
+}
diff --git a/src/Application/Configuration/YarpAppConfigBinder.cs b/src/Application/Configuration/YarpAppConfigBinder.cs
new file mode 100644
index 000000000..5ecffec4e
--- /dev/null
+++ b/src/Application/Configuration/YarpAppConfigBinder.cs
@@ -0,0 +1,60 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace Yarp.Application.Configuration;
+
+///
+/// Binds IConfiguration into the YarpAppConfig object model.
+/// This is the single conversion point — all application code uses the resulting object model.
+///
+public static class YarpAppConfigBinder
+{
+ public static YarpAppConfig Bind(IConfiguration configuration)
+ {
+ var config = new YarpAppConfig();
+
+ configuration.GetSection(nameof(config.StaticFiles)).Bind(config.StaticFiles);
+ configuration.GetSection(nameof(config.NavigationFallback)).Bind(config.NavigationFallback);
+ configuration.GetSection(nameof(config.Telemetry)).Bind(config.Telemetry);
+
+ // Legacy env var support
+ MapLegacyKeys(configuration, config);
+
+ return config;
+ }
+
+ private static void MapLegacyKeys(IConfiguration configuration, YarpAppConfig config)
+ {
+ // YARP_ENABLE_STATIC_FILES -> StaticFiles.Enabled
+ if (!config.StaticFiles.Enabled
+ && string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase))
+ {
+ config.StaticFiles.Enabled = true;
+ }
+
+ // YARP_DISABLE_SPA_FALLBACK -> NavigationFallback.Path = null
+ // Legacy: when static files were enabled, SPA fallback defaulted to on.
+ // If the new config sets NavigationFallback.Path, that takes precedence.
+ if (config.StaticFiles.Enabled
+ && config.NavigationFallback.Path is null
+ && !string.Equals(configuration["YARP_DISABLE_SPA_FALLBACK"], "true", StringComparison.OrdinalIgnoreCase))
+ {
+ // Legacy behavior: SPA fallback was on by default when static files were enabled
+ // Only apply if using legacy keys (no explicit NavigationFallback section)
+ if (!configuration.GetSection(nameof(config.NavigationFallback)).Exists()
+ && string.Equals(configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase))
+ {
+ config.NavigationFallback.Path = "/index.html";
+ }
+ }
+
+ // YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE -> Telemetry.UnsafeAcceptAnyCertificate
+ if (!config.Telemetry.UnsafeAcceptAnyCertificate
+ && string.Equals(configuration["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"], "true", StringComparison.OrdinalIgnoreCase))
+ {
+ config.Telemetry.UnsafeAcceptAnyCertificate = true;
+ }
+ }
+}
diff --git a/src/Application/Extensions.cs b/src/Application/Extensions.cs
index d12a6440c..cbaca4a97 100644
--- a/src/Application/Extensions.cs
+++ b/src/Application/Extensions.cs
@@ -11,18 +11,15 @@
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
-using static System.Net.WebRequestMethods;
+using Yarp.Application.Configuration;
namespace Microsoft.Extensions.Hosting;
-// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
-// This project should be referenced by each service project in your solution.
-// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
- public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ public static TBuilder AddServiceDefaults(this TBuilder builder, YarpAppConfig config) where TBuilder : IHostApplicationBuilder
{
- builder.ConfigureOpenTelemetry();
+ builder.ConfigureOpenTelemetry(config);
builder.AddDefaultHealthChecks();
@@ -31,7 +28,7 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where
return builder;
}
- public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder, YarpAppConfig config) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
@@ -62,7 +59,7 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w
.AddOtlpExporter();
});
- if (string.Equals(builder.Configuration["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"], "true", StringComparison.OrdinalIgnoreCase))
+ if (config.Telemetry.UnsafeAcceptAnyCertificate)
{
// We cannot use UseOtlpExporter() since it doesn't support configuration via OtlpExporterOptions
// https://github.com/open-telemetry/opentelemetry-dotnet/issues/5802
diff --git a/src/Application/Features/LoggingFeature.cs b/src/Application/Features/LoggingFeature.cs
new file mode 100644
index 000000000..cf1220d1b
--- /dev/null
+++ b/src/Application/Features/LoggingFeature.cs
@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Console;
+using Yarp.Application.Configuration;
+
+namespace Yarp.Application.Features;
+
+public static class LoggingFeature
+{
+ ///
+ /// Suppress noisy framework logs on the console provider in default mode.
+ /// Other providers (OTEL, etc.) still receive everything.
+ ///
+ public static ILoggingBuilder ConfigureDefaultLogging(this ILoggingBuilder logging, IConfiguration configuration)
+ {
+ // Suppress noisy framework logs on console by default
+ logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
+ logging.AddFilter("Microsoft.AspNetCore.DataProtection", LogLevel.None);
+ logging.AddFilter("Microsoft.Hosting.Lifetime", LogLevel.None);
+ logging.AddFilter("Yarp.ReverseProxy", LogLevel.Warning);
+
+ // Re-add logging configuration so user overrides in the Logging section
+ // take precedence over our defaults above
+ logging.AddConfiguration(configuration.GetSection("Logging"));
+
+ return logging;
+ }
+
+ ///
+ /// Print startup banner showing what's configured.
+ /// Written directly to Console so it always shows regardless of log level.
+ ///
+ public static void PrintBanner(YarpAppConfig config, string? configFilePath, WebApplication app)
+ {
+ Console.WriteLine();
+ Console.WriteLine("YARP");
+ Console.WriteLine();
+
+ if (configFilePath is not null)
+ {
+ Console.WriteLine($" Config: {configFilePath}");
+ }
+
+ if (config.StaticFiles.Enabled)
+ {
+ var webRoot = app.Environment.WebRootPath ?? "(not found)";
+ Console.WriteLine($" Static files: {webRoot}");
+ }
+
+ if (config.NavigationFallback.Path is not null)
+ {
+ Console.WriteLine($" SPA fallback: {config.NavigationFallback.Path}");
+ }
+
+ Console.WriteLine();
+
+ // Print listening URLs once the server has started
+ app.Lifetime.ApplicationStarted.Register(() =>
+ {
+ var addresses = app.Services.GetRequiredService()
+ .Features.Get()?.Addresses;
+
+ if (addresses is { Count: > 0 })
+ {
+ foreach (var address in addresses)
+ {
+ Console.WriteLine($" Listening on: {address}");
+ }
+ Console.WriteLine();
+ }
+ });
+ }
+}
diff --git a/src/Application/Features/NavigationFallbackFeature.cs b/src/Application/Features/NavigationFallbackFeature.cs
new file mode 100644
index 000000000..9ba98a9ac
--- /dev/null
+++ b/src/Application/Features/NavigationFallbackFeature.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Yarp.Application.Configuration;
+
+namespace Yarp.Application.Features;
+
+public static class NavigationFallbackFeature
+{
+ public static WebApplication MapNavigationFallback(this WebApplication app, YarpAppConfig config)
+ {
+ if (config.NavigationFallback.Path is not null)
+ {
+ app.MapFallbackToFile(config.NavigationFallback.Path);
+ }
+
+ return app;
+ }
+}
diff --git a/src/Application/Features/ReverseProxyFeature.cs b/src/Application/Features/ReverseProxyFeature.cs
new file mode 100644
index 000000000..2ae5b7d47
--- /dev/null
+++ b/src/Application/Features/ReverseProxyFeature.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Yarp.Application.Features;
+
+public static class ReverseProxyFeature
+{
+ public static IHostApplicationBuilder AddReverseProxy(this IHostApplicationBuilder builder)
+ {
+ builder.Services.AddServiceDiscovery();
+ builder.Services.AddReverseProxy()
+ .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
+ .AddServiceDiscoveryDestinationResolver();
+
+ return builder;
+ }
+}
diff --git a/src/Application/Features/StaticFilesFeature.cs b/src/Application/Features/StaticFilesFeature.cs
new file mode 100644
index 000000000..32dca2a31
--- /dev/null
+++ b/src/Application/Features/StaticFilesFeature.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Yarp.Application.Configuration;
+
+namespace Yarp.Application.Features;
+
+public static class StaticFilesFeature
+{
+ public static WebApplication UseStaticFiles(this WebApplication app, YarpAppConfig config)
+ {
+ if (config.StaticFiles.Enabled)
+ {
+ app.UseFileServer();
+ }
+
+ return app;
+ }
+}
diff --git a/src/Application/Program.cs b/src/Application/Program.cs
index 2a3e091f0..ead454944 100644
--- a/src/Application/Program.cs
+++ b/src/Application/Program.cs
@@ -2,51 +2,74 @@
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using Yarp.Application.Configuration;
+using Yarp.Application.Features;
-var builder = WebApplication.CreateBuilder();
-
-// Load configuration from file if passed
+// Parse config file path from args before creating the builder
+// so we can set ContentRoot/WebRoot via WebApplicationOptions
+string? configFilePath = null;
if (args.Length == 1)
{
- var configFile = args[0];
- var fileInfo = new FileInfo(configFile);
+ var fileInfo = new FileInfo(args[0]);
if (!fileInfo.Exists)
{
- Console.Error.WriteLine($"Could not find '{configFile}'.");
+ Console.Error.WriteLine($"Could not find '{args[0]}'.");
return 2;
}
- builder.Configuration.AddJsonFile(fileInfo.FullName, optional: false, reloadOnChange: true);
- builder.Configuration.AddEnvironmentVariables();
+ configFilePath = fileInfo.FullName;
}
-// Configure YARP
-builder.AddServiceDefaults();
-builder.Services.AddReverseProxy()
- .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
- .AddServiceDiscoveryDestinationResolver();
-
-var app = builder.Build();
-var enableStaticFiles = string.Equals(app.Configuration["YARP_ENABLE_STATIC_FILES"], "true", StringComparison.OrdinalIgnoreCase);
-if (enableStaticFiles)
+WebApplicationOptions options;
+if (configFilePath is not null)
{
- app.UseFileServer();
+ var configDir = Path.GetDirectoryName(configFilePath) ?? Directory.GetCurrentDirectory();
+ options = new WebApplicationOptions
+ {
+ ContentRootPath = configDir,
+ WebRootPath = Path.Combine(configDir, "wwwroot")
+ };
+}
+else
+{
+ options = new WebApplicationOptions();
}
-app.UseRouting();
-app.MapReverseProxy();
-if (enableStaticFiles)
+var builder = WebApplication.CreateBuilder(options);
+
+// Suppress noisy framework logs on console by default
+builder.Logging.ConfigureDefaultLogging(builder.Configuration);
+
+// Load configuration from file if passed
+if (configFilePath is not null)
{
- var disableSpaFallback = string.Equals(app.Configuration["YARP_DISABLE_SPA_FALLBACK"], "true", StringComparison.OrdinalIgnoreCase);
- if (!disableSpaFallback)
- {
- app.MapFallbackToFile("index.html");
- }
+ builder.Configuration.AddJsonFile(configFilePath, optional: false, reloadOnChange: true);
+ builder.Configuration.AddEnvironmentVariables();
}
+// Test hook: allows WebApplicationFactory to inject config before binding
+// See: https://github.com/dotnet/aspnetcore/issues/37680
+builder.Configuration.AddTestConfiguration();
+
+// Bind config into the object model — single conversion point, before Build()
+var config = YarpAppConfigBinder.Bind(builder.Configuration);
+
+// Services
+builder.AddServiceDefaults(config);
+builder.AddReverseProxy();
+
+var app = builder.Build();
+
+// Print startup banner before any middleware runs
+LoggingFeature.PrintBanner(config, configFilePath, app);
+
+// Middleware pipeline — order matters
+app.UseStaticFiles(config);
+app.UseRouting();
+app.MapReverseProxy();
+app.MapNavigationFallback(config);
+
await app.RunAsync();
return 0;
diff --git a/src/Application/README.md b/src/Application/README.md
new file mode 100644
index 000000000..a597ca668
--- /dev/null
+++ b/src/Application/README.md
@@ -0,0 +1,164 @@
+# YARP Container Application
+
+An opinionated web server and reverse proxy built on ASP.NET Core and [YARP](https://dotnet.github.io/yarp/). JSON config, no code required.
+
+## Quick Start
+
+Create a `yarp-config.json` next to your `wwwroot/` directory:
+
+```json
+{
+ "$schema": "./yarp-config.schema.json",
+
+ "StaticFiles": {
+ "Enabled": true
+ },
+ "NavigationFallback": {
+ "Path": "/index.html"
+ },
+ "ReverseProxy": {
+ "Routes": {
+ "api": {
+ "ClusterId": "backend",
+ "Match": { "Path": "/api/{**catch-all}" }
+ }
+ },
+ "Clusters": {
+ "backend": {
+ "Destinations": {
+ "d1": { "Address": "http://backend:5000" }
+ }
+ }
+ }
+ }
+}
+```
+
+Run it:
+
+```bash
+yarp ./yarp-config.json
+```
+
+```text
+YARP
+
+ Config: ./yarp-config.json
+ Static files: ./wwwroot
+ SPA fallback: /index.html
+
+ Listening on: http://localhost:5000
+```
+
+## Container Usage
+
+```yaml
+services:
+ yarp:
+ image: yarp
+ environment:
+ - StaticFiles__Enabled=true
+ - NavigationFallback__Path=/index.html
+ volumes:
+ - ./yarp-config.json:/etc/yarp-config.json
+ - ./wwwroot:/etc/wwwroot
+ command: ["/etc/yarp-config.json"]
+ ports:
+ - "5000:5000"
+```
+
+Mount the config file and `wwwroot/` into the same directory. The app uses the config file directory as the content root and serves static files from its `wwwroot/` subdirectory.
+
+Simple toggles work as environment variables. Complex config (proxy routes, etc.) goes in the JSON file.
+
+## Configuration
+
+All configuration goes through `IConfiguration` — JSON files, environment variables, or any other provider. See [`yarp-config.schema.json`](yarp-config.schema.json) for IDE autocomplete and validation.
+
+### `StaticFiles`
+
+Serve static files from `wwwroot/`.
+
+```json
+{ "StaticFiles": { "Enabled": true } }
+```
+
+### `NavigationFallback`
+
+SPA fallback — serve a file (typically `index.html`) for unmatched routes so client-side routing works.
+
+```json
+{ "NavigationFallback": { "Path": "/index.html" } }
+```
+
+### `ReverseProxy`
+
+YARP reverse proxy routes and clusters. See the [YARP configuration docs](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files) for the full reference.
+
+### `Telemetry`
+
+OTLP export uses standard `OTEL_*` environment variables. This section covers YARP-specific telemetry options.
+
+```json
+{ "Telemetry": { "UnsafeAcceptAnyCertificate": true } }
+```
+
+## Logging
+
+By default, the console shows a clean startup banner and warnings/errors only — no per-request noise. Framework logs (DataProtection, Hosting.Lifetime, etc.) are suppressed on console but still flow to other providers (OTEL).
+
+To re-enable framework logs for debugging, use the standard `Logging` config:
+
+```json
+{
+ "Logging": {
+ "Console": {
+ "LogLevel": {
+ "Microsoft.AspNetCore": "Information"
+ }
+ }
+ }
+}
+```
+
+## Architecture
+
+This is an opinionated, pre-built application — not an extensible framework. Users who need custom behavior should use the [YARP library](https://dotnet.github.io/yarp/) directly in their own ASP.NET Core app.
+
+### Project Structure
+
+```text
+Configuration/ Config model (IConfiguration → POCOs)
+ YarpAppConfig.cs Root config object
+ YarpAppConfigBinder.cs Single conversion point + legacy key mapping
+ StaticFilesOptions.cs Per-feature options
+ NavigationFallbackOptions.cs
+ TelemetryOptions.cs
+Features/ Per-feature extension methods
+ StaticFilesFeature.cs
+ NavigationFallbackFeature.cs
+ ReverseProxyFeature.cs
+ LoggingFeature.cs
+Program.cs Pipeline ordering
+Extensions.cs Service defaults (telemetry, health checks)
+yarp-config.schema.json JSON Schema for IDE support
+```
+
+### Adding a Feature
+
+1. Add options class: `Configuration/XxxOptions.cs`
+2. Add property to `YarpAppConfig.cs`
+3. Add bind line to `YarpAppConfigBinder.cs`
+4. Add feature logic: `Features/XxxFeature.cs`
+5. Add call to `Program.cs` in the correct pipeline position
+6. Add section to `yarp-config.schema.json`
+
+## Legacy Environment Variables
+
+These continue to work for backward compatibility:
+
+| Legacy Key | Maps To |
+| --- | --- |
+| `YARP_ENABLE_STATIC_FILES` | `StaticFiles:Enabled` |
+| `YARP_DISABLE_SPA_FALLBACK` | Disables `NavigationFallback:Path` |
+| `YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE` | `Telemetry:UnsafeAcceptAnyCertificate` |
diff --git a/src/Application/Yarp.Application.csproj b/src/Application/Yarp.Application.csproj
index 859d9fb29..35048b1d8 100644
--- a/src/Application/Yarp.Application.csproj
+++ b/src/Application/Yarp.Application.csproj
@@ -14,6 +14,10 @@
+
+
+
+
diff --git a/src/Application/yarp-config.schema.json b/src/Application/yarp-config.schema.json
new file mode 100644
index 000000000..35a561486
--- /dev/null
+++ b/src/Application/yarp-config.schema.json
@@ -0,0 +1,48 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "YARP Container Configuration",
+ "description": "Configuration schema for the YARP container application",
+ "type": "object",
+ "properties": {
+ "StaticFiles": {
+ "type": "object",
+ "description": "Static file serving configuration",
+ "properties": {
+ "Enabled": {
+ "type": "boolean",
+ "default": false,
+ "description": "Enable static file serving from wwwroot"
+ }
+ },
+ "additionalProperties": false
+ },
+ "NavigationFallback": {
+ "type": "object",
+ "description": "SPA navigation fallback configuration",
+ "properties": {
+ "Path": {
+ "type": "string",
+ "description": "File to serve for unmatched non-file routes (e.g., /index.html)"
+ }
+ },
+ "additionalProperties": false
+ },
+ "Telemetry": {
+ "type": "object",
+ "description": "Telemetry configuration. OTLP export uses standard OTEL_* env vars.",
+ "properties": {
+ "UnsafeAcceptAnyCertificate": {
+ "type": "boolean",
+ "default": false,
+ "description": "Skip TLS validation for the OTLP exporter (dev/test only)"
+ }
+ },
+ "additionalProperties": false
+ },
+ "ReverseProxy": {
+ "type": "object",
+ "description": "YARP reverse proxy configuration. See https://microsoft.github.io/reverse-proxy/articles/config-files.html"
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/test/Application.Tests/SpaFallbackTests.cs b/test/Application.Tests/SpaFallbackTests.cs
index d3294aab1..9d432a813 100644
--- a/test/Application.Tests/SpaFallbackTests.cs
+++ b/test/Application.Tests/SpaFallbackTests.cs
@@ -2,32 +2,72 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Net;
+using System.Text.Json;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Xunit;
+using Yarp.Application.Configuration;
namespace Yarp.Application.Tests;
-public class SpaFallbackTests
+internal class YarpTestApp : WebApplicationFactory
{
- private static WebApplicationFactory CreateFactory(Dictionary? config = null)
+ private Action? _configAction;
+
+ public void ConfigureConfiguration(Action configure)
+ {
+ _configAction += configure;
+ }
+
+ public void Configure(YarpAppConfig config)
+ {
+ ConfigureConfiguration(b =>
+ {
+ var json = JsonSerializer.Serialize(config);
+ using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json));
+ b.AddJsonStream(stream);
+ });
+ }
+
+ public void Configure(Dictionary config)
+ {
+ ConfigureConfiguration(b => b.AddInMemoryCollection(config));
+ }
+
+ protected override IWebHostBuilder? CreateWebHostBuilder()
+ {
+ if (_configAction is { } a)
+ {
+ TestConfiguration.Create(a);
+ }
+
+ return base.CreateWebHostBuilder();
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
- var factory = new WebApplicationFactory()
- .WithWebHostBuilder(builder =>
- {
- builder.UseWebRoot(Path.Combine(AppContext.BaseDirectory, "wwwroot"));
+ builder.UseWebRoot(Path.Combine(AppContext.BaseDirectory, "wwwroot"));
+ }
+}
- if (config is not null)
- {
- builder.ConfigureAppConfiguration((context, configBuilder) =>
- {
- configBuilder.AddInMemoryCollection(config);
- });
- }
- });
+public class SpaFallbackTests
+{
+ private static YarpTestApp CreateApp(YarpAppConfig config)
+ {
+ var app = new YarpTestApp();
+ app.Configure(config);
+ return app;
+ }
- return factory;
+ private static YarpTestApp CreateApp(Dictionary? config = null)
+ {
+ var app = new YarpTestApp();
+ if (config is not null)
+ {
+ app.Configure(config);
+ }
+ return app;
}
private static Dictionary StaticFilesEnabled() => new()
@@ -38,7 +78,7 @@ private static WebApplicationFactory CreateFactory(Dictionary()
{
["YARP_ENABLE_STATIC_FILES"] = "true",
["YARP_DISABLE_SPA_FALLBACK"] = "true"
@@ -120,7 +160,7 @@ public async Task SpaFallback_Disabled_Returns404_ForUnknownRoutes()
[Fact]
public async Task SpaFallback_Disabled_StillServesStaticFiles()
{
- using var factory = CreateFactory(new()
+ using var factory = CreateApp(new Dictionary()
{
["YARP_ENABLE_STATIC_FILES"] = "true",
["YARP_DISABLE_SPA_FALLBACK"] = "true"
@@ -137,7 +177,7 @@ public async Task SpaFallback_Disabled_StillServesStaticFiles()
[Fact]
public async Task StaticFiles_Disabled_Returns404_ForAll()
{
- using var factory = CreateFactory();
+ using var factory = CreateApp();
using var client = factory.CreateClient();
var indexResponse = await client.GetAsync("/index.html");
@@ -154,7 +194,7 @@ public async Task SpaFallback_CoexistsWithSpecificYarpRoutes()
{
// When YARP has specific routes (e.g. /api/), non-YARP paths
// should still fall back to index.html for SPA routing
- using var factory = CreateFactory(new()
+ using var factory = CreateApp(new Dictionary()
{
["YARP_ENABLE_STATIC_FILES"] = "true",
["ReverseProxy:Routes:api:ClusterId"] = "backend",
@@ -179,7 +219,7 @@ public async Task SpaFallback_CatchAllYarpRoute_WinsOverFallback()
{
// When YARP has a catch-all route, it takes priority over the
// SPA fallback — this is expected because YARP owns all routing
- using var factory = CreateFactory(new()
+ using var factory = CreateApp(new Dictionary()
{
["YARP_ENABLE_STATIC_FILES"] = "true",
["ReverseProxy:Routes:catchall:ClusterId"] = "backend",
@@ -193,4 +233,70 @@ public async Task SpaFallback_CatchAllYarpRoute_WinsOverFallback()
var response = await client.GetAsync("/some/spa/route");
Assert.NotEqual(HttpStatusCode.OK, response.StatusCode);
}
+
+ // Tests using the strongly-typed config object model
+
+ [Fact]
+ public async Task ObjectModel_StaticFilesAndFallback()
+ {
+ using var app = CreateApp(new YarpAppConfig
+ {
+ StaticFiles = { Enabled = true },
+ NavigationFallback = { Path = "/index.html" }
+ });
+ using var client = app.CreateClient();
+
+ var cssResponse = await client.GetAsync("/style.css");
+ Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode);
+
+ var spaResponse = await client.GetAsync("/some/spa/route");
+ Assert.Equal(HttpStatusCode.OK, spaResponse.StatusCode);
+ var content = await spaResponse.Content.ReadAsStringAsync();
+ Assert.Contains("SPA Index", content);
+ }
+
+ [Fact]
+ public async Task ObjectModel_StaticFilesWithoutFallback()
+ {
+ using var app = CreateApp(new YarpAppConfig
+ {
+ StaticFiles = { Enabled = true }
+ });
+ using var client = app.CreateClient();
+
+ var cssResponse = await client.GetAsync("/style.css");
+ Assert.Equal(HttpStatusCode.OK, cssResponse.StatusCode);
+
+ var spaResponse = await client.GetAsync("/some/spa/route");
+ Assert.Equal(HttpStatusCode.NotFound, spaResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task ObjectModel_CustomFallbackPath()
+ {
+ using var app = CreateApp(new YarpAppConfig
+ {
+ StaticFiles = { Enabled = true },
+ NavigationFallback = { Path = "/index.html" }
+ });
+ using var client = app.CreateClient();
+
+ var response = await client.GetAsync("/deep/nested/route");
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var content = await response.Content.ReadAsStringAsync();
+ Assert.Contains("SPA Index", content);
+ }
+
+ [Fact]
+ public async Task ObjectModel_EverythingDisabled()
+ {
+ using var app = CreateApp(new YarpAppConfig());
+ using var client = app.CreateClient();
+
+ var indexResponse = await client.GetAsync("/index.html");
+ var cssResponse = await client.GetAsync("/style.css");
+
+ Assert.Equal(HttpStatusCode.NotFound, indexResponse.StatusCode);
+ Assert.Equal(HttpStatusCode.NotFound, cssResponse.StatusCode);
+ }
}
diff --git a/test/Application.Tests/YarpAppConfigBinderTests.cs b/test/Application.Tests/YarpAppConfigBinderTests.cs
new file mode 100644
index 000000000..c798fa801
--- /dev/null
+++ b/test/Application.Tests/YarpAppConfigBinderTests.cs
@@ -0,0 +1,139 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+using Xunit;
+using Yarp.Application.Configuration;
+
+namespace Yarp.Application.Tests;
+
+public class YarpAppConfigBinderTests
+{
+ private static YarpAppConfig Bind(Dictionary config)
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(config)
+ .Build();
+ return YarpAppConfigBinder.Bind(configuration);
+ }
+
+ // New config format
+
+ [Fact]
+ public void Bind_StaticFilesEnabled()
+ {
+ var config = Bind(new() { ["StaticFiles:Enabled"] = "true" });
+ Assert.True(config.StaticFiles.Enabled);
+ }
+
+ [Fact]
+ public void Bind_NavigationFallbackPath()
+ {
+ var config = Bind(new() { ["NavigationFallback:Path"] = "/app.html" });
+ Assert.Equal("/app.html", config.NavigationFallback.Path);
+ }
+
+ [Fact]
+ public void Bind_TelemetryUnsafeCert()
+ {
+ var config = Bind(new() { ["Telemetry:UnsafeAcceptAnyCertificate"] = "true" });
+ Assert.True(config.Telemetry.UnsafeAcceptAnyCertificate);
+ }
+
+ [Fact]
+ public void Bind_Defaults_EverythingOff()
+ {
+ var config = Bind(new());
+ Assert.False(config.StaticFiles.Enabled);
+ Assert.Null(config.NavigationFallback.Path);
+ Assert.False(config.Telemetry.UnsafeAcceptAnyCertificate);
+ }
+
+ // Legacy key mapping
+
+ [Fact]
+ public void Legacy_EnableStaticFiles()
+ {
+ var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "true" });
+ Assert.True(config.StaticFiles.Enabled);
+ }
+
+ [Fact]
+ public void Legacy_EnableStaticFiles_ImpliesFallback()
+ {
+ var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "true" });
+ Assert.Equal("/index.html", config.NavigationFallback.Path);
+ }
+
+ [Fact]
+ public void Legacy_DisableSpaFallback()
+ {
+ var config = Bind(new()
+ {
+ ["YARP_ENABLE_STATIC_FILES"] = "true",
+ ["YARP_DISABLE_SPA_FALLBACK"] = "true"
+ });
+ Assert.True(config.StaticFiles.Enabled);
+ Assert.Null(config.NavigationFallback.Path);
+ }
+
+ [Fact]
+ public void Legacy_UnsafeCert()
+ {
+ var config = Bind(new() { ["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"] = "true" });
+ Assert.True(config.Telemetry.UnsafeAcceptAnyCertificate);
+ }
+
+ // Precedence: new config wins over legacy
+
+ [Fact]
+ public void Precedence_NewConfigSection_PreventsLegacyFallback()
+ {
+ // When NavigationFallback section exists explicitly, legacy
+ // YARP_ENABLE_STATIC_FILES doesn't auto-set fallback path
+ var config = Bind(new()
+ {
+ ["YARP_ENABLE_STATIC_FILES"] = "true",
+ ["NavigationFallback:Path"] = "/custom.html"
+ });
+ Assert.Equal("/custom.html", config.NavigationFallback.Path);
+ }
+
+ [Fact]
+ public void Precedence_ExplicitFallbackWinsOverLegacy()
+ {
+ var config = Bind(new()
+ {
+ ["YARP_ENABLE_STATIC_FILES"] = "true",
+ ["NavigationFallback:Path"] = "/custom.html"
+ });
+ // Explicit NavigationFallback takes precedence over legacy default
+ Assert.Equal("/custom.html", config.NavigationFallback.Path);
+ }
+
+ [Fact]
+ public void Precedence_NewTelemetryWinsOverLegacy()
+ {
+ var config = Bind(new()
+ {
+ ["Telemetry:UnsafeAcceptAnyCertificate"] = "true",
+ ["YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE"] = "false"
+ });
+ Assert.True(config.Telemetry.UnsafeAcceptAnyCertificate);
+ }
+
+ [Fact]
+ public void Legacy_StaticFilesDisabled_NoFallback()
+ {
+ // Without YARP_ENABLE_STATIC_FILES, no fallback should be set
+ var config = Bind(new());
+ Assert.Null(config.NavigationFallback.Path);
+ }
+
+ [Fact]
+ public void Legacy_CaseInsensitive()
+ {
+ var config = Bind(new() { ["YARP_ENABLE_STATIC_FILES"] = "True" });
+ Assert.True(config.StaticFiles.Enabled);
+ }
+}