Skip to content

Configuration model for the YARP container image #3015

@davidfowl

Description

@davidfowl

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<T> because some config applies at startup before DI is available.
  6. Backward compatibleYARP_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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions