Configuration model for the YARP container image
Context
The YARP container image (src/Application) currently uses ad-hoc flat keys for configuration (YARP_ENABLE_STATIC_FILES, YARP_DISABLE_SPA_FALLBACK, YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE). As we add more features (see #3014), this approach doesn't scale.
This issue defines a structured configuration model for the YARP container app. Individual feature issues will reference this model and describe the config blob for each feature as it's implemented.
Design principles
IConfiguration-based, any source — Today we use IConfiguration as the input, which supports JSON files, environment variables, command line args, and any other provider. JSON is the primary UX for complex config (headers, redirects). Simple toggles work naturally as env vars (StaticFiles__Enabled=true). The object model layer means we can introduce other config UX (YAML, UI) in the future without changing feature code.
- Flat top-level sections — Each feature is a peer section alongside
ReverseProxy. No wrapping namespace — this app is the entire process, there's no collision risk.
- Objects all the way down — Every section is an object, never a bare scalar.
"Compression": { "Enabled": true } not "Compression": true. This allows adding properties later without breaking existing configs.
- Unified match syntax — Every section that matches on paths (
Headers, Redirects, and future Auth) uses the same Match property with the same glob pattern format. This enables a future unified Routes array without breaking changes.
- Object model, not config — Configuration is read from
IConfiguration into a strongly-typed POCO object model at startup. All application code uses the object model, never IConfiguration directly. This decouples the config source from behavior — we can introduce a different config UX later (YAML, CLI flags, UI) without changing any middleware or feature code. We don't use IOptions<T> because some config applies at startup before DI is available.
- Backward compatible —
YARP_ENABLE_STATIC_FILES continues to work, mapped to StaticFiles:Enabled.
- Opinionated — This is a pre-built application, not an extensible framework. It does what it does. Users who need custom behavior use the YARP library directly in their own ASP.NET Core app.
Configuration schema (initial proposal)
Note: This schema is an initial proposal based on what we know today. It will evolve as we implement features — expect the shape to change as we learn what works in practice.
Full example
{
"Server": {
"Port": 8080,
"Tls": {
"Certificate": "/certs/cert.pem",
"Key": "/certs/key.pem"
}
},
"StaticFiles": {
"Enabled": true,
"CleanUrls": true,
"TrailingSlash": "never",
"PreCompressed": true,
"ErrorPages": {
"404": "/404.html"
}
},
"NavigationFallback": {
"Path": "/index.html",
"Exclude": ["/api/*", "/.well-known/*"]
},
"Compression": {
"Enabled": true
},
"Https": {
"Redirect": {
"Enabled": true
},
"Hsts": {
"Enabled": true
}
},
"Headers": [
{ "Match": "/_astro/*", "Set": { "Cache-Control": "public, max-age=31536000, immutable" } },
{ "Match": "/*.html", "Set": { "Cache-Control": "no-cache" } },
{ "Match": "/*", "Set": { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" } }
],
"Redirects": [
{ "Match": "/old-page", "Destination": "/new-page", "StatusCode": 301 },
{ "Match": "/install.sh", "Destination": "https://aka.ms/install.sh", "StatusCode": 302 }
],
"Telemetry": {
"UnsafeAcceptAnyCertificate": true
},
"ReverseProxy": {
"Routes": {
"api": {
"ClusterId": "backend",
"Match": { "Path": "/api/{**catch-all}" }
}
},
"Clusters": {
"backend": {
"Destinations": {
"d1": { "Address": "http://backend:5000" }
}
}
}
}
}
Minimal example
{
"StaticFiles": {
"Enabled": true
},
"NavigationFallback": {
"Path": "/index.html"
},
"Compression": {
"Enabled": true
}
}
Section reference
Server
Sugar for common Kestrel configuration. Full Kestrel config continues to work for advanced cases (HTTP/2, client certs, multiple endpoints).
| Property |
Type |
Default |
Description |
Port |
int? |
null |
Port to listen on (sets http://+:{port}, or https://+:{port} if TLS configured) |
Tls |
object? |
null |
TLS certificate configuration |
Tls.Certificate |
string |
|
Path to certificate file (.pem or .pfx) |
Tls.Key |
string? |
null |
Path to private key file (for .pem certs) |
StaticFiles
| Property |
Type |
Default |
Description |
Enabled |
bool |
false |
Enable static file serving from wwwroot |
CleanUrls |
bool |
false |
Serve /about from about.html |
TrailingSlash |
string? |
null |
"always", "never", or null (no normalization) |
PreCompressed |
bool |
false |
Serve .br/.gz sidecar files when client accepts |
ErrorPages |
object? |
null |
Map of status code to file path (e.g., "404": "/404.html") |
NavigationFallback
| Property |
Type |
Default |
Description |
Path |
string? |
null |
File to serve for unmatched non-file routes (e.g., /index.html) |
Exclude |
string[]? |
null |
Glob patterns to exclude from fallback |
Compression
| Property |
Type |
Default |
Description |
Enabled |
bool |
false |
Enable on-the-fly response compression (gzip/brotli) |
Https
| Property |
Type |
Default |
Description |
Redirect |
object? |
null |
HTTPS redirect configuration |
Redirect.Enabled |
bool |
false |
Redirect HTTP to HTTPS (forwarded-header aware) |
Hsts |
object? |
null |
HSTS configuration |
Hsts.Enabled |
bool |
false |
Add Strict-Transport-Security header in production |
Telemetry
OTLP export is configured via standard OTEL_* environment variables (e.g., OTEL_EXPORTER_OTLP_ENDPOINT). This section covers YARP-specific telemetry options. Legacy env var YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE maps to Telemetry:UnsafeAcceptAnyCertificate.
| Property |
Type |
Default |
Description |
UnsafeAcceptAnyCertificate |
bool |
false |
Skip TLS validation for the OTLP exporter (dev/test only) |
Headers
Array of rules. All matching rules are applied (accumulated, not first-match).
| Property |
Type |
Description |
Match |
string |
Glob pattern for request path |
Set |
object |
Key-value pairs of headers to set on the response |
Redirects
Array of rules. First match wins.
| Property |
Type |
Default |
Description |
Match |
string |
|
Glob pattern for request path |
Destination |
string |
|
Target URL (relative or absolute) |
StatusCode |
int |
301 |
HTTP status code (301, 302, 307, 308) |
Options classes
There is a single IConfiguration → rich object model conversion step at startup. All application code references the object model — never IConfiguration directly. This means the object model can be populated from any config source in the future without changing feature code.
The conversion must be AOT-compatible. We use the configuration binding source generator (Microsoft.Extensions.Configuration.Binder source gen) rather than reflection-based GetSection().Get<T>(). This means the options classes must be source-generator-friendly (public properties, parameterless constructors, no complex converters).
public class YarpAppConfig
{
public ServerOptions Server { get; set; } = new();
public StaticFilesOptions StaticFiles { get; set; } = new();
public NavigationFallbackOptions NavigationFallback { get; set; } = new();
public CompressionOptions Compression { get; set; } = new();
public HttpsOptions Https { get; set; } = new();
public TelemetryOptions Telemetry { get; set; } = new();
public List<HeaderRule>? Headers { get; set; }
public List<RedirectRule>? Redirects { get; set; }
}
```csharp
public class ServerOptions
{
public int? Port { get; set; }
public TlsOptions? Tls { get; set; }
}
public class TlsOptions
{
public required string Certificate { get; set; }
public string? Key { get; set; }
}
public class StaticFilesOptions
{
public bool Enabled { get; set; }
public bool CleanUrls { get; set; }
public string? TrailingSlash { get; set; }
public bool PreCompressed { get; set; }
public Dictionary<int, string>? ErrorPages { get; set; }
}
public class NavigationFallbackOptions
{
public string? Path { get; set; }
public List<string>? Exclude { get; set; }
}
public class CompressionOptions
{
public bool Enabled { get; set; }
}
public class HttpsOptions
{
public RedirectOptions? Redirect { get; set; }
public HstsOptions? Hsts { get; set; }
}
public class TelemetryOptions
{
public bool UnsafeAcceptAnyCertificate { get; set; }
}
public class HeaderRule
{
public required string Match { get; set; }
public required Dictionary<string, string> Set { get; set; }
}
public class RedirectRule
{
public required string Match { get; set; }
public required string Destination { get; set; }
public int StatusCode { get; set; } = 301;
}
Environment variables
Simple toggles:
Server__Port=8080
Server__Tls__Certificate=/certs/cert.pem
Server__Tls__Key=/certs/key.pem
StaticFiles__Enabled=true
StaticFiles__CleanUrls=true
StaticFiles__TrailingSlash=never
NavigationFallback__Path=/index.html
Compression__Enabled=true
Https__Redirect__Enabled=true
Telemetry__UnsafeAcceptAnyCertificate=true
Arrays work but JSON is the expected path for these:
Headers__0__Match=/_astro/*
Headers__0__Set__Cache-Control=public, max-age=31536000, immutable
Container usage
services:
yarp:
image: yarp
environment:
- StaticFiles__Enabled=true
- Compression__Enabled=true
- NavigationFallback__Path=/index.html
volumes:
- ./yarp-config.json:/app/config.json
- ./wwwroot:/app/wwwroot
command: ["/app/config.json"]
Future extensibility
Unified route rules
The Match property uses the same glob syntax across Headers, Redirects, and future sections. This enables a future unified Routes array:
{
"Routes": [
{ "Match": "/admin/*", "Auth": { "AllowedRoles": ["admin"] } },
{ "Match": "/_astro/*", "Headers": { "Cache-Control": "immutable" } },
{ "Match": "/old-page", "Redirect": { "Destination": "/new-page" } }
]
}
The separate sections would continue to work alongside a unified Routes array -- no breaking change needed.
Adding new features
New features add properties to existing sections or new top-level sections. For example:
{
"Compression": {
"Enabled": true,
"MinSize": 1024,
"MimeTypes": ["text/*", "application/javascript"]
},
"Https": {
"Redirect": {
"Enabled": true
},
"Hsts": {
"MaxAge": 31536000,
"Preload": true,
"IncludeSubDomains": true
}
}
}
Comparison with nginx and Caddy
The same Astro docs site configured on all three platforms:
nginx
server {
listen 443 ssl http2;
server_name docs.example.com;
root /var/www/site;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_static on;
brotli_static on;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
error_page 404 /404.html;
location = /404.html { internal; }
location = /old-page { return 301 /new-page; }
location = /install.sh { return 302 https://aka.ms/install.sh; }
location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location ~* \.html$ {
add_header Cache-Control "no-cache" always;
}
location /api/ { proxy_pass http://backend:5000; }
location / { try_files $uri $uri.html $uri/ /index.html; }
rewrite ^(.+)/$ $1 permanent;
}
server {
listen 80;
return 301 https://$host$request_uri;
}
Caddy
docs.example.com {
root * /var/www/site
encode gzip zstd br
file_server { precompressed br gzip }
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
header X-Content-Type-Options "nosniff"
header X-Frame-Options "DENY"
header /_astro/* Cache-Control "public, max-age=31536000, immutable"
header /*.html Cache-Control "no-cache"
redir /old-page /new-page 301
redir /install.sh https://aka.ms/install.sh 302
@trailing path_regexp trailing ^(.+)/$
redir @trailing {re.trailing.1} 301
handle /api/* { reverse_proxy backend:5000 }
handle {
try_files {path} {path}.html {path}/ /index.html
file_server
}
handle_errors {
@404 expression {http.error.status_code} == 404
rewrite @404 /404.html
file_server
}
}
Feature mapping
| Feature |
nginx |
Caddy |
YARP |
| Static files |
root + try_files |
root + file_server |
StaticFiles.Enabled |
| Custom 404 |
error_page 404 |
handle_errors |
StaticFiles.ErrorPages |
| Clean URLs |
try_files $uri.html |
try_files {path}.html |
StaticFiles.CleanUrls |
| Trailing slash |
rewrite |
path_regexp + redir |
StaticFiles.TrailingSlash |
| Pre-compressed |
gzip_static/brotli_static |
precompressed br gzip |
StaticFiles.PreCompressed |
| SPA fallback |
try_files /index.html |
try_files /index.html |
NavigationFallback.Path |
| Fallback exclusions |
location /api/ |
handle /api/* |
NavigationFallback.Exclude |
| Compression |
gzip on |
encode gzip zstd br |
Compression.Enabled |
| HTTPS redirect |
return 301 https:// |
automatic |
Https.Redirect |
| HSTS |
add_header |
header |
Https.Hsts |
| Headers per path |
add_header per location |
header per path |
Headers[].Match + .Set |
| Redirects |
return 301 per location |
redir |
Redirects[].Match + .Destination |
| Reverse proxy |
proxy_pass |
reverse_proxy |
ReverseProxy (existing) |
Configuration model for the YARP container image
Context
The YARP container image (
src/Application) currently uses ad-hoc flat keys for configuration (YARP_ENABLE_STATIC_FILES,YARP_DISABLE_SPA_FALLBACK,YARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATE). As we add more features (see #3014), this approach doesn't scale.This issue defines a structured configuration model for the YARP container app. Individual feature issues will reference this model and describe the config blob for each feature as it's implemented.
Design principles
IConfiguration-based, any source — Today we useIConfigurationas the input, which supports JSON files, environment variables, command line args, and any other provider. JSON is the primary UX for complex config (headers, redirects). Simple toggles work naturally as env vars (StaticFiles__Enabled=true). The object model layer means we can introduce other config UX (YAML, UI) in the future without changing feature code.ReverseProxy. No wrapping namespace — this app is the entire process, there's no collision risk."Compression": { "Enabled": true }not"Compression": true. This allows adding properties later without breaking existing configs.Headers,Redirects, and futureAuth) uses the sameMatchproperty with the same glob pattern format. This enables a future unifiedRoutesarray without breaking changes.IConfigurationinto a strongly-typed POCO object model at startup. All application code uses the object model, neverIConfigurationdirectly. This decouples the config source from behavior — we can introduce a different config UX later (YAML, CLI flags, UI) without changing any middleware or feature code. We don't useIOptions<T>because some config applies at startup before DI is available.YARP_ENABLE_STATIC_FILEScontinues to work, mapped toStaticFiles:Enabled.Configuration schema (initial proposal)
Full example
{ "Server": { "Port": 8080, "Tls": { "Certificate": "/certs/cert.pem", "Key": "/certs/key.pem" } }, "StaticFiles": { "Enabled": true, "CleanUrls": true, "TrailingSlash": "never", "PreCompressed": true, "ErrorPages": { "404": "/404.html" } }, "NavigationFallback": { "Path": "/index.html", "Exclude": ["/api/*", "/.well-known/*"] }, "Compression": { "Enabled": true }, "Https": { "Redirect": { "Enabled": true }, "Hsts": { "Enabled": true } }, "Headers": [ { "Match": "/_astro/*", "Set": { "Cache-Control": "public, max-age=31536000, immutable" } }, { "Match": "/*.html", "Set": { "Cache-Control": "no-cache" } }, { "Match": "/*", "Set": { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" } } ], "Redirects": [ { "Match": "/old-page", "Destination": "/new-page", "StatusCode": 301 }, { "Match": "/install.sh", "Destination": "https://aka.ms/install.sh", "StatusCode": 302 } ], "Telemetry": { "UnsafeAcceptAnyCertificate": true }, "ReverseProxy": { "Routes": { "api": { "ClusterId": "backend", "Match": { "Path": "/api/{**catch-all}" } } }, "Clusters": { "backend": { "Destinations": { "d1": { "Address": "http://backend:5000" } } } } } }Minimal example
{ "StaticFiles": { "Enabled": true }, "NavigationFallback": { "Path": "/index.html" }, "Compression": { "Enabled": true } }Section reference
ServerSugar for common Kestrel configuration. Full
Kestrelconfig continues to work for advanced cases (HTTP/2, client certs, multiple endpoints).Portnullhttp://+:{port}, orhttps://+:{port}if TLS configured)TlsnullTls.CertificateTls.KeynullStaticFilesEnabledfalseCleanUrlsfalse/aboutfromabout.htmlTrailingSlashnull"always","never", ornull(no normalization)PreCompressedfalse.br/.gzsidecar files when client acceptsErrorPagesnull"404": "/404.html")NavigationFallbackPathnull/index.html)ExcludenullCompressionEnabledfalseHttpsRedirectnullRedirect.EnabledfalseHstsnullHsts.EnabledfalseStrict-Transport-Securityheader in productionTelemetryOTLP export is configured via standard
OTEL_*environment variables (e.g.,OTEL_EXPORTER_OTLP_ENDPOINT). This section covers YARP-specific telemetry options. Legacy env varYARP_UNSAFE_OLTP_CERT_ACCEPT_ANY_SERVER_CERTIFICATEmaps toTelemetry:UnsafeAcceptAnyCertificate.UnsafeAcceptAnyCertificatefalseHeadersArray of rules. All matching rules are applied (accumulated, not first-match).
MatchSetRedirectsArray of rules. First match wins.
MatchDestinationStatusCode301Options classes
There is a single
IConfiguration→ rich object model conversion step at startup. All application code references the object model — neverIConfigurationdirectly. This means the object model can be populated from any config source in the future without changing feature code.The conversion must be AOT-compatible. We use the configuration binding source generator (
Microsoft.Extensions.Configuration.Bindersource gen) rather than reflection-basedGetSection().Get<T>(). This means the options classes must be source-generator-friendly (public properties, parameterless constructors, no complex converters).Environment variables
Simple toggles:
Arrays work but JSON is the expected path for these:
Headers__0__Match=/_astro/* Headers__0__Set__Cache-Control=public, max-age=31536000, immutableContainer usage
Future extensibility
Unified route rules
The
Matchproperty uses the same glob syntax acrossHeaders,Redirects, and future sections. This enables a future unifiedRoutesarray:{ "Routes": [ { "Match": "/admin/*", "Auth": { "AllowedRoles": ["admin"] } }, { "Match": "/_astro/*", "Headers": { "Cache-Control": "immutable" } }, { "Match": "/old-page", "Redirect": { "Destination": "/new-page" } } ] }The separate sections would continue to work alongside a unified
Routesarray -- no breaking change needed.Adding new features
New features add properties to existing sections or new top-level sections. For example:
{ "Compression": { "Enabled": true, "MinSize": 1024, "MimeTypes": ["text/*", "application/javascript"] }, "Https": { "Redirect": { "Enabled": true }, "Hsts": { "MaxAge": 31536000, "Preload": true, "IncludeSubDomains": true } } }Comparison with nginx and Caddy
The same Astro docs site configured on all three platforms:
nginx
Caddy
Feature mapping
root+try_filesroot+file_serverStaticFiles.Enablederror_page 404handle_errorsStaticFiles.ErrorPagestry_files $uri.htmltry_files {path}.htmlStaticFiles.CleanUrlsrewritepath_regexp+redirStaticFiles.TrailingSlashgzip_static/brotli_staticprecompressed br gzipStaticFiles.PreCompressedtry_files /index.htmltry_files /index.htmlNavigationFallback.Pathlocation /api/handle /api/*NavigationFallback.Excludegzip onencode gzip zstd brCompression.Enabledreturn 301 https://Https.Redirectadd_headerheaderHttps.Hstsadd_headerper locationheaderper pathHeaders[].Match+.Setreturn 301per locationredirRedirects[].Match+.Destinationproxy_passreverse_proxyReverseProxy(existing)