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 20: external-dns for the Wildcard

"Every Ingress declares a hostname. Every hostname needs a DNS record. external-dns is the bridge."


Why

Part 18 installed an Ingress controller. Part 19 installed cert-manager so every Ingress can have a cert. The remaining piece is DNS: when a workload declares an Ingress with host: api.acme.lab, something has to make api.acme.lab resolve to the cluster's worker IPs from the developer's host machine.

Two options:

  1. Pre-populate a wildcard record in PiHole (or /etc/hosts) pointing *.acme.lab at the first worker. Simple, works for most cases, fails when the cluster has multiple ingress points.
  2. Run external-dns inside the cluster and let it create per-host DNS records dynamically as Ingress objects are created. Closer to production, handles multiple ingress points, supports cross-cluster.

The thesis: K8s.Dsl ships both, with external-dns as the default for k8s-multi and k8s-ha. external-dns talks to HomeLab's existing IDnsProvider plugin via a small bridge service running on the host (similar to the secrets bridge from Part 10).


The shape

[Injectable(ServiceLifetime.Singleton)]
public sealed class ExternalDnsHelmReleaseContributor : IHelmReleaseContributor
{
    public string TargetCluster => "*";
    public bool ShouldContribute() =>
        _config.K8s?.Dns?.Mode != "static-wildcard";

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

        bundle.HelmReleases.Add(new HelmReleaseSpec
        {
            Name = "external-dns",
            Namespace = "external-dns",
            Chart = "external-dns/external-dns",
            Version = "1.15.0",
            RepoUrl = "https://kubernetes-sigs.github.io/external-dns/",
            CreateNamespace = true,
            Values = new()
            {
                ["provider"] = "webhook",
                ["sources"] = new[] { "ingress", "service" },
                ["domainFilters"] = new[] { _config.Acme.Tld },
                ["policy"] = "sync",   // create + delete records to match cluster state
                ["interval"] = "1m",
                ["webhook"] = new Dictionary<string, object?>
                {
                    ["image"] = "registry.acme.lab/homelab/external-dns-bridge:1.0.0",
                    ["env"] = new[]
                    {
                        new Dictionary<string, object?> { ["name"] = "DNS_PROVIDER_URL", ["value"] = _config.K8s?.Dns?.BridgeUrl ?? "http://homelab-dns-bridge.acme.lab:8080" }
                    }
                }
            }
        });
    }
}

The Helm release uses external-dns's webhook provider. The webhook target is a small HTTP service (homelab-dns-bridge) that runs on the host (or in DevLab's compose stack) and translates external-dns's REST API calls into IDnsProvider.AddAsync / RemoveAsync calls against PiHole or the hosts file.


The bridge service

The bridge is ~200 lines of ASP.NET Core in the K8sDsl.Lib NuGet, exposed as a CLI verb:

$ homelab k8s dns-bridge serve --listen 0.0.0.0:8080
[Injectable(ServiceLifetime.Singleton)]
public sealed class ExternalDnsBridgeServer
{
    private readonly IDnsProvider _dns;
    private readonly IClock _clock;
    private readonly IHomeLabEventBus _events;

    [HttpGet("/records")]
    public async Task<IActionResult> GetRecords(CancellationToken ct)
    {
        var entries = await _dns.ListAsync(ct);
        if (entries.IsFailure) return StatusCode(500, entries.Errors);

        return Ok(entries.Value.Select(e => new
        {
            dnsName = e.Hostname,
            targets = new[] { e.Ip },
            recordType = "A",
            recordTTL = 60
        }));
    }

    [HttpPost("/records")]
    public async Task<IActionResult> SyncRecords([FromBody] List<ExternalDnsChange> changes, CancellationToken ct)
    {
        foreach (var change in changes)
        {
            if (change.Action == "Create" || change.Action == "Update")
            {
                foreach (var target in change.Targets)
                    await _dns.AddAsync(change.DnsName, target, ct);
            }
            else if (change.Action == "Delete")
            {
                await _dns.RemoveAsync(change.DnsName, ct);
            }
        }

        await _events.PublishAsync(new ExternalDnsRecordsSynced(changes.Count, _clock.UtcNow), ct);
        return Ok();
    }
}

The bridge translates the external-dns webhook API into HomeLab's IDnsProvider calls. external-dns inside the cluster polls the bridge once per minute, sends a list of desired records, and the bridge applies them to PiHole (or whichever DNS backend the user picked).


The static wildcard fallback

For users who do not want to run external-dns (and the bridge), the static-wildcard mode pre-creates one DNS entry that covers everything:

k8s:
  dns:
    mode: static-wildcard

In this mode, K8s.Dsl pre-populates the IDnsProvider with a single wildcard record:

*.acme.lab → 192.168.60.21

(192.168.60.21 is the first worker's IP.) Every Ingress hostname under acme.lab resolves to this one IP, which is hosting the ingress controller via hostNetwork. Simple, works for k8s-single and k8s-multi topologies where there is one obvious "first worker", fails to give per-host load balancing in HA topology.


What this gives you that hand-managed DNS doesn't

A hand-managed approach is homelab dns add api.acme.lab 192.168.60.21 for every new Ingress. Works. Drifts the moment you forget to remove a record after deleting an Ingress. The DNS provider accumulates stale records.

A typed external-dns + bridge gives you, for the same surface area:

  • Automatic record creation when an Ingress is created
  • Automatic record deletion when an Ingress is deleted
  • A bridge to the existing IDnsProvider so PiHole and hosts-file both work
  • A static-wildcard fallback for users who do not want the bridge
  • Tests for the bridge against fake IDnsProvider

The bargain pays back the first time you delete an Ingress and the DNS record disappears 60 seconds later without you doing anything.


⬇ Download