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 18: Ingress Controller — nginx-ingress vs Traefik

"The Ingress object is standard. The controller behind it is a choice. Two are first-class for HomeLab K8s."


Why

Kubernetes' Ingress object is standard. The controller that watches Ingress objects and configures a load balancer is not — it is a separate thing the user installs. The most common choices are nginx-ingress (the de-facto standard) and Traefik (the one HomeLab Docker already uses for its compose stack).

The thesis: K8s.Dsl ships two IHelmReleaseContributors — NginxIngressHelmReleaseContributor and TraefikIngressHelmReleaseContributor. The user picks via k8s.ingress. Both expose port 80/443 on the gateway VM via hostNetwork-like configuration so the Ingress is reachable from the host machine over the lab's wildcard hostname.


The shape

[Injectable(ServiceLifetime.Singleton)]
public sealed class NginxIngressHelmReleaseContributor : IHelmReleaseContributor
{
    public string TargetCluster => "*";
    public bool ShouldContribute() => (_config.K8s?.Ingress ?? "nginx") == "nginx";

    public void Contribute(KubernetesBundle bundle)
    {
        if (!ShouldContribute()) return;

        bundle.HelmReleases.Add(new HelmReleaseSpec
        {
            Name = "ingress-nginx",
            Namespace = "ingress-nginx",
            Chart = "ingress-nginx/ingress-nginx",
            Version = "4.11.3",
            RepoUrl = "https://kubernetes.github.io/ingress-nginx",
            CreateNamespace = true,
            Wait = true,
            Values = new()
            {
                ["controller"] = new Dictionary<string, object?>
                {
                    ["kind"] = "DaemonSet",
                    ["hostNetwork"] = true,
                    ["dnsPolicy"] = "ClusterFirstWithHostNet",
                    ["service"] = new Dictionary<string, object?>
                    {
                        ["enabled"] = false   // hostNetwork mode, no Service needed
                    },
                    ["nodeSelector"] = new Dictionary<string, object?>
                    {
                        ["k8s.role"] = "worker"
                    },
                    ["ingressClassResource"] = new Dictionary<string, object?>
                    {
                        ["name"] = "nginx",
                        ["default"] = true
                    },
                    ["config"] = new Dictionary<string, object?>
                    {
                        ["enable-real-ip"] = "true",
                        ["use-forwarded-headers"] = "true",
                        ["proxy-body-size"] = "100m"
                    }
                }
            }
        });
    }
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class TraefikIngressHelmReleaseContributor : IHelmReleaseContributor
{
    public string TargetCluster => "*";
    public bool ShouldContribute() => _config.K8s?.Ingress == "traefik";

    public void Contribute(KubernetesBundle bundle)
    {
        if (!ShouldContribute()) return;

        bundle.HelmReleases.Add(new HelmReleaseSpec
        {
            Name = "traefik",
            Namespace = "traefik",
            Chart = "traefik/traefik",
            Version = "32.1.0",
            RepoUrl = "https://traefik.github.io/charts",
            CreateNamespace = true,
            Values = new()
            {
                ["deployment"] = new Dictionary<string, object?>
                {
                    ["kind"] = "DaemonSet"
                },
                ["hostNetwork"] = true,
                ["service"] = new Dictionary<string, object?>
                {
                    ["enabled"] = false
                },
                ["ingressRoute"] = new Dictionary<string, object?>
                {
                    ["dashboard"] = new Dictionary<string, object?> { ["enabled"] = false }
                },
                ["providers"] = new Dictionary<string, object?>
                {
                    ["kubernetesIngress"] = new Dictionary<string, object?> { ["enabled"] = true },
                    ["kubernetesCRD"] = new Dictionary<string, object?> { ["enabled"] = true }
                },
                ["nodeSelector"] = new Dictionary<string, object?>
                {
                    ["k8s.role"] = "worker"
                }
            }
        });
    }
}

Both controllers run as DaemonSets with hostNetwork: true so they bind directly to ports 80/443 on the worker nodes' host network. This is how the host machine reaches the cluster: a request to https://gitlab.acme.lab resolves (via PiHole) to the IP of acme-w-1, which is hosting the ingress controller, which routes the request to the right Service inside the cluster.

The alternative — Service: type=LoadBalancer plus metallb or kube-vip — works on real production but adds another moving part for dev. The hostNetwork approach is simpler and matches the multi-node topology cleanly.


Choosing between them

Reason to pick nginx-ingress Reason to pick Traefik
Production runs nginx-ingress Production runs Traefik
You want the most-tested, most-documented option You want CRDs (IngressRoute, Middleware) for advanced routing
You use the rich annotation set (nginx.ingress.kubernetes.io/...) You use Middleware CRDs for shared cross-cutting concerns
You need TCP/UDP ingress via tcp-services ConfigMap You want HomeLab Docker's existing Traefik knowledge to transfer

The default is nginx. K8s.Dsl ships both because both are common in production and dev should match.


The cluster-wide IngressClass

Both contributors set themselves as the default IngressClass. The architecture test from Part 17 (the one for default StorageClass) is generalized to cover any "default-marked" cluster-scoped resource:

[Fact]
public void only_one_ingress_class_is_marked_default()
{
    var bundle = new KubernetesBundle();
    foreach (var c in EnabledIngressContributors()) c.Contribute(bundle);

    var defaults = bundle.CrdInstances
        .Where(m => m.Kind == "IngressClass")
        .Where(m => (m.Metadata?.Annotations?.GetValueOrDefault("ingressclass.kubernetes.io/is-default-class") ?? "false") == "true");

    defaults.Should().HaveCountLessOrEqualTo(1);
}

The wildcard hostname routing

The cluster's wildcard hostname (*.acme.lab) needs to resolve to every worker node so any worker can serve any Ingress. The external-dns integration from Part 20 handles this: it watches Ingress objects, extracts their host field, and creates DNS records pointing at all worker IPs (or at the load balancer in HA topology).

Until external-dns is installed (which happens at the same homelab k8s apply step), HomeLab pre-populates the DNS provider (PiHole) with a single wildcard entry *.acme.lab → 192.168.60.21 (the first worker's IP). After external-dns takes over, the wildcard is replaced by per-host entries.


What this gives you that hand-rolled ingress doesn't

A bash install of nginx-ingress is helm install ingress-nginx ingress-nginx/ingress-nginx --set controller.kind=DaemonSet --set controller.hostNetwork=true ... with a long flag list. The flags drift from version to version. The user maintains a values.yaml file that drifts from the actual install.

A typed IHelmReleaseContributor for each ingress gives you, for the same surface area:

  • Two parallel contributors with the same shape
  • Typed values as a Dictionary<string, object?> projected from typed C# fields
  • Automatic default IngressClass with architecture-test enforcement
  • nodeSelector matching the worker label so the controllers do not land on control planes
  • Plugin extensibility for a future Contour or HAProxy ingress

The bargain pays back the first time you switch from nginx-ingress to Traefik with one config field and watch every existing Ingress object continue to work because both controllers honour the standard Ingress API.


⬇ Download