Part 33: Traefik Routing for DevLab
"Routing is policy. Policy belongs in code, not in label strings."
Why
DevLab has roughly a dozen services exposed over HTTPS: GitLab, the GitLab registry, baget, MinIO, Grafana, Prometheus, Loki, the docs site, the Vagrant box registry, Traefik's own dashboard. Each one needs:
- A unique hostname (
gitlab.frenchexdev.lab,registry.frenchexdev.lab,baget.frenchexdev.lab, etc.) - TLS termination using the shared wildcard cert
- A specific set of middlewares: security headers (everyone), rate limiting (most), basic auth (Grafana, Prometheus, Traefik dashboard), IP allowlist (some)
- A backend pointing at the right compose service or, for non-container backends, the right file-provider entry
The thesis of this part is: every service that needs Traefik routing has a TraefikContributor that emits its router, its service, and its middlewares idempotently. The contributors are small. The middlewares are shared. The wildcard cert is the same one for all of them.
The shape
We saw the TraefikDynamicConfig types in Part 23. Here is what a contributor looks like in practice:
[Injectable(ServiceLifetime.Singleton)]
public sealed class GrafanaTraefikContributor : ITraefikContributor
{
private readonly HomeLabConfig _config;
public GrafanaTraefikContributor(IOptions<HomeLabConfig> config) => _config = config.Value;
public void Contribute(TraefikDynamicConfig dyn)
{
var host = $"grafana.{_config.Acme.Tld}";
dyn.Http.Routers["grafana"] = new TraefikRouter
{
Rule = $"Host(`{host}`)",
Service = "grafana-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "basic-auth-grafana" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
dyn.Http.Services["grafana-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://grafana:3000" } },
PassHostHeader = true,
HealthCheck = new TraefikHealthCheck
{
Path = "/api/health",
Interval = "30s",
Timeout = "5s"
}
}
};
// Shared middlewares — idempotent
dyn.Http.Middlewares["security-headers"] ??= SecurityHeadersMiddleware();
dyn.Http.Middlewares["basic-auth-grafana"] ??= new TraefikMiddleware
{
BasicAuth = new TraefikBasicAuth
{
UsersFile = "/etc/traefik/users/grafana.htpasswd"
}
};
}
private static TraefikMiddleware SecurityHeadersMiddleware() => new()
{
Headers = new TraefikHeaders
{
StsSeconds = 31536000,
StsIncludeSubdomains = true,
ContentTypeNosniff = true,
FrameDeny = true,
BrowserXssFilter = true,
ReferrerPolicy = "strict-origin-when-cross-origin",
CustomFrameOptionsValue = "SAMEORIGIN",
ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
}
};
}[Injectable(ServiceLifetime.Singleton)]
public sealed class GrafanaTraefikContributor : ITraefikContributor
{
private readonly HomeLabConfig _config;
public GrafanaTraefikContributor(IOptions<HomeLabConfig> config) => _config = config.Value;
public void Contribute(TraefikDynamicConfig dyn)
{
var host = $"grafana.{_config.Acme.Tld}";
dyn.Http.Routers["grafana"] = new TraefikRouter
{
Rule = $"Host(`{host}`)",
Service = "grafana-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "basic-auth-grafana" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
dyn.Http.Services["grafana-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://grafana:3000" } },
PassHostHeader = true,
HealthCheck = new TraefikHealthCheck
{
Path = "/api/health",
Interval = "30s",
Timeout = "5s"
}
}
};
// Shared middlewares — idempotent
dyn.Http.Middlewares["security-headers"] ??= SecurityHeadersMiddleware();
dyn.Http.Middlewares["basic-auth-grafana"] ??= new TraefikMiddleware
{
BasicAuth = new TraefikBasicAuth
{
UsersFile = "/etc/traefik/users/grafana.htpasswd"
}
};
}
private static TraefikMiddleware SecurityHeadersMiddleware() => new()
{
Headers = new TraefikHeaders
{
StsSeconds = 31536000,
StsIncludeSubdomains = true,
ContentTypeNosniff = true,
FrameDeny = true,
BrowserXssFilter = true,
ReferrerPolicy = "strict-origin-when-cross-origin",
CustomFrameOptionsValue = "SAMEORIGIN",
ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
}
};
}The contributor:
- Adds a router for the host
- Adds a service pointing at the backend
- References two middlewares (one shared, one specific)
- Adds the shared middleware idempotently with
??= - Adds the specific middleware (no idempotency needed; it is unique to this contributor)
The shared middlewares catalog
| Middleware | Purpose | Used by |
|---|---|---|
security-headers |
HSTS, CSP, X-Frame-Options, etc. | Everyone |
rate-limit-default |
100 req/s, burst 200 | Everyone except internal |
compression |
gzip + brotli | The docs site |
basic-auth-grafana |
htpasswd-protected | Grafana |
basic-auth-prometheus |
htpasswd-protected | Prometheus |
basic-auth-traefik |
htpasswd-protected | Traefik dashboard |
ip-allowlist-internal |
Allow only 192.168.56.0/24 |
Internal-only services |
redirect-to-https |
308 to https://$host$uri | The web entrypoint |
Each middleware is created by the first contributor that needs it. Subsequent contributors reference it by name. The architecture test asserts that:
[Fact]
public void shared_middlewares_are_only_defined_once_in_the_final_config()
{
var dyn = new TraefikDynamicConfig();
foreach (var c in StandardTraefikContributors())
c.Contribute(dyn);
// No middleware should be present twice in the final config
dyn.Http.Middlewares.Keys.Should().OnlyHaveUniqueItems();
}[Fact]
public void shared_middlewares_are_only_defined_once_in_the_final_config()
{
var dyn = new TraefikDynamicConfig();
foreach (var c in StandardTraefikContributors())
c.Contribute(dyn);
// No middleware should be present twice in the final config
dyn.Http.Middlewares.Keys.Should().OnlyHaveUniqueItems();
}The GitLab /admin lockdown
GitLab's /admin endpoint controls the entire instance. Even with a strong password, exposing it on the same hostname as the public site is a risk. A common pattern is to not expose /admin on the public hostname at all, and serve it on a separate internal hostname that requires the IP allowlist:
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabTraefikContributor : ITraefikContributor
{
public void Contribute(TraefikDynamicConfig dyn)
{
// Public router (everything except /admin)
dyn.Http.Routers["gitlab-public"] = new TraefikRouter
{
Rule = $"Host(`gitlab.{_config.Acme.Tld}`) && !PathPrefix(`/admin`)",
Service = "gitlab-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "rate-limit-default" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
// Admin router (separate hostname, IP-restricted)
dyn.Http.Routers["gitlab-admin"] = new TraefikRouter
{
Rule = $"Host(`gitlab-admin.{_config.Acme.Tld}`)",
Service = "gitlab-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "ip-allowlist-internal" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
// Both routers point at the same backend service
dyn.Http.Services["gitlab-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://gitlab:80" } },
PassHostHeader = true
}
};
// Idempotent middlewares
dyn.Http.Middlewares["security-headers"] ??= SecurityHeadersMiddleware();
dyn.Http.Middlewares["rate-limit-default"] ??= RateLimitMiddleware(100, 200);
dyn.Http.Middlewares["ip-allowlist-internal"] ??= new TraefikMiddleware
{
IpAllowList = new TraefikIpAllowList
{
SourceRange = new[] { $"{_config.Vos.Subnet}.0/24", "127.0.0.1/32" }
}
};
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabTraefikContributor : ITraefikContributor
{
public void Contribute(TraefikDynamicConfig dyn)
{
// Public router (everything except /admin)
dyn.Http.Routers["gitlab-public"] = new TraefikRouter
{
Rule = $"Host(`gitlab.{_config.Acme.Tld}`) && !PathPrefix(`/admin`)",
Service = "gitlab-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "rate-limit-default" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
// Admin router (separate hostname, IP-restricted)
dyn.Http.Routers["gitlab-admin"] = new TraefikRouter
{
Rule = $"Host(`gitlab-admin.{_config.Acme.Tld}`)",
Service = "gitlab-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "ip-allowlist-internal" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
// Both routers point at the same backend service
dyn.Http.Services["gitlab-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://gitlab:80" } },
PassHostHeader = true
}
};
// Idempotent middlewares
dyn.Http.Middlewares["security-headers"] ??= SecurityHeadersMiddleware();
dyn.Http.Middlewares["rate-limit-default"] ??= RateLimitMiddleware(100, 200);
dyn.Http.Middlewares["ip-allowlist-internal"] ??= new TraefikMiddleware
{
IpAllowList = new TraefikIpAllowList
{
SourceRange = new[] { $"{_config.Vos.Subnet}.0/24", "127.0.0.1/32" }
}
};
}
}/admin is now only reachable from the host-only subnet, on a separate hostname that has its own DNS entry. Anyone outside the subnet gets a 403 from Traefik before the request ever reaches GitLab.
The runner registration token flow
The GitLab runner needs to register with GitLab over HTTPS. The runner runs inside the same network as GitLab, but it talks to https://gitlab.frenchexdev.lab from inside the container. That hostname resolves via PiHole. The cert is signed by HomeLab's CA. The runner trusts the CA via the setup-ca-certificates script in the Packer overlay.
The token flow:
homelab gitlab configurecalls the GitLab API to create a registration token (or, in modern GitLab, a runner token via therunner_idflow)- The token is stored in the secrets store via
ISecretStore.WriteAsync("GITLAB_RUNNER_TOKEN", token) - The runner contributor reads the token from the secret store at provisioning time and writes it into the runner's
config.toml - The runner restarts with the new config and registers automatically
[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabRunnerComposeContributor : IComposeFileContributor
{
public void Contribute(ComposeFile compose)
{
compose.Services["gitlab-runner"] = new ComposeService
{
Image = "gitlab/gitlab-runner:v17.5.0",
Restart = "always",
Volumes = new()
{
"./gitlab-runner/config:/etc/gitlab-runner",
"/var/run/docker.sock:/var/run/docker.sock"
},
Environment = new()
{
["CI_SERVER_URL"] = $"https://gitlab.{_config.Acme.Tld}",
["RUNNER_TOKEN_FILE"] = "/run/secrets/gitlab_runner_token"
},
Networks = new() { "platform" },
Secrets = new() { "gitlab_runner_token" },
DependsOn = new()
{
["gitlab"] = new() { Condition = "service_healthy" }
}
};
compose.Secrets["gitlab_runner_token"] ??= new ComposeSecret
{
File = "./secrets/gitlab_runner_token"
};
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabRunnerComposeContributor : IComposeFileContributor
{
public void Contribute(ComposeFile compose)
{
compose.Services["gitlab-runner"] = new ComposeService
{
Image = "gitlab/gitlab-runner:v17.5.0",
Restart = "always",
Volumes = new()
{
"./gitlab-runner/config:/etc/gitlab-runner",
"/var/run/docker.sock:/var/run/docker.sock"
},
Environment = new()
{
["CI_SERVER_URL"] = $"https://gitlab.{_config.Acme.Tld}",
["RUNNER_TOKEN_FILE"] = "/run/secrets/gitlab_runner_token"
},
Networks = new() { "platform" },
Secrets = new() { "gitlab_runner_token" },
DependsOn = new()
{
["gitlab"] = new() { Condition = "service_healthy" }
}
};
compose.Secrets["gitlab_runner_token"] ??= new ComposeSecret
{
File = "./secrets/gitlab_runner_token"
};
}
}The runner reads the token from a docker secret, which is itself a file populated by ISecretStore before the compose deploy.
File provider fallback
Some "services" are not containers. Examples: a self-hosted file share, an external API, a service running directly on the Vagrant host. Traefik's docker provider cannot route to them, but the file provider can. We use a IFileProviderTraefikContributor for these cases:
[Injectable(ServiceLifetime.Singleton)]
public sealed class HostFileShareTraefikContributor : ITraefikContributor
{
public void Contribute(TraefikDynamicConfig dyn)
{
dyn.Http.Routers["fileshare"] = new TraefikRouter
{
Rule = $"Host(`fs.{_config.Acme.Tld}`)",
Service = "fileshare-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "ip-allowlist-internal" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
dyn.Http.Services["fileshare-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://192.168.56.1:8080" } }
}
};
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class HostFileShareTraefikContributor : ITraefikContributor
{
public void Contribute(TraefikDynamicConfig dyn)
{
dyn.Http.Routers["fileshare"] = new TraefikRouter
{
Rule = $"Host(`fs.{_config.Acme.Tld}`)",
Service = "fileshare-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "ip-allowlist-internal" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
dyn.Http.Services["fileshare-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://192.168.56.1:8080" } }
}
};
}
}The backend URL is the host's LAN IP, not a container name. The same Traefik instance routes to both kinds.
The test
public sealed class DevLabTraefikRoutingTests
{
[Fact]
public void every_service_with_a_compose_contributor_has_a_traefik_router()
{
var compose = new ComposeFile();
foreach (var c in ComposeContributors()) c.Contribute(compose);
var dyn = new TraefikDynamicConfig();
foreach (var c in TraefikContributors()) c.Contribute(dyn);
// For each service that has a Host(...) router, the corresponding compose service must exist
var routedHosts = dyn.Http.Routers.Values
.Select(r => Regex.Match(r.Rule, @"Host\(`([^`]+)`\)").Groups[1].Value)
.Where(h => h != "")
.ToList();
routedHosts.Should().NotBeEmpty();
// gitlab.lab → gitlab service must exist
compose.Services.Should().ContainKey("gitlab");
}
[Fact]
public void gitlab_admin_router_has_ip_allowlist_middleware()
{
var dyn = new TraefikDynamicConfig();
new GitLabTraefikContributor(/* ... */).Contribute(dyn);
dyn.Http.Routers["gitlab-admin"].Middlewares.Should().Contain("ip-allowlist-internal");
dyn.Http.Middlewares["ip-allowlist-internal"].IpAllowList!.SourceRange
.Should().Contain("192.168.56.0/24");
}
[Fact]
public void all_routers_use_the_default_cert_resolver()
{
var dyn = new TraefikDynamicConfig();
foreach (var c in TraefikContributors()) c.Contribute(dyn);
dyn.Http.Routers.Values.Should().OnlyContain(r => r.Tls != null && r.Tls.CertResolver == "default");
}
}public sealed class DevLabTraefikRoutingTests
{
[Fact]
public void every_service_with_a_compose_contributor_has_a_traefik_router()
{
var compose = new ComposeFile();
foreach (var c in ComposeContributors()) c.Contribute(compose);
var dyn = new TraefikDynamicConfig();
foreach (var c in TraefikContributors()) c.Contribute(dyn);
// For each service that has a Host(...) router, the corresponding compose service must exist
var routedHosts = dyn.Http.Routers.Values
.Select(r => Regex.Match(r.Rule, @"Host\(`([^`]+)`\)").Groups[1].Value)
.Where(h => h != "")
.ToList();
routedHosts.Should().NotBeEmpty();
// gitlab.lab → gitlab service must exist
compose.Services.Should().ContainKey("gitlab");
}
[Fact]
public void gitlab_admin_router_has_ip_allowlist_middleware()
{
var dyn = new TraefikDynamicConfig();
new GitLabTraefikContributor(/* ... */).Contribute(dyn);
dyn.Http.Routers["gitlab-admin"].Middlewares.Should().Contain("ip-allowlist-internal");
dyn.Http.Middlewares["ip-allowlist-internal"].IpAllowList!.SourceRange
.Should().Contain("192.168.56.0/24");
}
[Fact]
public void all_routers_use_the_default_cert_resolver()
{
var dyn = new TraefikDynamicConfig();
foreach (var c in TraefikContributors()) c.Contribute(dyn);
dyn.Http.Routers.Values.Should().OnlyContain(r => r.Tls != null && r.Tls.CertResolver == "default");
}
}What this gives you that bash doesn't
A hand-written Traefik dynamic config is a 500-line YAML file with routers:, services:, middlewares: sections that everyone has to scroll through to find the bit they care about. Adding a new service is "find the right indentation level". Renaming a middleware is "grep for the name and pray you found all uses".
A typed contributor pattern for Traefik gives you, for the same surface area:
- One contributor per service with router + service + middleware references
- Idempotent shared middlewares so security headers are defined once
- Architecture tests that lock router uniqueness, middleware presence, cert-resolver consistency
- Cross-checking between compose and Traefik (every routed host must have a compose service)
- File provider fallback for non-container backends with the same shape
The bargain pays back the first time you add the IP allowlist to /admin and the test suite proves it is in place.