Skip to content

Instantly share code, notes, and snippets.

@JPVenson
Created May 5, 2026 11:08
Show Gist options
  • Select an option

  • Save JPVenson/d2c44043d4fd2756ac9932ed13b9967b to your computer and use it in GitHub Desktop.

Select an option

Save JPVenson/d2c44043d4fd2756ac9932ed13b9967b to your computer and use it in GitHub Desktop.

traefik-gateway

A single-file .NET 10 ASP.NET Core service that reads another Traefik instance's dynamic route files and re-publishes a subset of them — the ones marked with a custom externally: true flag — as a fresh dynamic configuration that an edge Traefik can consume via its HTTP provider, with all matching routes pointed at one shared upstream IP.

                  ┌────────────────────┐
                  │   Edge Traefik #1  │  HTTP provider polls /config every Ns
                  └─────────▲──────────┘
                            │ JSON
                  ┌─────────┴──────────┐         reads YAML
                  │  traefik-gateway   ├───────────────────────► Traefik #2's
                  │  (this script)     │                          route files
                  └────────────────────┘

Repository layout

Path Purpose
traefik-gateway.cs The whole app — a .NET 10 file-based program
Dockerfile Multi-stage build → Alpine ASP.NET runtime image
compose.yaml Reference deployment (edge Traefik + gateway)
sample-routes/ Example Traefik #2 dynamic config to test against
TraefikGateway/ Optional: traditional .csproj version of the same

How Traefik's HTTP provider works

Traefik can pull dynamic configuration from any HTTP endpoint that returns a JSON document in Traefik's standard dynamic-config schema. Configure it on Traefik #1 in its static config:

# traefik.yml on Traefik #1
providers:
  http:
    endpoint: "http://traefik-gateway:8080/config"
    pollInterval: "10s"
  file:
    directory: /etc/traefik/dynamic   # local routes still work alongside
    watch: true

Each poll, Traefik fetches /config, validates the JSON, and merges it with whatever the file/docker/k8s providers also produced.

What this app does

  1. Walks TraefikGateway:RoutesPath for *.yml / *.yaml.
  2. Parses each file as a Traefik dynamic config.
  3. For every router under http.routers that has the custom field externally: true, it:
    • clones the router definition,
    • removes the marker field and any middlewares references,
    • rewrites service to a single shared service called externally-flagged-target.
  4. If TraefikGateway:DomainWhitelist is set, the filter does two things:
    1. Drop the router if none of its Host(...) clauses references a whitelisted domain (exact match or any subdomain of it).
    2. Rewrite the surviving router's rule to remove every individual Host(...) argument that isn't whitelisted. So Host(a.example.com, b.legacy.internal) with whitelist example.com becomes Host(a.example.com). Empty Host() calls that result from this — for example, one side of an || going dead — are spliced out with their connector. If the rewrite would leave a dead branch inside an && (where the rule could never match anything), the whole router is dropped.
  5. Defines the shared service as a load-balancer with one server pointing at TraefikGateway:ExternalUrl.
  6. Returns the result as JSON at GET /config.

Two extra endpoints sit alongside /config:

  • GET /healthz — liveness probe used by the Docker HEALTHCHECK and by compose.yaml's depends_on block.
  • GET /domains — returns a deduplicated, alphabetically sorted JSON array of every domain mentioned in the Host(...) clauses of the adopted routers (after whitelist filtering and rule rewriting). Useful for downstream tooling: cert provisioning, DNS reconciliation, audit reports.

Running with Docker (recommended)

docker build -t traefik-gateway:latest .

docker run --rm -p 8080:8080 \
  -e TraefikGateway__RoutesPath=/routes \
  -e TraefikGateway__ExternalUrl=http://10.20.30.40:8080 \
  -v "$PWD/sample-routes:/routes:ro" \
  traefik-gateway:latest

curl -s http://localhost:8080/config | jq .

The image lands at roughly 95 MB — Alpine ASP.NET runtime + the published DLLs. The build does not include the .NET SDK; that stays in the build stage and is discarded.

End-to-end with compose.yaml

The included compose file spins up an edge Traefik that polls the gateway:

docker compose up --build
# In another shell:
curl -s http://localhost:8080/config | jq .         # gateway output
curl -s http://localhost:80/api/rawdata | jq .      # what Traefik #1 ingested

Edit sample-routes/example.yml, wait ~10 s, and you'll see Traefik #1 pick up the change without restarting.

Going smaller

Two paths if 95 MB isn't slim enough:

  • Chiseled Ubuntu base. Replace the runtime stage with mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled. Drops to ~70 MB, no shell or package manager (use docker exec only with nsenter style debugging tools).
  • Native AOT. File-based apps default to PublishAot=true. To try it, remove -p:PublishAot=false from the Dockerfile and switch the runtime stage to a minimal base like gcr.io/distroless/cc-debian12. Caveat: YamlDotNet's untyped deserialization path uses reflection that the AOT trimmer will warn or error on. You'd likely need to swap to a typed deserialization or to a different YAML library (e.g. YamlDotNet with source generators, or VYaml) for AOT to succeed cleanly.

Running directly with dotnet (no Docker)

# One-shot run; first invocation restores YamlDotNet, subsequent runs are cached.
dotnet run traefik-gateway.cs

# With config overrides:
dotnet run traefik-gateway.cs -- \
  --TraefikGateway:RoutesPath=./sample-routes \
  --TraefikGateway:ExternalUrl=http://10.20.30.40:8080

On Linux/macOS you can also chmod +x traefik-gateway.cs and run it as ./traefik-gateway.cs thanks to the shebang.

Configuration

Standard ASP.NET Core configuration binding — set via env vars, command-line, or a sidecar appsettings.json:

Key Env var Purpose Default
TraefikGateway:RoutesPath TraefikGateway__RoutesPath Directory of Traefik #2's route YAML files /etc/traefik/dynamic
TraefikGateway:ExternalUrl TraefikGateway__ExternalUrl The single upstream all flagged routes will point to http://10.0.0.1:80
TraefikGateway:DomainWhitelist TraefikGateway__DomainWhitelist Comma-separated list of allowed domains. A flagged router is only adopted if its Host(...) rule references one of these (exact host or any subdomain). Unset = allow all. (unset, allows all)
TraefikGateway:DefaultEntryPoint TraefikGateway__DefaultEntryPoint Used when source router omits entryPoints websecure

Sample input → output

sample-routes/example.yml (relevant excerpt):

http:
  routers:
    public-api:
      rule: "Host(`api.example.com`)"
      service: api-backend
      tls: {}
      externally: true                                       # adopted as-is
    internal-grafana:
      rule: "Host(`grafana.internal`)"
      service: grafana
      # ignored - no externally flag
    mixed-domains-api:
      rule: "Host(`api.example.com`, `api.legacy.internal`) && PathPrefix(`/v1`)"
      service: api-backend
      externally: true                                       # adopted, rule rewritten
    staging-api:
      rule: "Host(`api.staging.internal`)"
      service: staging-backend
      externally: true                                       # dropped by whitelist

With TraefikGateway__DomainWhitelist=example.com, GET /config returns:

{
  "http": {
    "routers": {
      "public-api": {
        "rule": "Host(`api.example.com`)",
        "entryPoints": ["websecure"],
        "service": "externally-flagged-target",
        "tls": {}
      },
      "mixed-domains-api": {
        "rule": "Host(`api.example.com`) && PathPrefix(`/v1`)",
        "entryPoints": ["websecure"],
        "service": "externally-flagged-target",
        "tls": {}
      }
    },
    "services": {
      "externally-flagged-target": {
        "loadBalancer": {
          "servers": [{ "url": "http://10.20.30.40:8080" }]
        }
      }
    }
  }
}

Note the mixed-domains-api rule: api.legacy.internal was stripped, and the rest of the rule (PathPrefix(\/v1`)`) carries through untouched.

GET /domains returns the resulting host set:

[
  "api.example.com"
]

Even though mixed-domains-api was originally written for two hosts, only api.example.com survives the whitelist-driven rewrite — so that's all /domains reports.

internal-grafana is excluded everywhere (no flag). staging-api is dropped entirely by the whitelist (no whitelisted host to keep). Drop the whitelist and staging-api would be adopted with its rule untouched.

Caveats / things you may want to extend

  • Custom field tolerance. Traefik's file provider in recent versions logs a warning for unknown fields like externally: but still loads the config. If your Traefik #2 is configured strictly, keep the markers in a parallel metadata file instead and adjust the parser.
  • Middlewares are stripped from the cloned router because their definitions don't exist on Traefik #1. To forward them instead, also copy referenced entries from http.middlewares into the response.
  • No TCP / UDP routers — only http.routers are processed. Easy to add.
  • No caching / file watching. Every /config call re-reads the disk. Fine for typical 5–30 s poll intervals; add IMemoryCache + FileSystemWatcher if your file count grows.
  • Auth. The /config endpoint is unauthenticated. If you expose it beyond a private network, add an API-key middleware and configure Traefik's HTTP provider with the matching header:
    providers:
      http:
        endpoint: "https://gateway.internal/config"
        headers:
          X-Api-Key: "..."
# syntax=docker/dockerfile:1.7
# ---- Build stage -----------------------------------------------------------
# .NET 10 SDK on Alpine. Knows how to handle file-based apps directly.
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Only the script is needed - the #: directives encode the rest of the project.
COPY traefik-gateway.cs .
# Framework-dependent publish.
# - PublishAot=false: YamlDotNet's reflection-based deserialization isn't
# AOT-safe. Disable it (file-based apps default to AOT-on).
# - PackAsTool=false: avoid producing a global tool nupkg.
# - The NuGet cache is mounted so package restores are reused across builds.
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish traefik-gateway.cs \
--configuration Release \
--output /app \
-p:PublishAot=false \
-p:PackAsTool=false
# ---- Runtime stage ---------------------------------------------------------
# ASP.NET Core 10 runtime on Alpine, no SDK. ~85 MB base.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime
WORKDIR /app
# Copy the published output (DLL + deps + runtime config) from build stage.
# The 'app' user/group already exists in Microsoft's .NET base images
# (since .NET 8), so we just chown to it and switch.
COPY --from=build --chown=app:app /app .
USER app
ENV ASPNETCORE_URLS=http://+:8080 \
DOTNET_NOLOGO=1 \
DOTNET_RUNNING_IN_CONTAINER=true
EXPOSE 8080
# Optional healthcheck against /healthz.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["dotnet", "traefik-gateway.dll"]
http:
routers:
public-api:
rule: "Host(`api.example.com`)"
entryPoints:
- websecure
service: api-backend
tls: {}
externally: true # <-- will be picked up by the gateway
internal-grafana:
rule: "Host(`grafana.internal`)"
entryPoints:
- websecure
service: grafana
tls: {}
# no externally flag -> ignored
public-app:
rule: "Host(`app.example.com`) && PathPrefix(`/`)"
entryPoints:
- websecure
service: app-backend
tls: {}
middlewares:
- rate-limit
externally: true # <-- will be picked up
mixed-domains-api:
rule: "Host(`api.example.com`, `api.legacy.internal`) && PathPrefix(`/v1`)"
entryPoints:
- websecure
service: api-backend
tls: {}
externally: true # <-- adopted, but the legacy host gets stripped
# from the rule when DomainWhitelist=example.com
# -> Host(`api.example.com`) && PathPrefix(`/v1`)
staging-api:
rule: "Host(`api.staging.internal`)"
entryPoints:
- websecure
service: staging-backend
tls: {}
externally: true # <-- flagged, but filtered out when
# DomainWhitelist excludes "*.staging.internal"
services:
api-backend:
loadBalancer:
servers:
- url: http://172.17.0.2:8080
grafana:
loadBalancer:
servers:
- url: http://172.17.0.3:3000
app-backend:
loadBalancer:
servers:
- url: http://172.17.0.4:80
staging-backend:
loadBalancer:
servers:
- url: http://172.17.0.5:8080
middlewares:
rate-limit:
rateLimit:
average: 100
#!/usr/bin/env dotnet
#:sdk Microsoft.NET.Sdk.Web
#:package YamlDotNet@15.1.6
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// --- Configuration -----------------------------------------------------------
// Where Traefik #2's dynamic route files live (yaml/yml). Mount this read-only.
var routesPath = builder.Configuration["TraefikGateway:RoutesPath"]
?? "/etc/traefik/dynamic";
// The single IP/URL that all "externally"-flagged routers will be redirected to.
// Format: scheme://host:port (e.g. http://10.20.30.40:8080)
var externalUrl = builder.Configuration["TraefikGateway:ExternalUrl"]
?? "http://10.0.0.1:80";
// Default entryPoint applied if a source router didn't specify one.
var defaultEntryPoint = builder.Configuration["TraefikGateway:DefaultEntryPoint"]
?? "websecure";
// The value this gateway expects to find in each router's `route-aggregator`
// field. Routers whose value matches (case-insensitive) are adopted; everything
// else is ignored. This lets multiple gateway instances cherry-pick different
// subsets of routes from the same source files.
// e.g. TraefikGateway__AggregatorKey="edge"
// matches routers with: route-aggregator: edge
var aggregatorKey = builder.Configuration["TraefikGateway:AggregatorKey"] ?? "";
// Optional comma-separated whitelist of domains. When set, an adopted router
// is only kept if at least one of its Host(...) clauses references a listed
// domain (exact match) or any subdomain of it. Unset/empty = allow all.
// e.g. TraefikGateway__DomainWhitelist="example.com,api.mycorp.io"
var domainWhitelist = (builder.Configuration["TraefikGateway:DomainWhitelist"] ?? "")
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
const string ExternalServiceName = "externally-flagged-target";
const string AggregatorField = "route-aggregator";
// -----------------------------------------------------------------------------
if (string.IsNullOrEmpty(aggregatorKey))
{
app.Logger.LogWarning(
"TraefikGateway:AggregatorKey is unset - no routers will be adopted. " +
"Set it to the value your route files use in their '{Field}' field.",
AggregatorField);
}
// -----------------------------------------------------------------------------
app.MapGet("/healthz", () => Results.Ok("ok"));
app.MapGet("/config", (ILogger<Program> logger) =>
{
var routers = new JsonObject();
var services = new JsonObject
{
[ExternalServiceName] = new JsonObject
{
["loadBalancer"] = new JsonObject
{
["servers"] = new JsonArray(
new JsonObject { ["url"] = externalUrl }
)
}
}
};
foreach (var (name, routerObj, rule) in EnumerateExternallyFlagged(routesPath, domainWhitelist, logger))
{
var clone = routerObj.DeepClone().AsObject();
clone.Remove(ExternallyFlag);
clone.Remove("middlewares"); // strip refs Traefik #1 wouldn't know
clone["service"] = ExternalServiceName;
clone["rule"] = rule; // possibly rewritten by the whitelist filter
if (clone["entryPoints"] is null)
clone["entryPoints"] = new JsonArray(JsonValue.Create(defaultEntryPoint));
routers[name] = clone;
logger.LogInformation("Adopted externally-flagged router '{Name}'", name);
}
var result = new JsonObject
{
["http"] = new JsonObject
{
["routers"] = routers,
["services"] = services
}
};
return Results.Content(
result.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
"application/json");
});
// Returns the deduplicated set of domains pulled from the Host(...) clauses
// of every router with externally:true. This is the canonical answer to
// "which domains are externally exposed?", derived purely from the source
// rules - no separate config needed.
app.MapGet("/domains", (ILogger<Program> logger) =>
{
var domains = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (_, _, rule) in EnumerateExternallyFlagged(routesPath, domainWhitelist, logger))
{
foreach (var host in RuleParser.ExtractHosts(rule))
domains.Add(host);
}
return Results.Json(domains, new JsonSerializerOptions { WriteIndented = true });
});
app.Run();
// Walks the routes directory once and yields every router that carries
// externally:true (and, if a whitelist is configured, whose Host(...) clauses
// reference one of the allowed domains). Shared by /config and /domains so
// they stay consistent.
static IEnumerable<(string Name, JsonObject Router, string Rule)> EnumerateExternallyFlagged(
string routesPath, IReadOnlyCollection<string> domainWhitelist, ILogger logger)
{
if (!Directory.Exists(routesPath))
{
logger.LogWarning("Routes path '{Path}' does not exist", routesPath);
yield break;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var jsonSerializer = new SerializerBuilder()
.JsonCompatible()
.Build();
var files = Directory.EnumerateFiles(routesPath, "*.*", SearchOption.AllDirectories)
.Where(f => f.EndsWith(".yml", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".yaml", StringComparison.OrdinalIgnoreCase));
foreach (var file in files)
{
// Parsing errors are logged and the file is skipped - one bad file
// shouldn't take down the whole config.
JsonObject? sourceRouters = null;
try
{
using var reader = new StreamReader(file);
var yamlObj = deserializer.Deserialize(reader);
if (yamlObj is null) continue;
var json = jsonSerializer.Serialize(yamlObj);
var node = JsonNode.Parse(json);
sourceRouters = node?["http"]?["routers"] as JsonObject;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to parse {File}", file);
}
if (sourceRouters is null) continue;
foreach (var kv in sourceRouters.ToList())
{
if (kv.Value is not JsonObject routerObj) continue;
if (!IsExternallyFlagged(routerObj)) continue;
var rule = routerObj["rule"]?.ToString() ?? "";
if (domainWhitelist.Count > 0)
{
var hosts = RuleParser.ExtractHosts(rule).ToList();
if (hosts.Count == 0)
{
logger.LogDebug(
"Skipping '{Name}': no Host(...) clause to match against DomainWhitelist",
kv.Key);
continue;
}
if (!hosts.Any(h => RuleParser.MatchesAnyDomain(h, domainWhitelist)))
{
logger.LogDebug(
"Skipping '{Name}': hosts [{Hosts}] not in DomainWhitelist",
kv.Key, string.Join(", ", hosts));
continue;
}
// Rewrite the rule to drop any non-whitelisted Host arguments.
// Returns null if the rewrite would leave a dead AND-branch -
// in that case the router can never match and we drop it.
var rewritten = RuleParser.RewriteForWhitelist(rule, domainWhitelist);
if (rewritten is null)
{
logger.LogDebug(
"Skipping '{Name}': rule has a dead AND-branch after whitelist rewrite",
kv.Key);
continue;
}
if (!string.Equals(rewritten, rule, StringComparison.Ordinal))
{
logger.LogInformation(
"Rewrote rule for '{Name}': '{Old}' -> '{New}'",
kv.Key, rule, rewritten);
}
rule = rewritten;
}
yield return (kv.Key, routerObj, rule);
}
}
}
static bool IsExternallyFlagged(JsonObject router)
{
if (router["externally"] is not JsonValue v) return false;
if (v.TryGetValue<bool>(out var b)) return b;
if (v.TryGetValue<string>(out var s))
return string.Equals(s, "true", StringComparison.OrdinalIgnoreCase);
return false;
}
static class RuleParser
{
// Matches `Host(...)` invocations - case-insensitive, allows whitespace.
private static readonly Regex HostCall =
new(@"Host\s*\(([^)]*)\)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
// Matches each backtick-quoted argument inside a Host(...) call.
private static readonly Regex Backticked =
new(@"`([^`]+)`", RegexOptions.Compiled);
public static IEnumerable<string> ExtractHosts(string rule)
{
if (string.IsNullOrEmpty(rule)) yield break;
foreach (Match call in HostCall.Matches(rule))
{
foreach (Match arg in Backticked.Matches(call.Groups[1].Value))
yield return arg.Groups[1].Value;
}
}
// True if `host` equals one of `domains` exactly, or is a subdomain of one.
// e.g. domain "example.com" matches host "example.com" AND "api.example.com".
public static bool MatchesAnyDomain(string host, IReadOnlyCollection<string> domains)
{
foreach (var d in domains)
{
if (host.Equals(d, StringComparison.OrdinalIgnoreCase)) return true;
if (host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
// Matches an "OR'd dead branch" - either "|| Host()" or "Host() ||" with
// any surrounding whitespace. Used to splice empty Host() sentinels out
// of OR chains after we filter their arguments.
private static readonly Regex DeadOrBranch =
new(@"(\s*\|\|\s*Host\(\))|(Host\(\)\s*\|\|\s*)", RegexOptions.Compiled);
// Rewrites a rule so that every Host(...) call only retains arguments that
// match the whitelist. Returns:
// - the original rule, if whitelist is empty (no-op)
// - the rewritten rule, if every surviving Host(...) is non-empty
// - null, if the rewrite would leave a dead Host() in an && expression
// (in which case the caller should drop the router entirely).
public static string? RewriteForWhitelist(string rule, IReadOnlyCollection<string> whitelist)
{
if (whitelist.Count == 0 || string.IsNullOrEmpty(rule)) return rule;
// 1. For each Host(...) call, keep only whitelisted arguments. If none
// remain, leave a sentinel "Host()" so the next pass can splice it.
var rewritten = HostCall.Replace(rule, m =>
{
var args = Backticked.Matches(m.Groups[1].Value)
.Select(b => b.Groups[1].Value)
.Where(h => MatchesAnyDomain(h, whitelist))
.ToList();
return args.Count == 0
? "Host()"
: "Host(" + string.Join(", ", args.Select(a => $"`{a}`")) + ")";
});
// 2. Splice out OR-connected dead branches: "|| Host()" or "Host() ||".
rewritten = DeadOrBranch.Replace(rewritten, "");
// 3. Any remaining Host() means it sits in an AND chain or stands alone,
// so the rule can never match a whitelisted host - signal a drop.
if (rewritten.Contains("Host()", StringComparison.Ordinal))
return null;
return rewritten.Trim();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment