Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Traefik Bundle: Typed Configuration from Traefik v3 JSON Schemas

Two JSON Schemas, two configuration lifetimes, 25 mutually-exclusive middleware types — and all of it in YAML that Traefik reads at startup or hot-reloads at runtime. What if one [TraefikBundle] attribute could turn all of that into IntelliSense-complete C# with fluent builders?

The Docker Compose Bundle showed how to merge 32 schema versions into one unified type system. The Traefik Bundle takes a different angle on the same idea. Instead of multi-version merging, the challenge here is modeling Traefik's hard split between static and dynamic configuration, and its heavy use of discriminated unions for middleware and service types. The underlying pipeline is the same: design-time schema download, build-time Roslyn generation, runtime fluent builders returning Result<T> (result-pattern). Same AbstractBuilder<T> foundation. Same philosophy: the schema is the source of truth, the compiler is the enforcement layer.

This is part of the infrastructure-as-code journey — turning every infrastructure tool into a typed, validated, IntelliSense-driven C# API.

The Problem

Traefik is one of the most widely deployed reverse proxies. It powers ingress for Docker, Kubernetes, and standalone deployments. Its configuration is YAML-based and split into two fundamentally different categories:

  • Static configuration — read once at process startup. Defines entry points (ports), providers (where routing config comes from), certificate resolvers (Let's Encrypt ACME), API dashboard, logging, metrics, and tracing. Changing static config requires a restart.
  • Dynamic configuration — hot-reloadable at runtime without downtime. Defines HTTP/TCP/UDP routers (rule matching), services (load balancers), middlewares (transforms), and TLS options. The file provider watches for changes and applies them seamlessly.

Most .NET libraries that interact with Traefik either hand-code models for a subset of the config or fall back to Dictionary<string, object>. Both approaches break as the spec evolves.

The middleware system is particularly treacherous. Traefik defines 25 HTTP middleware types — addPrefix, basicAuth, chain, headers, rateLimit, circuitBreaker, forwardAuth, compress, and 17 more — where exactly one must be set per middleware definition. YAML gives zero compile-time protection against setting two mutually exclusive middleware types on the same entry. A typo in a middleware name? Silent failure at runtime. A router pointing to a non-existent service? Traefik logs a warning but keeps running.

The SchemaStore publishes two JSON Schemas for Traefik v3: traefik-v3.json (42 KB, static config) and traefik-v3-file-provider.json (79 KB, dynamic config). Together they define 122 definitions covering every configuration surface. That is the source of truth. The question is how to turn it into a type system.

The Idea

The Traefik Bundle downloads both schemas at design time from SchemaStore, then at build time uses a Roslyn incremental source generator to parse each schema, detect its kind (static vs. dynamic), resolve discriminated unions, and emit strongly-typed models with fluent builders. The result: TraefikStaticConfig and TraefikDynamicConfig — two root classes that cover the entire Traefik v3 surface, backed by ~155 model classes and ~160 matching builders.

Diagram
The Traefik Bundle follows the same shape as the Compose one but forks on schema kind: TraefikSchemaReader routes the static and dynamic files into separate roots before emitters produce the 316 generated files.

Design time fetches schemas from SchemaStore via DesignPipeline. Build time parses each JSON Schema with TraefikSchemaReader, which detects whether a file is static or dynamic from the filename, then emits root classes, definitions, and builders. Output is ~316 generated files — all from a single [TraefikBundle] attribute.

1. Download the schemas

A design-time CLI downloads both schemas from SchemaStore:

var schemas = new (string Name, string Url)[]
{
    ("static", "https://raw.githubusercontent.com/SchemaStore/schemastore/" +
               "master/src/schemas/json/traefik-v3.json"),
    ("file-provider", "https://raw.githubusercontent.com/SchemaStore/schemastore/" +
                      "master/src/schemas/json/traefik-v3-file-provider.json"),
};

var pipeline = new DesignPipeline<(string Name, string Url)>()
    .UseHttpDownload(item => item.Url)
    .UseSave()
    .Build();

return await new DesignPipelineRunner<(string Name, string Url)>
{
    ItemCollector = new StaticItemCollector<(string Name, string Url)>(schemas),
    Pipeline = pipeline,
    KeySelector = item => item.Name,
    OutputDir = outputDir,
    OutputFilePattern = "traefik-v3-{key}.json",
}.RunAsync(args);

This produces two files: traefik-v3-static.json (42 KB) and traefik-v3-file-provider.json (79 KB). Where the Docker Compose Bundle needed a GitHub release scraper to discover 32 versions, Traefik targets two known SchemaStore URLs. Two files, two URLs, no rate-limiting needed.

2. Declare the bundle

[TraefikBundle]
public partial class TraefikBundleDescriptor;

Plus the project file wiring:

<AdditionalFiles Include="schemas\traefik-v3-*.json" />

That's it. The Roslyn incremental generator discovers the attribute, reads every matching JSON file, and emits the full type system at build time.

3. Use the generated API

Static configuration — entry points, providers, dashboard:

var result = await new TraefikStaticConfigBuilder()
    .WithEntryPoints(new Dictionary<string, TraefikStaticEntryPoint>
    {
        ["web"] = new() { Address = ":80" },
        ["websecure"] = new() { Address = ":443" }
    })
    .WithApi(new TraefikStaticAPI { Dashboard = true, Insecure = true })
    .WithProviders(new TraefikStaticProviders
    {
        File = new TraefikFileProvider { Directory = "/etc/traefik/dynamic", Watch = true }
    })
    .WithLog(new TraefikTypesTraefikLog { Level = "DEBUG" })
    .BuildAsync();

Dynamic configuration — routers, services, middlewares:

var result = await new TraefikDynamicConfigBuilder()
    .WithHttp(new TraefikDynamicHttp
    {
        Routers = new Dictionary<string, TraefikHttpRouter>
        {
            ["app"] = new()
            {
                Rule = "Host(`app.example.com`)",
                Service = "app-svc",
                EntryPoints = ["websecure"],
                Middlewares = ["security-headers"]
            }
        },
        Services = new Dictionary<string, TraefikHttpService>
        {
            ["app-svc"] = new()
            {
                LoadBalancer = new TraefikHttpLoadBalancerService
                {
                    Servers = [new() { Url = "http://backend:8080" }]
                }
            }
        },
        Middlewares = new Dictionary<string, TraefikHttpMiddleware>
        {
            ["security-headers"] = new()
            {
                Headers = new TraefikHeadersMiddleware
                {
                    FrameDeny = true,
                    ContentTypeNosniff = true
                }
            }
        }
    })
    .BuildAsync();

Every With method has IntelliSense. Builders inherit from AbstractBuilder<T> with async validation and Result<T> returns. The serializer then converts the built objects to Traefik-native camelCase YAML.

Real-World Examples

The power of typed configuration becomes obvious when you look at real production scenarios. Each example below shows the YAML that people write by hand, followed by the typed C# equivalent that the Traefik Bundle generates.

Example 1: HTTPS Redirect + Let's Encrypt

The most common production setup: redirect all HTTP traffic to HTTPS, with automatic certificate management via Let's Encrypt.

The YAML you write by hand:

# traefik.yml (static configuration)
entryPoints:
  web:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      email: admin@example.com
      storage: /letsencrypt/acme.json
      httpChallenge:
        entryPoint: web

api:
  dashboard: true

providers:
  file:
    directory: /etc/traefik/dynamic
    watch: true

log:
  level: INFO

The typed C# equivalent:

var result = await new TraefikStaticConfigBuilder()
    .WithEntryPoints(new Dictionary<string, TraefikStaticEntryPoint>
    {
        ["web"] = new()
        {
            Address = ":80",
            Http = new TraefikStaticHTTPConfig
            {
                Redirections = new TraefikStaticRedirections
                {
                    EntryPoint = new TraefikStaticRedirectEntryPoint
                    {
                        To = "websecure",
                        Scheme = "https",
                        Permanent = true
                    }
                }
            }
        },
        ["websecure"] = new() { Address = ":443" }
    })
    .WithCertificatesResolvers(new Dictionary<string, TraefikStaticCertificateResolver>
    {
        ["letsencrypt"] = new()
        {
            Acme = new TraefikAcmeConfiguration
            {
                Email = "admin@example.com",
                Storage = "/letsencrypt/acme.json",
                HttpChallenge = new TraefikAcmeHTTPChallenge { EntryPoint = "web" }
            }
        }
    })
    .WithApi(new TraefikStaticAPI { Dashboard = true })
    .WithProviders(new TraefikStaticProviders
    {
        File = new TraefikFileProvider { Directory = "/etc/traefik/dynamic", Watch = true }
    })
    .WithLog(new TraefikTypesTraefikLog { Level = "INFO" })
    .BuildAsync();

// Serialize to the YAML file Traefik reads
var config = result.ValueOrThrow().Resolved();
var yaml = TraefikSerializer.Serialize(config);
File.WriteAllText("/etc/traefik/traefik.yml", yaml);

In the YAML version, a typo in websecure (say, webscure) silently breaks the redirect — Traefik won't error, it just won't redirect. In the C# version, the redirect target is a string, but every surrounding type (TraefikStaticHTTPConfig, TraefikStaticRedirections, TraefikStaticRedirectEntryPoint, TraefikAcmeConfiguration) is validated by the compiler. You cannot accidentally set the ACME config on the wrong object or nest it at the wrong level.

Example 2: Security Headers — HSTS, CORS, XSS Protection

Production services need security headers. The headers middleware has 32 properties — HSTS, CORS, content security, frame options, and custom response headers.

The YAML:

# dynamic/security.yml
http:
  middlewares:
    security-headers:
      headers:
        # HSTS
        stsSeconds: 31536000
        stsIncludeSubdomains: true
        stsPreload: true
        # Content security
        contentTypeNosniff: true
        browserXssFilter: true
        frameDeny: true
        referrerPolicy: strict-origin-when-cross-origin
        # Custom headers
        customResponseHeaders:
          X-Robots-Tag: "noindex,nofollow"
          Permissions-Policy: "camera=(), microphone=(), geolocation=()"

    cors-api:
      headers:
        accessControlAllowMethods:
          - GET
          - POST
          - PUT
          - DELETE
          - OPTIONS
        accessControlAllowHeaders:
          - Content-Type
          - Authorization
        accessControlAllowOriginList:
          - "https://app.example.com"
        accessControlMaxAge: 86400
        addVaryHeader: true

The typed C#:

var dynamic = new TraefikDynamicConfig
{
    Http = new TraefikDynamicHttp
    {
        Middlewares = new Dictionary<string, TraefikHttpMiddleware>
        {
            ["security-headers"] = new()
            {
                Headers = new TraefikHeadersMiddleware
                {
                    // HSTS
                    StsSeconds = 31536000,
                    StsIncludeSubdomains = true,
                    StsPreload = true,
                    // Content security
                    ContentTypeNosniff = true,
                    BrowserXssFilter = true,
                    FrameDeny = true,
                    ReferrerPolicy = "strict-origin-when-cross-origin",
                    // Custom headers
                    CustomResponseHeaders = new Dictionary<string, string?>
                    {
                        ["X-Robots-Tag"] = "noindex,nofollow",
                        ["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
                    }
                }
            },
            ["cors-api"] = new()
            {
                Headers = new TraefikHeadersMiddleware
                {
                    AccessControlAllowMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
                    AccessControlAllowHeaders = ["Content-Type", "Authorization"],
                    AccessControlAllowOriginList = ["https://app.example.com"],
                    AccessControlMaxAge = 86400,
                    AddVaryHeader = true
                }
            }
        }
    }
};

var yaml = TraefikSerializer.Serialize(dynamic);
File.WriteAllText("/etc/traefik/dynamic/security.yml", yaml);

The TraefikHeadersMiddleware class has 32 typed properties. In YAML, stsSeconds accepts any value — you could accidentally set it to a string and Traefik would fail at load time. In C#, it's int?. BrowserXssFilter is bool?. AccessControlAllowMethods is List<string>?. The schema enforces every type at compile time.

Example 3: Rate Limiting + Circuit Breaker + Retry

Resilience middleware protects backends from overload. This example chains three middlewares: rate limiting (cap throughput), circuit breaking (stop cascading failures), and retry (handle transient errors).

The YAML:

# dynamic/resilience.yml
http:
  middlewares:
    rate-limit:
      rateLimit:
        average: 100
        burst: 50
        period: 1s

    circuit-breaker:
      circuitBreaker:
        expression: "NetworkErrorRatio() > 0.25 || ResponseCodeRatio(500, 600, 0, 600) > 0.25"
        checkPeriod: 10s
        fallbackDuration: 30s
        recoveryDuration: 60s

    retry:
      retry:
        attempts: 3
        initialInterval: 100ms

    resilience-chain:
      chain:
        middlewares:
          - rate-limit
          - circuit-breaker
          - retry

The typed C#:

var dynamic = new TraefikDynamicConfig
{
    Http = new TraefikDynamicHttp
    {
        Middlewares = new Dictionary<string, TraefikHttpMiddleware>
        {
            ["rate-limit"] = new()
            {
                RateLimit = new TraefikRateLimitMiddleware
                {
                    Average = 100,
                    Burst = 50,
                    Period = "1s"
                }
            },
            ["circuit-breaker"] = new()
            {
                CircuitBreaker = new TraefikCircuitBreakerMiddleware
                {
                    Expression = "NetworkErrorRatio() > 0.25 || " +
                                 "ResponseCodeRatio(500, 600, 0, 600) > 0.25",
                    CheckPeriod = "10s",
                    FallbackDuration = "30s",
                    RecoveryDuration = "60s"
                }
            },
            ["retry"] = new()
            {
                Retry = new TraefikRetryMiddleware
                {
                    Attempts = 3,
                    InitialInterval = "100ms"
                }
            },
            ["resilience-chain"] = new()
            {
                Chain = new TraefikChainMiddleware
                {
                    Middlewares = ["rate-limit", "circuit-breaker", "retry"]
                }
            }
        }
    }
};

Notice how the discriminated union works in practice: each middleware dictionary entry has exactly one property set on TraefikHttpMiddleware. The rate-limit entry sets RateLimit, the circuit-breaker entry sets CircuitBreaker, and the chain entry sets Chain. The remaining 24 properties on each instance are null. This is the flat nullable class pattern — one property per middleware type, set exactly one.

Example 4: Forward Auth with OAuth2 Proxy

Protecting services with OAuth2 authentication using Traefik's ForwardAuth middleware and an OAuth2 Proxy sidecar.

The YAML:

# dynamic/auth.yml
http:
  middlewares:
    oauth-verify:
      forwardAuth:
        address: "http://oauth2-proxy:4180/oauth2/auth"
        trustForwardHeader: true
        authResponseHeaders:
          - X-Auth-Request-User
          - X-Auth-Request-Email
          - Authorization
          - Set-Cookie

    oauth-errors:
      errors:
        service: oauth2-proxy-svc
        query: "/oauth2/sign_in?rd={url}"
        status:
          - "401"

  routers:
    app-secure:
      rule: "Host(`app.example.com`)"
      service: app-backend
      entryPoints:
        - websecure
      middlewares:
        - oauth-verify
      tls:
        certResolver: letsencrypt

    oauth2-proxy:
      rule: "Host(`app.example.com`) && PathPrefix(`/oauth2`)"
      service: oauth2-proxy-svc
      entryPoints:
        - websecure
      tls:
        certResolver: letsencrypt

  services:
    app-backend:
      loadBalancer:
        servers:
          - url: "http://app:8080"

    oauth2-proxy-svc:
      loadBalancer:
        servers:
          - url: "http://oauth2-proxy:4180"

The typed C#:

var dynamic = new TraefikDynamicConfig
{
    Http = new TraefikDynamicHttp
    {
        Middlewares = new Dictionary<string, TraefikHttpMiddleware>
        {
            ["oauth-verify"] = new()
            {
                ForwardAuth = new TraefikForwardAuthMiddleware
                {
                    Address = "http://oauth2-proxy:4180/oauth2/auth",
                    TrustForwardHeader = true,
                    AuthResponseHeaders =
                    [
                        "X-Auth-Request-User",
                        "X-Auth-Request-Email",
                        "Authorization",
                        "Set-Cookie"
                    ]
                }
            },
            ["oauth-errors"] = new()
            {
                Errors = new TraefikErrorsMiddleware
                {
                    Service = "oauth2-proxy-svc",
                    Query = "/oauth2/sign_in?rd={url}",
                    Status = ["401"]
                }
            }
        },
        Routers = new Dictionary<string, TraefikHttpRouter>
        {
            ["app-secure"] = new()
            {
                Rule = "Host(`app.example.com`)",
                Service = "app-backend",
                EntryPoints = ["websecure"],
                Middlewares = ["oauth-verify"],
                Tls = new TraefikHttpRouterTls { CertResolver = "letsencrypt" }
            },
            ["oauth2-proxy"] = new()
            {
                Rule = "Host(`app.example.com`) && PathPrefix(`/oauth2`)",
                Service = "oauth2-proxy-svc",
                EntryPoints = ["websecure"],
                Tls = new TraefikHttpRouterTls { CertResolver = "letsencrypt" }
            }
        },
        Services = new Dictionary<string, TraefikHttpService>
        {
            ["app-backend"] = new()
            {
                LoadBalancer = new TraefikHttpLoadBalancerService
                {
                    Servers = [new() { Url = "http://app:8080" }]
                }
            },
            ["oauth2-proxy-svc"] = new()
            {
                LoadBalancer = new TraefikHttpLoadBalancerService
                {
                    Servers = [new() { Url = "http://oauth2-proxy:4180" }]
                }
            }
        }
    }
};

This example shows how routers, services, and middlewares compose together. The app-secure router references the oauth-verify middleware by name, the app-backend service by name, and the letsencrypt certificate resolver (defined in static config). In YAML, all of these are string references — typos are silent. In C#, the structural types are validated: TraefikForwardAuthMiddleware.AuthResponseHeaders is List<string>?, TraefikHttpRouterTls.CertResolver is string?, and the nesting is enforced by the type hierarchy.

Example 5: Load Balancer with Health Checks, Sticky Sessions, and Mirroring

Advanced service configuration: health checks remove unhealthy backends, sticky sessions pin users to servers, weighted routing enables canary deployments, and mirroring shadows traffic for testing.

The YAML:

# dynamic/services.yml
http:
  services:
    app-primary:
      loadBalancer:
        servers:
          - url: "http://app-v1:8080"
          - url: "http://app-v2:8080"
        healthCheck:
          path: /health
          interval: 10s
          timeout: 3s
        sticky:
          cookie:
            name: srv_id
            secure: true
            httpOnly: true

    canary:
      weighted:
        services:
          - name: app-v1
            weight: 90
          - name: app-v2
            weight: 10

    shadow:
      mirroring:
        service: app-primary
        mirrors:
          - name: app-staging
            percent: 10

The typed C#:

var dynamic = new TraefikDynamicConfig
{
    Http = new TraefikDynamicHttp
    {
        Services = new Dictionary<string, TraefikHttpService>
        {
            ["app-primary"] = new()
            {
                LoadBalancer = new TraefikHttpLoadBalancerService
                {
                    Servers =
                    [
                        new() { Url = "http://app-v1:8080" },
                        new() { Url = "http://app-v2:8080" }
                    ],
                    HealthCheck = new TraefikHttpLoadBalancerServiceHealthCheck
                    {
                        Path = "/health",
                        Interval = "10s",
                        Timeout = "3s"
                    },
                    Sticky = new TraefikHttpLoadBalancerServiceSticky
                    {
                        Cookie = new TraefikHttpLoadBalancerServiceStickyCookie
                        {
                            Name = "srv_id",
                            Secure = true,
                            HttpOnly = true
                        }
                    }
                }
            },
            ["canary"] = new()
            {
                Weighted = new TraefikHttpWeightedService
                {
                    Services =
                    [
                        new() { Name = "app-v1", Weight = 90 },
                        new() { Name = "app-v2", Weight = 10 }
                    ]
                }
            },
            ["shadow"] = new()
            {
                Mirroring = new TraefikHttpMirroringService
                {
                    Service = "app-primary",
                    Mirrors =
                    [
                        new() { Name = "app-staging", Percent = 10 }
                    ]
                }
            }
        }
    }
};

Three of the four TraefikHttpService discriminated union branches are used here: LoadBalancer for the primary with health checks and sticky sessions, Weighted for canary routing (90/10 split), and Mirroring for shadow traffic. The fourth branch — Failover — provides automatic service failover. Each service entry sets exactly one of the four properties on TraefikHttpService.

Example 6: TCP Router — Database Proxy with TLS Passthrough

Traefik handles more than HTTP. TCP routers can proxy database connections with TLS passthrough — Traefik routes based on SNI without terminating TLS, so the database handles its own encryption.

The YAML:

# dynamic/tcp.yml
tcp:
  routers:
    postgres-prod:
      rule: "HostSNI(`db.example.com`)"
      service: postgres-cluster
      tls:
        passthrough: true

    redis-prod:
      rule: "HostSNI(`redis.example.com`)"
      service: redis-cluster
      tls:
        passthrough: true

  services:
    postgres-cluster:
      loadBalancer:
        servers:
          - address: "postgres-primary:5432"
          - address: "postgres-replica:5432"

    redis-cluster:
      loadBalancer:
        servers:
          - address: "redis-1:6379"
          - address: "redis-2:6379"
          - address: "redis-3:6379"

The typed C#:

var dynamic = new TraefikDynamicConfig
{
    Tcp = new TraefikDynamicTcp
    {
        Routers = new Dictionary<string, TraefikTcpRouter>
        {
            ["postgres-prod"] = new()
            {
                Rule = "HostSNI(`db.example.com`)",
                Service = "postgres-cluster",
                Tls = new TraefikTcpRouterTls { Passthrough = true }
            },
            ["redis-prod"] = new()
            {
                Rule = "HostSNI(`redis.example.com`)",
                Service = "redis-cluster",
                Tls = new TraefikTcpRouterTls { Passthrough = true }
            }
        },
        Services = new Dictionary<string, TraefikTcpService>
        {
            ["postgres-cluster"] = new()
            {
                LoadBalancer = new TraefikTcpLoadBalancerService
                {
                    Servers =
                    [
                        new() { Address = "postgres-primary:5432" },
                        new() { Address = "postgres-replica:5432" }
                    ]
                }
            },
            ["redis-cluster"] = new()
            {
                LoadBalancer = new TraefikTcpLoadBalancerService
                {
                    Servers =
                    [
                        new() { Address = "redis-1:6379" },
                        new() { Address = "redis-2:6379" },
                        new() { Address = "redis-3:6379" }
                    ]
                }
            }
        }
    }
};

Notice the parallel structure: TraefikDynamicTcp mirrors TraefikDynamicHttp with its own Routers and Services dictionaries. TraefikTcpService is its own discriminated union — LoadBalancer or Weighted — separate from the HTTP variant. The type system prevents mixing HTTP routers with TCP services or vice versa.

Traefik also supports Postgres STARTTLS — it reads the first bytes of a connection, detects STARTTLS negotiation, and routes accordingly. This is fully supported in the typed model.

Example 7: Observability — Prometheus Metrics + OpenTelemetry Tracing + Access Logs

Production Traefik needs observability. This is static configuration — metrics, tracing, and access logs are startup-time settings.

The YAML:

# traefik.yml (static configuration)
metrics:
  prometheus:
    addEntryPointsLabels: true
    addRoutersLabels: true
    addServicesLabels: true
    entryPoint: metrics
    buckets:
      - 0.1
      - 0.3
      - 1.2
      - 5.0

tracing:
  otlp:
    http:
      endpoint: "http://otel-collector:4318/v1/traces"

accessLog:
  filePath: /var/log/traefik/access.log
  format: json
  filters:
    statusCodes:
      - "400-499"
      - "500-599"
    retryAttempts: true

entryPoints:
  metrics:
    address: ":9100"

The typed C#:

var result = await new TraefikStaticConfigBuilder()
    .WithMetrics(new TraefikTypesMetrics
    {
        Prometheus = new TraefikTypesPrometheus
        {
            AddEntryPointsLabels = true,
            AddRoutersLabels = true,
            AddServicesLabels = true,
            EntryPoint = "metrics",
            Buckets = [0.1, 0.3, 1.2, 5.0]
        }
    })
    .WithTracing(new TraefikStaticTracing
    {
        Otlp = new TraefikTypesOTLP
        {
            Http = new TraefikTypesOTLPHttp
            {
                Endpoint = "http://otel-collector:4318/v1/traces"
            }
        }
    })
    .WithAccessLog(new TraefikTypesAccessLog
    {
        FilePath = "/var/log/traefik/access.log",
        Format = "json",
        Filters = new TraefikTypesAccessLogFilters
        {
            StatusCodes = ["400-499", "500-599"],
            RetryAttempts = true
        }
    })
    .WithEntryPoints(new Dictionary<string, TraefikStaticEntryPoint>
    {
        ["metrics"] = new() { Address = ":9100" }
    })
    .BuildAsync();

Traefik v3 exports metrics and traces in the OpenTelemetry format natively. The generated types cover the full OTel configuration surface — OTLP HTTP/gRPC endpoints, Prometheus exposition, Datadog, InfluxDB 2.X, and StatsD. The type system ensures you don't accidentally configure a tracing endpoint under the metrics section.

Two-Tier Configuration: Static vs. Dynamic

Traefik enforces a hard boundary between static and dynamic configuration. This is not a convention — it is architectural. Static config is read once at process startup. Dynamic config is hot-reloadable by the file provider without downtime.

Diagram
The generator mirrors Traefik's own architectural split: one schema file carves out the startup-only static config, the other carves out the hot-reloadable dynamic config, and each lands in its own root class.

The generator discovers this split from the filenames: TraefikSchemaReader.DetectKind maps traefik-v3-static.json to SchemaKind.Static and traefik-v3-file-provider.json to SchemaKind.Dynamic. Root properties are collected separately — 16 for static, 4 for dynamic (http, tcp, udp, tls).

Here's what the generated root classes look like:

// <auto-generated/>
public partial class TraefikStaticConfig
{
    public Dictionary<string, TraefikStaticEntryPoint>? EntryPoints { get; set; }
    public TraefikStaticProviders? Providers { get; set; }
    public Dictionary<string, TraefikStaticCertificateResolver>? CertificatesResolvers { get; set; }
    public TraefikStaticAPI? Api { get; set; }
    public TraefikTypesTraefikLog? Log { get; set; }
    public TraefikTypesAccessLog? AccessLog { get; set; }
    public TraefikTypesMetrics? Metrics { get; set; }
    public TraefikStaticTracing? Tracing { get; set; }
    public TraefikPingHandler? Ping { get; set; }
    public TraefikStaticServersTransport? ServersTransport { get; set; }
    public TraefikStaticTCPServersTransport? TcpServersTransport { get; set; }
    // ... 5 more properties
}

// <auto-generated/>
public partial class TraefikDynamicConfig
{
    public TraefikDynamicHttp? Http { get; set; }
    public TraefikDynamicTcp? Tcp { get; set; }
    public TraefikDynamicUdp? Udp { get; set; }
    public TraefikDynamicTls? Tls { get; set; }
}

The dynamic config nests one level deeper — each section contains dictionaries of routers, services, and middlewares:

// <auto-generated/>
public partial class TraefikDynamicHttp
{
    public Dictionary<string, TraefikHttpRouter>? Routers { get; set; }
    public Dictionary<string, TraefikHttpService>? Services { get; set; }
    public Dictionary<string, TraefikHttpMiddleware>? Middlewares { get; set; }
}

If you try to set a router in your static config, the compiler stops you — TraefikStaticConfig has no Routers property. If you try to define an entry point in your dynamic config, it won't compile — TraefikDynamicConfig has no EntryPoints property. The schema-driven split enforces Traefik's own architectural boundary at the type level.

Discriminated Unions: Middleware and Service Types

Traefik's middleware and service types are discriminated unions: a middleware is exactly one of addPrefix, basicAuth, chain, headers, rateLimit, and 20 more. The Traefik v3 file-provider schema expresses this via JSON Schema oneOf where each branch is an object with exactly one property containing a $ref.

Diagram
Every Traefik middleware and service oneOf is turned into a flat class whose branches enumerate the full discriminated union — 25 middleware variants and four service kinds — so typos become compile errors.

TraefikSchemaReader.IsDiscriminatedOneOf detects the pattern in the JSON Schema. The generator emits these as flat classes with nullable properties — one property per variant:

// <auto-generated/>
public partial class TraefikHttpMiddleware
{
    public TraefikAddPrefixMiddleware? AddPrefix { get; set; }
    public TraefikBasicAuthMiddleware? BasicAuth { get; set; }
    public TraefikBufferingMiddleware? Buffering { get; set; }
    public TraefikChainMiddleware? Chain { get; set; }
    public TraefikCircuitBreakerMiddleware? CircuitBreaker { get; set; }
    public TraefikCompressMiddleware? Compress { get; set; }
    public TraefikContentTypeMiddleware? ContentType { get; set; }
    public TraefikDigestAuthMiddleware? DigestAuth { get; set; }
    public TraefikErrorsMiddleware? Errors { get; set; }
    public TraefikForwardAuthMiddleware? ForwardAuth { get; set; }
    public TraefikGrpcWebMiddleware? GrpcWeb { get; set; }
    public TraefikHeadersMiddleware? Headers { get; set; }
    public TraefikInFlightReqMiddleware? InFlightReq { get; set; }
    public TraefikIpAllowListMiddleware? IpAllowList { get; set; }
    public TraefikPassTLSClientCertMiddleware? PassTlsClientCert { get; set; }
    public TraefikRateLimitMiddleware? RateLimit { get; set; }
    public TraefikRedirectRegexMiddleware? RedirectRegex { get; set; }
    public TraefikRedirectSchemeMiddleware? RedirectScheme { get; set; }
    public TraefikReplacePath? ReplacePath { get; set; }
    public TraefikReplacePathRegex? ReplacePathRegex { get; set; }
    public TraefikRetryMiddleware? Retry { get; set; }
    public TraefikStripPrefixMiddleware? StripPrefix { get; set; }
    public TraefikStripPrefixRegexMiddleware? StripPrefixRegex { get; set; }
    // ... remaining branches
}

All discriminated union types in the generated model:

Discriminated Union Branches Description
TraefikHttpMiddleware 25 addPrefix, basicAuth, chain, headers, rateLimit, circuitBreaker, compress, forwardAuth, ...
TraefikHttpService 4 loadBalancer, weighted, mirroring, failover
TraefikTcpService 2 loadBalancer, weighted
TraefikUdpService 2 loadBalancer, weighted

Why flat nullable classes instead of sealed hierarchies? A "true" discriminated union in C# would use a base class with sealed subtypes and pattern matching. We chose flat nullable properties instead:

  • Serialization: YamlDotNet serializes nullable properties naturally to the correct YAML structure. No custom converters needed.
  • Discovery: all 25 middleware types are visible via IntelliSense on a single class. No need to remember subtype names or use a factory.
  • Simplicity: no abstract base, no visitor pattern, no casting. Set one property, done.

The trade-off: nothing prevents setting two properties simultaneously at compile time. But Traefik's own validation catches the conflict at load time. The schema is the safety net; the types give you IntelliSense for all 25 options without forcing you into a switch expression to construct one.

YAML Round-Trip

Traefik consumes YAML natively, so round-trip fidelity is critical. TraefikSerializer handles this in 29 lines:

public static class TraefikSerializer
{
    private static readonly IDeserializer Deserializer = new DeserializerBuilder()
        .WithNamingConvention(CamelCaseNamingConvention.Instance)
        .IgnoreUnmatchedProperties()
        .Build();

    private static readonly ISerializer Serializer = new SerializerBuilder()
        .WithNamingConvention(CamelCaseNamingConvention.Instance)
        .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
        .Build();

    public static TraefikStaticConfig DeserializeStatic(string yaml) =>
        Deserializer.Deserialize<TraefikStaticConfig>(yaml);

    public static TraefikDynamicConfig DeserializeDynamic(string yaml) =>
        Deserializer.Deserialize<TraefikDynamicConfig>(yaml);

    public static string Serialize<T>(T obj) =>
        Serializer.Serialize(obj!);
}

Three design choices make this work seamlessly:

  • CamelCaseNamingConvention — C# PascalCase properties (EntryPoints, StsSeconds) map directly to Traefik's camelCase YAML keys (entryPoints, stsSeconds). No [YamlMember] annotations needed on any generated class.
  • OmitNull — null properties are omitted from serialized output. A TraefikStaticConfig with only Api set produces clean YAML with just the api: section, not 15 empty sections.
  • IgnoreUnmatchedProperties — deserialization tolerates unknown YAML keys. This provides forward compatibility when Traefik adds new configuration properties before the schema is updated.

Reading existing configuration is equally straightforward:

// Read an existing Traefik config into typed objects
var staticConfig = TraefikSerializer.DeserializeStatic(
    File.ReadAllText("/etc/traefik/traefik.yml"));

// Access properties with full IntelliSense
var dashboardEnabled = staticConfig.Api?.Dashboard;
var webAddress = staticConfig.EntryPoints?["web"].Address;
var acmeEmail = staticConfig.CertificatesResolvers?["letsencrypt"].Acme?.Email;

The Generated Class Hierarchy

2 schemas in, ~316 C# files out — 155 model classes and 160 matching fluent builders:

TraefikStaticConfig (root — 16 properties)
├── TraefikStaticEntryPoint (address, http, http2, http3, proxyProtocol, transport)
│   ├── TraefikStaticHTTPConfig → TraefikStaticRedirections → TraefikStaticRedirectEntryPoint
│   ├── TraefikStaticForwardedHeaders
│   ├── TraefikStaticProxyProtocol
│   └── TraefikStaticEntryPointsTransport
├── TraefikStaticProviders (17 provider types)
│   ├── TraefikDockerProvider, TraefikDockerSwarmProvider
│   ├── TraefikFileProvider
│   ├── TraefikCrdProvider, TraefikGatewayProvider, TraefikIngressProvider
│   ├── TraefikConsulProvider, TraefikConsulCatalogProvider
│   ├── TraefikEcsProvider, TraefikEtcdProvider, TraefikRedisProvider, TraefikZkProvider
│   └── TraefikRestProvider, TraefikHttpProvider
├── TraefikStaticCertificateResolver → TraefikAcmeConfiguration
│   ├── TraefikAcmeDNSChallenge, TraefikAcmeHTTPChallenge, TraefikAcmeTLSChallenge
│   └── TraefikAcmeEAB
├── TraefikStaticAPI, TraefikPingHandler
├── TraefikTypesTraefikLog, TraefikTypesAccessLog
├── TraefikTypesMetrics → Prometheus, Datadog, StatsD, InfluxDB2
├── TraefikStaticTracing → TraefikTypesOTLP → TraefikTypesOTLPHttp, TraefikTypesOTLPGrpc
└── TraefikStaticServersTransport, TraefikStaticTCPServersTransport

TraefikDynamicConfig (root — 4 sections)
├── TraefikDynamicHttp
│   ├── Routers: Dict<string, TraefikHttpRouter>
│   │   └── TraefikHttpRouterTls → TraefikHttpRouterTlsDomainsItem
│   ├── Services: Dict<string, TraefikHttpService>              ← discriminated union (4)
│   │   ├── TraefikHttpLoadBalancerService
│   │   │   ├── ...ServersItem, ...Sticky → ...StickyCookie
│   │   │   ├── ...HealthCheck, ...ResponseForwarding
│   │   ├── TraefikHttpWeightedService → ...ServicesItem, ...Sticky
│   │   ├── TraefikHttpMirroringService → ...MirrorsItem
│   │   └── TraefikHttpFailoverService
│   └── Middlewares: Dict<string, TraefikHttpMiddleware>         ← discriminated union (25)
│       ├── TraefikHeadersMiddleware (32 properties)
│       ├── TraefikForwardAuthMiddleware → ...Tls
│       ├── TraefikRateLimitMiddleware → TraefikSourceCriterion → TraefikIpStrategy
│       ├── TraefikCircuitBreakerMiddleware, TraefikRetryMiddleware
│       ├── TraefikBasicAuthMiddleware, TraefikDigestAuthMiddleware
│       ├── TraefikPassTLSClientCertMiddleware → ...Info → ...Issuer, ...Subject
│       └── ... 18 more middleware types
├── TraefikDynamicTcp → Routers (TraefikTcpRouter), Services (TraefikTcpService ← union)
├── TraefikDynamicUdp → Routers (TraefikUdpRouter), Services (TraefikUdpService ← union)
└── TraefikDynamicTls → Certificates, Options, Stores

Every model class has a corresponding builder. Every builder inherits from AbstractBuilder<T> with per-property validation hooks and BuildAsync() returning Result<Reference<T>>.

Architecture at a Glance

Traefik/
├── src/
│   ├── FrenchExDev.Net.Traefik.Bundle.Design/      Schema downloader CLI
│   │   └── Program.cs                                DesignPipeline → SchemaStore
│   ├── FrenchExDev.Net.Traefik.Bundle.Attributes/   [TraefikBundle] marker
│   ├── FrenchExDev.Net.Traefik.Bundle.SourceGenerator/
│   │   ├── TraefikBundleGenerator.cs      Entry: IIncrementalGenerator
│   │   ├── TraefikSchemaReader.cs         JSON Schema → SchemaModel (static/dynamic detect)
│   │   ├── SchemaModels.cs                SchemaModel, UnifiedSchema, PropertyType enum
│   │   ├── TraefikModelClassEmitter.cs    Emits model classes + discriminated unions
│   │   ├── VersionMetadataEmitter.cs      Emits TraefikSchemaVersions + attributes
│   │   ├── TraefikBuilderHelper.cs        Schema → BuilderEmitModel conversion
│   │   └── TraefikNamingHelper.cs         camelCase → PascalCase, definition → class name
│   └── FrenchExDev.Net.Traefik.Bundle/              Consumer library
│       ├── TraefikBundleDescriptor.cs     [TraefikBundle] trigger (one line)
│       ├── TraefikSerializer.cs           YAML round-trip (YamlDotNet, 29 lines)
│       └── schemas/                       2 cached JSON Schema files (42 KB + 79 KB)
└── test/
    └── FrenchExDev.Net.Traefik.Bundle.Tests/
        ├── BuilderTests.cs                Fluent builder + entry point tests
        ├── SerializerTests.cs             YAML serialization round-trip tests
        ├── ModelTests.cs                  Model structure + discriminated union tests
        └── Fixtures/                      static-minimal.yaml, dynamic-minimal.yaml

Key Design Decisions

  • Two schemas, two roots, one generatorTraefikBundleGenerator reads both files in a single RegisterSourceOutput pass. TraefikSchemaReader.DetectKind uses the filename to route properties to TraefikStaticConfig or TraefikDynamicConfig. One generator, two type hierarchies.

  • Discriminated unions as flat nullable classesTraefikHttpMiddleware has 25 nullable properties rather than a sealed hierarchy. Pragmatic: YamlDotNet serializes naturally, Traefik validates at load time, and the developer gets IntelliSense for all 25 options without pattern-matching boilerplate.

  • YAML-native serializationTraefikSerializer uses YamlDotNet with CamelCaseNamingConvention, OmitNull, and IgnoreUnmatchedProperties. Unlike the Docker Compose Bundle (which targets a YAML format but uses JSON Schema), Traefik consumes YAML natively — round-trip fidelity is critical.

  • Version infrastructure is ready — the UnifiedSchema / [SinceVersion] / [UntilVersion] pipeline from the Docker Compose Bundle is present but currently single-version. When Traefik v4 schemas appear, multi-version merging is a configuration change, not a code change.

  • Shared builder generation — reuses the BuilderEmitter from FrenchExDev.Net.Builder.SourceGenerator.Lib. The same emission pipeline used by DockerCompose.Bundle, ensuring consistency and reducing maintenance surface.

  • 122 definitions, ~316 generated files — the generator emits model + builder for every definition, plus inline classes for nested objects. The debug comment confirms: "122 definitions, 16 static root props, 4 dynamic root props."

Why This Matters

Traefik is the default ingress controller for many container orchestration platforms. Its YAML configuration is powerful but unforgiving — a mistyped middleware name, a router pointing to a non-existent service, or a security header with the wrong casing fails silently until traffic hits the wrong endpoint.

This bundle turns that YAML into a compile-time concern. The generated types mirror the schema exactly, so a typo becomes a build error. The discriminated union pattern ensures you populate the right middleware sub-type with IntelliSense guiding every property. The two-tier split is enforced by the type system — static and dynamic configuration cannot be mixed because they are separate root classes with separate builders.

Combined with the Docker Compose Bundle, an entire homelab or production stack — Traefik as the edge proxy, Docker Compose defining the services behind it — can be defined in type-safe C# that emits valid YAML. No manual YAML editing required. One source of truth per tool, both validated by the compiler.

The pattern generalizes. Any infrastructure tool that publishes JSON Schemas — Kubernetes, Terraform providers, CloudFormation, Packer — can be wrapped the same way: download schemas, generate types, emit builders. The schema is the source of truth. The compiler is the enforcement layer. Configuration drift becomes a solved problem.


Traefik Bundle is part of the FrenchExDev.Net ecosystem. Built with Roslyn incremental source generators, JSON Schema traversal, and a firm belief that infrastructure schemas deserve the same type safety as domain models.

⬇ Download