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 23: Talking to Traefik — Static and Dynamic

"Traefik has two YAMLs. One is for things that change at restart. The other is for things that change live. Treat them differently or break things."


Why

Traefik is HomeLab's reverse proxy of choice. It is small, fast, has a great file provider, integrates with Docker labels, and supports automatic TLS via ACME or local certs. It is also famously two-config: a static config (traefik.yaml) that defines entrypoints, providers, log level, and certificate resolvers, plus a dynamic config (dynamic.yaml) that defines routers, services, middlewares, and TLS options. Static is loaded once at startup; dynamic is hot-reloaded on change.

The thesis of this part is: HomeLab generates both files from typed C# via Traefik.Bundle. The label builder is used for the docker provider. The file builder is used for the file provider. The two are isolated and tested separately. We never hand-edit Traefik YAML.


The shape

public sealed class TraefikStaticConfig
{
    public TraefikGlobal Global { get; set; } = new();
    public TraefikLog Log { get; set; } = new();
    public Dictionary<string, TraefikEntryPoint> EntryPoints { get; set; } = new();
    public TraefikProviders Providers { get; set; } = new();
    public Dictionary<string, TraefikCertificateResolver> CertificatesResolvers { get; set; } = new();
    public TraefikApi? Api { get; set; }
    public TraefikMetrics? Metrics { get; set; }
}

public sealed class TraefikDynamicConfig
{
    public TraefikHttp Http { get; set; } = new();
    public TraefikTls Tls { get; set; } = new();
}

public sealed class TraefikHttp
{
    public Dictionary<string, TraefikRouter> Routers { get; set; } = new();
    public Dictionary<string, TraefikService> Services { get; set; } = new();
    public Dictionary<string, TraefikMiddleware> Middlewares { get; set; } = new();
}

public sealed record TraefikRouter
{
    public required string Rule { get; init; }                  // "Host(`gitlab.frenchexdev.lab`)"
    public required string Service { get; init; }
    public IReadOnlyList<string> EntryPoints { get; init; } = Array.Empty<string>();
    public IReadOnlyList<string> Middlewares { get; init; } = Array.Empty<string>();
    public TraefikRouterTls? Tls { get; init; }
}

These types come from Traefik.Bundle, which already exists. The contributors are the new bit:

public interface ITraefikContributor
{
    void Contribute(TraefikDynamicConfig dynamic);
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class GitLabTraefikContributor : ITraefikContributor
{
    private readonly HomeLabConfig _config;
    public GitLabTraefikContributor(IOptions<HomeLabConfig> config) => _config = config.Value;

    public void Contribute(TraefikDynamicConfig dyn)
    {
        var host = $"gitlab.{_config.Acme.Tld}";

        dyn.Http.Routers["gitlab"] = new TraefikRouter
        {
            Rule = $"Host(`{host}`)",
            Service = "gitlab-svc",
            EntryPoints = new[] { "websecure" },
            Middlewares = new[] { "security-headers", "rate-limit" },
            Tls = new TraefikRouterTls { CertResolver = "default" }
        };

        dyn.Http.Services["gitlab-svc"] = new TraefikService
        {
            LoadBalancer = new TraefikLoadBalancer
            {
                Servers = new[]
                {
                    new TraefikServer { Url = "http://gitlab:80" }
                },
                PassHostHeader = true,
                HealthCheck = new TraefikHealthCheck
                {
                    Path = "/-/health",
                    Interval = "30s",
                    Timeout = "5s"
                }
            }
        };

        dyn.Http.Middlewares["security-headers"] ??= new TraefikMiddleware
        {
            Headers = new TraefikHeaders
            {
                StsSeconds = 31536000,
                StsIncludeSubdomains = true,
                ContentTypeNosniff = true,
                FrameDeny = true,
                BrowserXssFilter = true,
                ReferrerPolicy = "strict-origin-when-cross-origin"
            }
        };

        dyn.Http.Middlewares["rate-limit"] ??= new TraefikMiddleware
        {
            RateLimit = new TraefikRateLimit { Average = 100, Burst = 200 }
        };
    }
}

Each service in DevLab has a contributor. They mutate the shared TraefikDynamicConfig. The ??= pattern means middlewares are added once and reused — a contributor that needs security-headers either creates it or finds the existing one.


Static config generation

The static config is generated once during homelab init and rarely changes:

[Injectable(ServiceLifetime.Singleton)]
public sealed class TraefikStaticConfigGenerator
{
    public TraefikStaticConfig Generate(HomeLabConfig config)
    {
        return new TraefikStaticConfig
        {
            Global = new TraefikGlobal { CheckNewVersion = false, SendAnonymousUsage = false },
            Log = new TraefikLog { Level = "INFO", FilePath = "/var/log/traefik/traefik.log" },
            EntryPoints = new()
            {
                ["web"]       = new TraefikEntryPoint { Address = ":80", Http = new() { Redirections = new() { EntryPoint = new() { To = "websecure", Scheme = "https" } } } },
                ["websecure"] = new TraefikEntryPoint { Address = ":443" }
            },
            Providers = new TraefikProviders
            {
                Docker = config.Engine == "docker" ? new TraefikDockerProvider
                {
                    Endpoint = "unix:///var/run/docker.sock",
                    ExposedByDefault = false,
                    Network = "devlab"
                } : null,
                File = new TraefikFileProvider
                {
                    Directory = "/etc/traefik/dynamic",
                    Watch = true
                }
            },
            CertificatesResolvers = new()
            {
                ["default"] = new TraefikCertificateResolver
                {
                    // For HomeLab, we use file-based certs from the Tls library, not ACME against Let's Encrypt
                    // (a homelab doesn't have a public DNS resolvable by LE)
                    Acme = null
                }
            },
            Api = new TraefikApi { Dashboard = true, Insecure = false }
        };
    }
}

The static config has the docker provider only when the engine is Docker; with Podman, only the file provider is used (because Podman's docker-compatibility socket has subtle differences that Traefik's docker provider does not always handle).


The wiring

The Generate stage of the pipeline collects every ITraefikContributor and writes both files:

public async Task<Result<HomeLabContext>> RunAsync(HomeLabContext ctx, CancellationToken ct)
{
    var staticConfig = _staticGenerator.Generate(ctx.Config!);

    var dynamicConfig = new TraefikDynamicConfig();
    foreach (var contributor in _traefikContributors)
        contributor.Contribute(dynamicConfig);

    var staticResult = await _writer.WriteTraefikStaticAsync(staticConfig, ctx.Request.OutputDir, ct);
    var dynamicResult = await _writer.WriteTraefikDynamicAsync(dynamicConfig, ctx.Request.OutputDir, ct);

    if (staticResult.IsFailure) return staticResult.Map<HomeLabContext>();
    if (dynamicResult.IsFailure) return dynamicResult.Map<HomeLabContext>();

    var artifacts = ctx.Artifacts! with { Traefik = (staticResult.Value, dynamicResult.Value) };
    return Result.Success(ctx with { Artifacts = artifacts });
}

The two files land at out/traefik/traefik.yaml and out/traefik/dynamic/dynamic.yaml. The compose file mounts out/traefik/ into the Traefik container at /etc/traefik/.


The label builder (for the docker provider)

When the docker provider is in use, services can also be tagged with Traefik labels in the compose file. We use a typed builder to avoid label-name typos:

public sealed class TraefikLabels
{
    private readonly Dictionary<string, string> _labels = new();

    public TraefikLabels Enable() { _labels["traefik.enable"] = "true"; return this; }

    public TraefikLabels Router(string name, Action<TraefikLabelRouter> configure)
    {
        var r = new TraefikLabelRouter(name, _labels);
        configure(r);
        return this;
    }

    public IReadOnlyDictionary<string, string> Build() => _labels;
}

public sealed class TraefikLabelRouter
{
    private readonly string _name;
    private readonly Dictionary<string, string> _labels;
    public TraefikLabelRouter(string name, Dictionary<string, string> labels) { _name = name; _labels = labels; }

    public TraefikLabelRouter Rule(string rule)
    {
        _labels[$"traefik.http.routers.{_name}.rule"] = rule;
        return this;
    }

    public TraefikLabelRouter EntryPoints(params string[] eps)
    {
        _labels[$"traefik.http.routers.{_name}.entrypoints"] = string.Join(",", eps);
        return this;
    }

    public TraefikLabelRouter Tls(string? certResolver = null)
    {
        _labels[$"traefik.http.routers.{_name}.tls"] = "true";
        if (certResolver is not null) _labels[$"traefik.http.routers.{_name}.tls.certresolver"] = certResolver;
        return this;
    }
}

// Usage in a compose contributor:
var labels = new TraefikLabels()
    .Enable()
    .Router("gitlab", r => r
        .Rule("Host(`gitlab.frenchexdev.lab`)")
        .EntryPoints("websecure")
        .Tls(certResolver: "default"))
    .Build();

composeService.Labels = labels;

The builder catches typos at compile time. traefik.http.routers.gitlab.entrypints (missing o) is impossible to write because the method does the right thing.


The test

public sealed class TraefikContributorTests
{
    [Fact]
    public void gitlab_contributor_adds_router_service_and_middlewares()
    {
        var dyn = new TraefikDynamicConfig();
        var config = Options.Create(new HomeLabConfig
        {
            Name = "test", Topology = "single", Acme = new() { Name = "fxd", Tld = "lab" }
        });
        var c = new GitLabTraefikContributor(config);

        c.Contribute(dyn);

        dyn.Http.Routers.Should().ContainKey("gitlab");
        dyn.Http.Routers["gitlab"].Rule.Should().Be("Host(`gitlab.lab`)");
        dyn.Http.Routers["gitlab"].Tls!.CertResolver.Should().Be("default");
        dyn.Http.Services.Should().ContainKey("gitlab-svc");
        dyn.Http.Middlewares.Should().ContainKey("security-headers");
        dyn.Http.Middlewares.Should().ContainKey("rate-limit");
    }

    [Fact]
    public void multiple_contributors_share_middlewares_via_idempotent_add()
    {
        var dyn = new TraefikDynamicConfig();
        var cfg = Options.Create(new HomeLabConfig { Name = "t", Topology = "multi", Acme = new() { Tld = "lab" } });

        new GitLabTraefikContributor(cfg).Contribute(dyn);
        new BagetTraefikContributor(cfg).Contribute(dyn);
        // both want security-headers; the middleware should appear once

        dyn.Http.Middlewares.Should().ContainKey("security-headers");
        dyn.Http.Routers.Should().ContainKey("gitlab");
        dyn.Http.Routers.Should().ContainKey("baget");
    }

    [Fact]
    public void label_builder_emits_correct_label_keys_and_values()
    {
        var labels = new TraefikLabels()
            .Enable()
            .Router("api", r => r.Rule("Host(`api.lab`)").EntryPoints("websecure").Tls(certResolver: "default"))
            .Build();

        labels["traefik.enable"].Should().Be("true");
        labels["traefik.http.routers.api.rule"].Should().Be("Host(`api.lab`)");
        labels["traefik.http.routers.api.entrypoints"].Should().Be("websecure");
        labels["traefik.http.routers.api.tls"].Should().Be("true");
        labels["traefik.http.routers.api.tls.certresolver"].Should().Be("default");
    }
}

What this gives you that bash doesn't

Hand-written Traefik YAML is the second-most common source of drift in a homelab (after compose YAML). Every label is a string. Every router rule is a string. Every middleware reference is a string. Renaming a service in compose silently breaks the Traefik router that pointed at it.

A typed Traefik bundle with contributors and a label builder gives you, for the same surface area:

  • A static config generated once, schema-checked
  • A dynamic config built from contributors, with shared middlewares deduplicated
  • A label builder for the docker provider that catches typos
  • Both engines (docker provider + file provider) supported via config flag
  • Tests that lock router rules and middleware shape

The bargain pays back the first time you rename a service and Traefik also updates, because the same C# source generated both files.


⬇ Download