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

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'"
        }
    };
}

The contributor:

  1. Adds a router for the host
  2. Adds a service pointing at the backend
  3. References two middlewares (one shared, one specific)
  4. Adds the shared middleware idempotently with ??=
  5. 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();
}

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" }
            }
        };
    }
}

/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:

  1. homelab gitlab configure calls the GitLab API to create a registration token (or, in modern GitLab, a runner token via the runner_id flow)
  2. The token is stored in the secrets store via ISecretStore.WriteAsync("GITLAB_RUNNER_TOKEN", token)
  3. The runner contributor reads the token from the secret store at provisioning time and writes it into the runner's config.toml
  4. 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"
        };
    }
}

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" } }
            }
        };
    }
}

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");
    }
}

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.


⬇ Download