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
└────────────────────┘
| 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 |
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: trueEach poll, Traefik fetches /config, validates the JSON, and merges it with
whatever the file/docker/k8s providers also produced.
- Walks
TraefikGateway:RoutesPathfor*.yml/*.yaml. - Parses each file as a Traefik dynamic config.
- For every router under
http.routersthat has the custom fieldexternally: true, it:- clones the router definition,
- removes the marker field and any
middlewaresreferences, - rewrites
serviceto a single shared service calledexternally-flagged-target.
- If
TraefikGateway:DomainWhitelistis set, the filter does two things:- Drop the router if none of its
Host(...)clauses references a whitelisted domain (exact match or any subdomain of it). - Rewrite the surviving router's
ruleto remove every individualHost(...)argument that isn't whitelisted. SoHost(a.example.com,b.legacy.internal)with whitelistexample.combecomesHost(a.example.com). EmptyHost()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.
- Drop the router if none of its
- Defines the shared service as a load-balancer with one server pointing
at
TraefikGateway:ExternalUrl. - Returns the result as JSON at
GET /config.
Two extra endpoints sit alongside /config:
GET /healthz— liveness probe used by the DockerHEALTHCHECKand bycompose.yaml'sdepends_onblock.GET /domains— returns a deduplicated, alphabetically sorted JSON array of every domain mentioned in theHost(...)clauses of the adopted routers (after whitelist filtering and rule rewriting). Useful for downstream tooling: cert provisioning, DNS reconciliation, audit reports.
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.
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 ingestedEdit sample-routes/example.yml, wait ~10 s, and you'll see Traefik #1 pick
up the change without restarting.
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 (usedocker execonly withnsenterstyle debugging tools). - Native AOT. File-based apps default to
PublishAot=true. To try it, remove-p:PublishAot=falsefrom the Dockerfile and switch the runtime stage to a minimal base likegcr.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.YamlDotNetwith source generators, orVYaml) for AOT to succeed cleanly.
# 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:8080On Linux/macOS you can also chmod +x traefik-gateway.cs and run it as
./traefik-gateway.cs thanks to the shebang.
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-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 whitelistWith 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.
- 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.middlewaresinto the response. - No TCP / UDP routers — only
http.routersare processed. Easy to add. - No caching / file watching. Every
/configcall re-reads the disk. Fine for typical 5–30 s poll intervals; addIMemoryCache+FileSystemWatcherif your file count grows. - Auth. The
/configendpoint 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: "..."