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