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 26: The Docker Host Overlay

"A 'Docker host' is an Alpine VM with apk add docker, a daemon.json, and an exposed TCP socket. Three files. Make them generated."


Why

Part 25 produced a clean Alpine box. This part adds Docker on top. The result is what HomeLab actually deploys to: an Alpine VM with the Docker daemon listening on tcp://0.0.0.0:2375 (or tcp://0.0.0.0:2376 with mTLS), with the right kernel modules loaded, with the right sysctl tweaks for container networking, and with a Vagrant post-processor that emits a .box file.

The thesis of this part is: DockerHostContributor adds three things to the Packer bundle: an installation script, a daemon configuration file, and a post-processor. All three are generated from typed config. The user never edits any of them. The result is reproducible, auditable, and pinned.


The shape

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

    public void Contribute(PackerBundle bundle)
    {
        // 1. Add the install script
        bundle.Scripts.Add(new PackerScript("install-docker.sh", InstallDockerScript()));

        // 2. Add the daemon.json
        bundle.Scripts.Add(new PackerScript("daemon.json", DaemonJsonContent()));

        // 3. Add the kernel modules + sysctl script
        bundle.Scripts.Add(new PackerScript("configure-kernel.sh", KernelConfigureScript()));

        // 4. Add the provisioner that runs all three on the booted VM
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "file",
            Properties = new()
            {
                ["source"] = "scripts/daemon.json",
                ["destination"] = "/tmp/daemon.json"
            }
        });
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "shell",
            Properties = new()
            {
                ["scripts"] = new[]
                {
                    "scripts/install-docker.sh",
                    "scripts/configure-kernel.sh",
                },
                ["execute_command"] = "{{ .Vars }} sh '{{ .Path }}'"
            }
        });

        // 5. Add the post-processor that emits the .box file
        bundle.PostProcessors.Add(new PackerPostProcessor
        {
            Type = "vagrant",
            Properties = new()
            {
                ["output"] = "output-vagrant/{{.BuildName}}-dockerhost-{{.Provider}}.box",
                ["compression_level"] = 9,
                ["vagrantfile_template"] = "vagrantfile_template.rb"
            }
        });
    }

    private string InstallDockerScript() => $$"""
        #!/bin/sh
        set -eux

        # Enable community repository (where docker lives)
        sed -i 's|^#\(.*community\)|\1|' /etc/apk/repositories
        apk update
        apk add --no-cache docker docker-cli-compose ca-certificates curl

        # Add the alpine user to the docker group
        addgroup alpine docker || true

        # Install daemon.json (file provisioner copied it to /tmp)
        install -m 0644 /tmp/daemon.json /etc/docker/daemon.json

        # Enable + start docker via OpenRC
        rc-update add docker default
        service docker start

        # Wait for socket to become available
        for i in $(seq 1 30); do
            [ -S /var/run/docker.sock ] && break
            sleep 1
        done

        docker version
        """;

    private string DaemonJsonContent()
    {
        var hosts = _config.Engine == "docker"
            ? new[] { "unix:///var/run/docker.sock", "tcp://0.0.0.0:2375" }
            : new[] { "unix:///var/run/docker.sock" };

        var json = new
        {
            hosts = hosts,
            log_driver = "json-file",
            log_opts = new { max_size = "10m", max_file = "3" },
            storage_driver = "overlay2",
            features = new { buildkit = true },
            metrics_addr = "0.0.0.0:9323",
            experimental = false
        };
        return JsonSerializer.Serialize(json, new JsonSerializerOptions { WriteIndented = true });
    }

    private string KernelConfigureScript() => """
        #!/bin/sh
        set -eux

        # Load kernel modules required by Docker networking
        cat > /etc/modules-load.d/docker.conf <<EOF
        overlay
        br_netfilter
        ip_tables
        EOF

        modprobe overlay
        modprobe br_netfilter
        modprobe ip_tables

        # sysctl tweaks for container networking
        cat > /etc/sysctl.d/99-docker.conf <<EOF
        net.bridge.bridge-nf-call-iptables = 1
        net.bridge.bridge-nf-call-ip6tables = 1
        net.ipv4.ip_forward = 1
        EOF

        sysctl --system
        """;
}

The contributor adds five things: three scripts, one provisioner that uploads the daemon.json, and one post-processor. All of them are generated from the typed config (_config.Engine decides whether to expose the TCP host).


The TCP socket — and the security caveat

tcp://0.0.0.0:2375 is plain text and unauthenticated. Anything that can reach the VM on port 2375 can take full control of the Docker daemon, which means full root on the host. This is a known footgun. We deliberately accept it for HomeLab v1 because:

  1. The VM is on a private host-only network (192.168.56.0/24), which is not reachable from anything outside the developer's machine.
  2. The developer's machine is the only thing that needs to talk to the Docker socket.
  3. The threat model for a homelab is "the developer's own machine", not "the public internet".

But — and this is important — HomeLab also supports tcp://0.0.0.0:2376 with mTLS, generated by the Tls library, for any user who wants the harder bar. Switching is one config field:

docker:
  tcp_port: 2376
  tcp_tls: true

When tcp_tls: true, the contributor:

  1. Generates a CA + server cert + client cert via the Tls library
  2. Adds them to the daemon.json (tlscacert, tlscert, tlskey)
  3. Distributes the client cert via a Vagrant synced folder so the host can authenticate
  4. Sets DOCKER_TLS_VERIFY=1 and DOCKER_CERT_PATH in the host's environment when calling Docker commands

We see this in Part 28. The point of this part is: the unencrypted default is intentional, the secure mode is one flag away, and the choice is documented in code.


The wiring

DockerHostContributor is [Injectable] and [Order(20)]. It runs after AlpineBaseContributor ([Order(10)]). The Generate stage applies them in order. The Apply stage then runs packer build against the generated bundle.

[Injectable(ServiceLifetime.Singleton)]
public sealed class PackerBuildRequestHandler : IRequestHandler<PackerBuildRequest, Result<PackerBuildResponse>>
{
    private readonly IPackerClient _packer;
    private readonly IHomeLabEventBus _events;
    private readonly IClock _clock;

    public async Task<Result<PackerBuildResponse>> HandleAsync(PackerBuildRequest req, CancellationToken ct)
    {
        await _events.PublishAsync(new PackerBuildStarted(req.ImageName, _clock.UtcNow), ct);
        var sw = Stopwatch.StartNew();

        var result = await _packer.BuildAsync(req.WorkingDir, ct);
        sw.Stop();

        if (result.IsFailure)
        {
            await _events.PublishAsync(new PackerBuildFailed(req.ImageName, result.Errors, sw.Elapsed, _clock.UtcNow), ct);
            return result.Map<PackerBuildResponse>();
        }

        var boxPath = result.Value.OutputPath;
        await _events.PublishAsync(new PackerBuildCompleted(req.ImageName, boxPath, sw.Elapsed, _clock.UtcNow), ct);
        return Result.Success(new PackerBuildResponse(boxPath));
    }
}

The handler is the standard pipeline: publish started, run, publish completed, return.


The test

public sealed class DockerHostContributorTests
{
    [Fact]
    public void contributor_adds_install_script()
    {
        var bundle = new PackerBundle();
        new DockerHostContributor(Options.Create(StandardConfig())).Contribute(bundle);

        bundle.Scripts.Should().Contain(s => s.FileName == "install-docker.sh");
        bundle.Scripts.First(s => s.FileName == "install-docker.sh").Content.Should().Contain("apk add --no-cache docker");
    }

    [Fact]
    public void contributor_adds_tcp_host_when_engine_is_docker()
    {
        var bundle = new PackerBundle();
        var config = StandardConfig() with { Engine = "docker" };
        new DockerHostContributor(Options.Create(config)).Contribute(bundle);

        var daemonJson = bundle.Scripts.First(s => s.FileName == "daemon.json").Content;
        daemonJson.Should().Contain("tcp://0.0.0.0:2375");
    }

    [Fact]
    public void contributor_omits_tcp_host_when_engine_is_podman()
    {
        var bundle = new PackerBundle();
        var config = StandardConfig() with { Engine = "podman" };
        new DockerHostContributor(Options.Create(config)).Contribute(bundle);

        var daemonJson = bundle.Scripts.First(s => s.FileName == "daemon.json").Content;
        daemonJson.Should().NotContain("tcp://");
    }

    [Fact]
    public void contributor_adds_vagrant_post_processor()
    {
        var bundle = new PackerBundle();
        new DockerHostContributor(Options.Create(StandardConfig())).Contribute(bundle);

        bundle.PostProcessors.Should().ContainSingle(p => p.Type == "vagrant");
        bundle.PostProcessors[0].Properties["compression_level"].Should().Be(9);
    }

    [Fact]
    public void contributor_loads_required_kernel_modules()
    {
        var bundle = new PackerBundle();
        new DockerHostContributor(Options.Create(StandardConfig())).Contribute(bundle);

        var kernelScript = bundle.Scripts.First(s => s.FileName == "configure-kernel.sh").Content;
        kernelScript.Should().Contain("overlay");
        kernelScript.Should().Contain("br_netfilter");
    }

    private static HomeLabConfig StandardConfig() => new()
    {
        Name = "test", Topology = "single", Engine = "docker",
        Packer = new() { Distro = "alpine", Version = "3.21", DiskSize = 20480 }
    };
}

What this gives you that bash doesn't

A bash script that turns Alpine into a Docker host is the median piece of evidence in the "I have a homelab" pile. It is apk add docker, then cat > /etc/docker/daemon.json with a heredoc that uses single quotes incorrectly, then rc-update add docker, then reboot. It works once. It is not committed. It is regenerated from memory each time.

A typed DockerHostContributor gives you, for the same surface area:

  • A typed daemon.json generated from a real C# object, serialised once
  • Engine-aware behaviour (no TCP host on Podman; mTLS available on demand)
  • Kernel modules and sysctl tweaks in a deterministic script
  • A Vagrant post-processor producing a named, compressed .box file
  • Tests for every script's contents and shape
  • Composition with the Alpine base contributor and any plugin contributors

The bargain pays back the first time you upgrade Docker — change one variable in the config, rerun packer build, and the new daemon is on the next box you provision.


⬇ Download