Part 26: The Docker Host Overlay
"A 'Docker host' is an Alpine VM with
apk add docker, adaemon.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
""";
}[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:
- 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. - The developer's machine is the only thing that needs to talk to the Docker socket.
- 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: truedocker:
tcp_port: 2376
tcp_tls: trueWhen tcp_tls: true, the contributor:
- Generates a CA + server cert + client cert via the
Tlslibrary - Adds them to the daemon.json (
tlscacert,tlscert,tlskey) - Distributes the client cert via a Vagrant synced folder so the host can authenticate
- Sets
DOCKER_TLS_VERIFY=1andDOCKER_CERT_PATHin 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));
}
}[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 }
};
}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
.boxfile - 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.