Configuration model for the YARP container image
- Dominant language
- C#
- Stars
- 9.6k
- Forks
- 933
- Avg merge
- 12d 18h
- Merged PRs (30d)
- 2
Description
# 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
1. **`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.
2. **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.
3. **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.
4. **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.
5. **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` because some config applies at startup before DI is available.
6. **Backward compatible** — `YARP_ENABLE_STATIC_FILES` continues to work, mapped to `StaticFiles:Enabled`.
7. **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
```json
{
"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
```json
{
"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()`. This means the options classes must be source-generator-friendly (public properties, parameterless constructors, no complex converters).
```csharp
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? Headers { get; set; }
public List? 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? ErrorPages { get; set; }
}
public class NavigationFallbackOptions
{
public string? Path { get; set; }
public List? 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 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:
```bash
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:
```bash
Headers__0__Match=/_astro/*
Headers__0__Set__Cache-Control=public, max-age=31536000, immutable
```
### Container usage
```yaml
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:
```json
{
"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:
```json
{
"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
```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
```caddyfile
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) |
Contributor guide
Assessment
This issue has not been assessed yet.