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 35: DNS — Hosts File vs PiHole

"/etc/hosts works on your laptop. It does not work inside the container that is running on the VM that is running on your laptop."


Why

DevLab needs DNS. Specifically, it needs gitlab.frenchexdev.lab to resolve to the right IP from at least three places:

  1. The host machine (your laptop). When you load https://gitlab.frenchexdev.lab in a browser, the browser asks the OS resolver, which checks /etc/hosts first.
  2. Other VMs in DevLab (in multi-VM topology). The platform VM needs to resolve data.frenchexdev.lab to reach Postgres, MinIO, etc.
  3. Containers running inside the VMs (most importantly, the GitLab runner). When the runner pulls a NuGet package from https://baget.frenchexdev.lab, it needs that hostname to resolve from inside the container.

/etc/hosts solves (1). It does nothing for (2) and (3) — VMs and containers do not see the host's hosts file. For those, you need a real DNS server, and the simplest one for a homelab is PiHole: a small Pi-friendly DNS resolver with a REST API for managing custom records.

The thesis of this part is: HomeLab supports two DNS providers via the IDnsProvider plugin contract: HostsFileDnsProvider (default, solo dev) and PiHoleDnsProvider (multi-VM, containers). The user picks via config. Adding more providers (Cloudflare for off-network testing, Route53 for AWS labs) is a plugin away.


The shape

public interface IDnsProvider
{
    string Name { get; }
    Task<Result> AddAsync(string hostname, string ip, CancellationToken ct);
    Task<Result> RemoveAsync(string hostname, CancellationToken ct);
    Task<Result<IReadOnlyList<DnsEntry>>> ListAsync(CancellationToken ct);
}

public sealed record DnsEntry(string Hostname, string Ip, string Provider);

HostsFileDnsProvider

[Injectable(ServiceLifetime.Singleton)]
public sealed class HostsFileDnsProvider : IDnsProvider
{
    public string Name => "hosts-file";
    private readonly IFileSystem _fs;
    private readonly IPlatformInfo _platform;
    private const string Marker = "# managed-by-homelab";

    public async Task<Result> AddAsync(string hostname, string ip, CancellationToken ct)
    {
        var path = _platform.HostsFilePath;  // /etc/hosts on Unix, C:\Windows\System32\drivers\etc\hosts on Windows
        var lines = (await _fs.File.ReadAllLinesAsync(path, ct)).ToList();

        var existingIndex = lines.FindIndex(l => l.Contains(Marker) && l.Contains(hostname));
        var newLine = $"{ip}\t{hostname}\t{Marker}";

        if (existingIndex >= 0)
            lines[existingIndex] = newLine;
        else
            lines.Add(newLine);

        try
        {
            await _fs.File.WriteAllLinesAsync(path, lines, ct);
            return Result.Success();
        }
        catch (UnauthorizedAccessException)
        {
            return Result.Failure(
                $"cannot write to {path}: run with sudo / Administrator, or use a different DNS provider");
        }
    }

    public async Task<Result> RemoveAsync(string hostname, CancellationToken ct)
    {
        var path = _platform.HostsFilePath;
        var lines = (await _fs.File.ReadAllLinesAsync(path, ct))
            .Where(l => !(l.Contains(Marker) && l.Contains(hostname)))
            .ToList();
        await _fs.File.WriteAllLinesAsync(path, lines, ct);
        return Result.Success();
    }

    public async Task<Result<IReadOnlyList<DnsEntry>>> ListAsync(CancellationToken ct)
    {
        var path = _platform.HostsFilePath;
        var lines = await _fs.File.ReadAllLinesAsync(path, ct);
        var entries = lines
            .Where(l => l.Contains(Marker))
            .Select(l =>
            {
                var parts = l.Split('\t', StringSplitOptions.RemoveEmptyEntries);
                return new DnsEntry(parts[1], parts[0], "hosts-file");
            })
            .ToList();
        return Result.Success<IReadOnlyList<DnsEntry>>(entries);
    }
}

The marker comment lets us round-trip our entries without disturbing entries the user added by hand. We never touch a line that does not have # managed-by-homelab.

PiHoleDnsProvider

[Injectable(ServiceLifetime.Singleton)]
public sealed class PiHoleDnsProvider : IDnsProvider
{
    public string Name => "pihole";

    private readonly IPiHoleApi _api;
    private readonly ISecretStore _secrets;

    public async Task<Result> AddAsync(string hostname, string ip, CancellationToken ct)
    {
        var token = await _secrets.ReadAsync("PIHOLE_API_TOKEN", ct);
        if (token.IsFailure) return token.Map();

        var result = await _api.AddCustomDnsAsync(hostname, ip, token.Value, ct);
        return result.Map();
    }

    public async Task<Result> RemoveAsync(string hostname, CancellationToken ct)
    {
        var token = await _secrets.ReadAsync("PIHOLE_API_TOKEN", ct);
        if (token.IsFailure) return token.Map();

        var result = await _api.RemoveCustomDnsAsync(hostname, token.Value, ct);
        return result.Map();
    }

    // ...
}

[TypedHttpClient]
public partial interface IPiHoleApi
{
    [Get("/admin/api.php?customdns&action=add&domain={hostname}&ip={ip}&auth={token}")]
    Task<Result<PiHoleResponse>> AddCustomDnsAsync(string hostname, string ip, string token, CancellationToken ct);

    [Get("/admin/api.php?customdns&action=delete&domain={hostname}&auth={token}")]
    Task<Result<PiHoleResponse>> RemoveCustomDnsAsync(string hostname, string token, CancellationToken ct);

    [Get("/admin/api.php?customdns&action=get&auth={token}")]
    Task<Result<PiHoleCustomDnsListResponse>> ListCustomDnsAsync(string token, CancellationToken ct);
}

The PiHole API is a simple REST surface (the v5 API; v6 has GraphQL, which the contract above can be migrated to without changing consumers).


When to use which

Scenario Provider
Solo dev, single-VM topology, only the host browses DevLab hosts-file
Solo dev, multi-VM topology, runners inside VMs need DNS pihole
Team, shared LAN, everyone uses the same DevLab pihole (with PiHole on a shared host)
CI ephemeral instance hosts-file (the CI runner is the only client)

The user picks via config:

dns:
  provider: pihole
  pihole_url: https://pihole.frenchexdev.lab
  # The token is in the secret store as PIHOLE_API_TOKEN

PiHole as a DevLab service

PiHole is itself one of DevLab's compose services, contributed by PiholeComposeContributor:

[Injectable(ServiceLifetime.Singleton)]
public sealed class PiholeComposeContributor : IComposeFileContributor
{
    public string TargetVm => "gateway";

    public void Contribute(ComposeFile compose)
    {
        compose.Services["pihole"] = new ComposeService
        {
            Image = "pihole/pihole:latest",
            Restart = "always",
            Hostname = "pihole",
            Environment = new()
            {
                ["TZ"] = "UTC",
                ["WEBPASSWORD_FILE"] = "/run/secrets/pihole_admin_password",
                ["DNSSEC"] = "true",
                ["VIRTUAL_HOST"] = $"pihole.{_config.Acme.Tld}",
            },
            Volumes = new()
            {
                "./pihole/etc-pihole:/etc/pihole",
                "./pihole/etc-dnsmasq.d:/etc/dnsmasq.d"
            },
            Ports = new()
            {
                "53:53/tcp",
                "53:53/udp",
                // The web UI is exposed via Traefik, not directly
            },
            Networks = new() { "platform" },
            Secrets = new() { "pihole_admin_password" },
            CapAdd = new() { "NET_ADMIN" }
        };

        compose.Secrets["pihole_admin_password"] ??= new ComposeSecret { File = "./secrets/pihole_admin_password" };
    }
}

The PiHole container exposes DNS on port 53 of the gateway VM. Other VMs in DevLab use that VM's IP as their DNS server (set in their Vagrant network config). Containers inside those VMs inherit the DNS via Docker's --dns flag.

This is the dogfood loop in microcosm: HomeLab uses PiHole to resolve hostnames; PiHole runs as part of DevLab; HomeLab provisioned DevLab.


The wiring

The DNS provider is selected at startup:

services.AddSingleton<IDnsProvider>(sp =>
{
    var config = sp.GetRequiredService<IOptions<HomeLabConfig>>().Value;
    return config.Dns.Provider switch
    {
        "hosts-file" => sp.GetRequiredService<HostsFileDnsProvider>(),
        "pihole"     => sp.GetRequiredService<PiHoleDnsProvider>(),
        _ => throw new InvalidOperationException($"unknown DNS provider: {config.Dns.Provider}")
    };
});

CLI verbs delegate to the provider:

[Injectable(ServiceLifetime.Singleton)]
public sealed class DnsAddRequestHandler : IRequestHandler<DnsAddRequest, Result<DnsAddResponse>>
{
    private readonly IDnsProvider _provider;
    private readonly IHomeLabEventBus _events;
    private readonly IClock _clock;

    public async Task<Result<DnsAddResponse>> HandleAsync(DnsAddRequest req, CancellationToken ct)
    {
        var result = await _provider.AddAsync(req.Hostname, req.Ip, ct);
        if (result.IsFailure) return result.Map<DnsAddResponse>();

        await _events.PublishAsync(new DnsEntryAdded(req.Hostname, req.Ip, _provider.Name, _clock.UtcNow), ct);
        return Result.Success(new DnsAddResponse(req.Hostname, req.Ip, _provider.Name));
    }
}

The test

public sealed class DnsProviderTests
{
    [Fact]
    public async Task hosts_file_provider_adds_entry_with_marker()
    {
        var fs = new MockFileSystem();
        fs.AddFile("/etc/hosts", new MockFileData("127.0.0.1 localhost\n"));
        var provider = new HostsFileDnsProvider(fs, new FakePlatformInfo("/etc/hosts"));

        var result = await provider.AddAsync("gitlab.lab", "192.168.56.10", default);

        result.IsSuccess.Should().BeTrue();
        var content = fs.File.ReadAllText("/etc/hosts");
        content.Should().Contain("192.168.56.10\tgitlab.lab\t# managed-by-homelab");
        content.Should().Contain("127.0.0.1 localhost"); // unmodified
    }

    [Fact]
    public async Task hosts_file_provider_removes_only_managed_entries()
    {
        var fs = new MockFileSystem();
        fs.AddFile("/etc/hosts", new MockFileData(
            "127.0.0.1 localhost\n" +
            "192.168.1.5 manual-entry\n" +
            "192.168.56.10\tgitlab.lab\t# managed-by-homelab\n"));
        var provider = new HostsFileDnsProvider(fs, new FakePlatformInfo("/etc/hosts"));

        await provider.RemoveAsync("gitlab.lab", default);

        var content = fs.File.ReadAllText("/etc/hosts");
        content.Should().NotContain("gitlab.lab");
        content.Should().Contain("manual-entry");
        content.Should().Contain("localhost");
    }

    [Fact]
    public async Task pihole_provider_calls_api_with_token_from_secret_store()
    {
        var secrets = new InMemorySecretStore();
        await secrets.WriteAsync("PIHOLE_API_TOKEN", "abc123", default);

        var api = new ScriptedPiHoleApi();
        api.OnAddCustomDns("gitlab.lab", "192.168.56.10", "abc123", PiHoleResponse.Success);

        var provider = new PiHoleDnsProvider(api, secrets);
        var result = await provider.AddAsync("gitlab.lab", "192.168.56.10", default);

        result.IsSuccess.Should().BeTrue();
        api.Calls.Should().ContainSingle(c => c.Method == "AddCustomDns" && c.Token == "abc123");
    }
}

What this gives you that bash doesn't

A bash script that manages DNS is sudo sed -i against /etc/hosts plus a curl to the PiHole admin URL with a token interpolated into the URL string. The first time you forget to escape a regex character, the sed eats a line you wanted to keep. The first time the token has a special character, the curl fails silently.

A typed two-provider DNS layer with the IDnsProvider plugin contract gives you, for the same surface area:

  • Two ready-to-use providers (hosts-file, pihole) selected by config
  • A marker comment that protects user-added hosts entries
  • A typed PiHole API with the token sourced from the secret store
  • Plugin extensibility — Cloudflare, Route53, and other providers slot in via NuGet
  • Tests that exercise both providers without touching real files or the network

The bargain pays back the first time you switch from hosts-file to pihole with one config change and watch the runners inside your DevLab VMs start resolving gitlab.frenchexdev.lab correctly.


End of Act V

We have now defined every layer of the running lab: the compose files (per VM, via contributors), the Traefik routing (with shared middlewares and the /admin lockdown), the TLS (CA, wildcard, OS trust enrollment), and the DNS (hosts file or PiHole). With these in place, homelab compose deploy against an up-and-running set of VMs produces a fully operational DevLab — every service routed, every connection encrypted, every name resolvable.

Act VI is the standing-up of DevLab end to end: the bootstrap paradox and its bash-script answer, the full single-VM and multi-VM deployment sequence, then a part each for the four real DevLab use cases that close the dogfood loops — Postgres+MinIO, GitLab+runners, the box registry, the NuGet feed, the docs site.


⬇ Download