Part 25: The Alpine Base Image
"Alpine is the right base for a homelab: 5 MB ISO, 30 MB rootfs, ten years of musl polish."
Why
Every homelab VM starts from a base image. The base image is the foundation: kernel, init, package manager, users, networking, SSH. Everything else (Docker, Podman, secrets, observability agents) is layered on top.
The base image should be:
- Small — small images mean fast builds, fast box pushes, fast VM boots, low disk waste
- Stable — predictable kernel, predictable init, predictable behaviour across upgrades
- Documented — install procedure that you can read in one sitting
- Reproducible — pinned versions, pinned ISO, pinned package set
There are a handful of distributions that meet all four criteria. Alpine Linux is the best of them for HomeLab's use case: a 60 MB virtualised ISO produces a 200 MB rootfs, the package manager is apk (fast and predictable), the init is OpenRC (simple), the libc is musl (small and increasingly mainstream), and the project has shipped a stable release every six months for over a decade.
The thesis of this part is: Packer.Alpine (an existing FrenchExDev library) produces the Alpine base box, and the AlpineBaseContributor from Part 20 encodes the recipe in typed C# so HomeLab can compose it with overlays.
The shape
The contributor we already saw in Part 20:
[Injectable(ServiceLifetime.Singleton)]
[Order(10)]
public sealed class AlpineBaseContributor : IPackerBundleContributor
{
private readonly HomeLabConfig _config;
private readonly IAlpineVersionResolver _versions;
public AlpineBaseContributor(IOptions<HomeLabConfig> config, IAlpineVersionResolver versions)
{
_config = config.Value;
_versions = versions;
}
public void Contribute(PackerBundle bundle)
{
var v = _config.Packer.Version; // "3.21"
bundle.Variables["disk_size"] = _config.Packer.DiskSize;
bundle.Variables["alpine_version"] = v;
bundle.Variables["build_password"] = "alpine-build";
bundle.Sources.Add(new PackerSource
{
Type = "virtualbox-iso",
Name = $"alpine-{v}",
Properties = new()
{
["guest_os_type"] = "Linux_64",
["iso_url"] = AlpineIsoUrl(v),
["iso_checksum"] = AlpineIsoChecksum(v),
["disk_size"] = "var.disk_size",
["memory"] = _config.Packer.Memory,
["cpus"] = _config.Packer.Cpus,
["ssh_username"] = "root",
["ssh_password"] = "var.build_password",
["ssh_timeout"] = "20m",
["http_directory"] = "http",
["boot_wait"] = "10s",
["boot_command"] = AlpineBootCommand(),
["shutdown_command"] = "poweroff",
["headless"] = true,
["vboxmanage"] = new[]
{
new[] { "modifyvm", "{{.Name}}", "--nat-localhostreachable1", "on" },
new[] { "modifyvm", "{{.Name}}", "--audio", "none" },
}
}
});
bundle.HttpFiles.Add(new PackerHttpFile("answers", AlpineAnswerFileGenerator.Generate(_config)));
}
private static string AlpineIsoUrl(string version)
{
var (major, minor, patch) = ParseAlpineVersion(version);
return $"https://dl-cdn.alpinelinux.org/alpine/v{major}.{minor}/releases/x86_64/alpine-virt-{major}.{minor}.{patch}-x86_64.iso";
}
private static string AlpineIsoChecksum(string version)
{
// Reference the published checksum file directly; Packer fetches it.
var (major, minor, patch) = ParseAlpineVersion(version);
return $"file:https://dl-cdn.alpinelinux.org/alpine/v{major}.{minor}/releases/x86_64/alpine-virt-{major}.{minor}.{patch}-x86_64.iso.sha256";
}
private static IReadOnlyList<string> AlpineBootCommand() => new[]
{
"<wait5>root<enter><wait>",
"setup-alpine -e -f http://{{ .HTTPIP }}:{{ .HTTPPort }}/answers<enter>",
"<wait>alpine-build<enter><wait>alpine-build<enter><wait5>",
"y<enter>"
};
private static (int Major, int Minor, int Patch) ParseAlpineVersion(string v)
{
var parts = v.Split('.');
return (int.Parse(parts[0]), int.Parse(parts[1]), parts.Length > 2 ? int.Parse(parts[2]) : 0);
}
}[Injectable(ServiceLifetime.Singleton)]
[Order(10)]
public sealed class AlpineBaseContributor : IPackerBundleContributor
{
private readonly HomeLabConfig _config;
private readonly IAlpineVersionResolver _versions;
public AlpineBaseContributor(IOptions<HomeLabConfig> config, IAlpineVersionResolver versions)
{
_config = config.Value;
_versions = versions;
}
public void Contribute(PackerBundle bundle)
{
var v = _config.Packer.Version; // "3.21"
bundle.Variables["disk_size"] = _config.Packer.DiskSize;
bundle.Variables["alpine_version"] = v;
bundle.Variables["build_password"] = "alpine-build";
bundle.Sources.Add(new PackerSource
{
Type = "virtualbox-iso",
Name = $"alpine-{v}",
Properties = new()
{
["guest_os_type"] = "Linux_64",
["iso_url"] = AlpineIsoUrl(v),
["iso_checksum"] = AlpineIsoChecksum(v),
["disk_size"] = "var.disk_size",
["memory"] = _config.Packer.Memory,
["cpus"] = _config.Packer.Cpus,
["ssh_username"] = "root",
["ssh_password"] = "var.build_password",
["ssh_timeout"] = "20m",
["http_directory"] = "http",
["boot_wait"] = "10s",
["boot_command"] = AlpineBootCommand(),
["shutdown_command"] = "poweroff",
["headless"] = true,
["vboxmanage"] = new[]
{
new[] { "modifyvm", "{{.Name}}", "--nat-localhostreachable1", "on" },
new[] { "modifyvm", "{{.Name}}", "--audio", "none" },
}
}
});
bundle.HttpFiles.Add(new PackerHttpFile("answers", AlpineAnswerFileGenerator.Generate(_config)));
}
private static string AlpineIsoUrl(string version)
{
var (major, minor, patch) = ParseAlpineVersion(version);
return $"https://dl-cdn.alpinelinux.org/alpine/v{major}.{minor}/releases/x86_64/alpine-virt-{major}.{minor}.{patch}-x86_64.iso";
}
private static string AlpineIsoChecksum(string version)
{
// Reference the published checksum file directly; Packer fetches it.
var (major, minor, patch) = ParseAlpineVersion(version);
return $"file:https://dl-cdn.alpinelinux.org/alpine/v{major}.{minor}/releases/x86_64/alpine-virt-{major}.{minor}.{patch}-x86_64.iso.sha256";
}
private static IReadOnlyList<string> AlpineBootCommand() => new[]
{
"<wait5>root<enter><wait>",
"setup-alpine -e -f http://{{ .HTTPIP }}:{{ .HTTPPort }}/answers<enter>",
"<wait>alpine-build<enter><wait>alpine-build<enter><wait5>",
"y<enter>"
};
private static (int Major, int Minor, int Patch) ParseAlpineVersion(string v)
{
var parts = v.Split('.');
return (int.Parse(parts[0]), int.Parse(parts[1]), parts.Length > 2 ? int.Parse(parts[2]) : 0);
}
}The contributor is a deterministic function of the config. It produces:
- Variables:
disk_size,alpine_version,build_password - A source:
virtualbox-iso.alpine-3.21with the right ISO URL, the right checksum reference, the right boot command, and the right SSH credentials - An HTTP file: the
answersfile thatsetup-alpine -e -fconsumes
The answers file
setup-alpine -e -f reads an answer file in environment-variable format:
# answers — generated by AlpineAnswerFileGenerator
KEYMAPOPTS="us us"
HOSTNAMEOPTS="-n alpine-build"
INTERFACESOPTS="auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
"
DNSOPTS="-d local 8.8.8.8 8.8.4.4"
TIMEZONEOPTS="-z UTC"
PROXYOPTS="none"
APKREPOSOPTS="-1"
USEROPTS="-a -u -g audio,input,video,netdev alpine"
USERSSHKEY=""
ROOTSSHKEY=""
SSHDOPTS="-c openssh"
NTPOPTS="-c chrony"
DISKOPTS="-m sys /dev/sda"
LBUOPTS="none"
APKCACHEOPTS="none"# answers — generated by AlpineAnswerFileGenerator
KEYMAPOPTS="us us"
HOSTNAMEOPTS="-n alpine-build"
INTERFACESOPTS="auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
"
DNSOPTS="-d local 8.8.8.8 8.8.4.4"
TIMEZONEOPTS="-z UTC"
PROXYOPTS="none"
APKREPOSOPTS="-1"
USEROPTS="-a -u -g audio,input,video,netdev alpine"
USERSSHKEY=""
ROOTSSHKEY=""
SSHDOPTS="-c openssh"
NTPOPTS="-c chrony"
DISKOPTS="-m sys /dev/sda"
LBUOPTS="none"
APKCACHEOPTS="none"The generator builds this from the config:
public static class AlpineAnswerFileGenerator
{
public static string Generate(HomeLabConfig config) => $$"""
KEYMAPOPTS="us us"
HOSTNAMEOPTS="-n alpine-build"
INTERFACESOPTS="auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
"
DNSOPTS="-d {{config.Acme.Tld}} 8.8.8.8 8.8.4.4"
TIMEZONEOPTS="-z UTC"
PROXYOPTS="none"
APKREPOSOPTS="-1"
USEROPTS="-a -u -g audio,input,video,netdev alpine"
SSHDOPTS="-c openssh"
NTPOPTS="-c chrony"
DISKOPTS="-m sys /dev/sda"
LBUOPTS="none"
APKCACHEOPTS="none"
""";
}public static class AlpineAnswerFileGenerator
{
public static string Generate(HomeLabConfig config) => $$"""
KEYMAPOPTS="us us"
HOSTNAMEOPTS="-n alpine-build"
INTERFACESOPTS="auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
"
DNSOPTS="-d {{config.Acme.Tld}} 8.8.8.8 8.8.4.4"
TIMEZONEOPTS="-z UTC"
PROXYOPTS="none"
APKREPOSOPTS="-1"
USEROPTS="-a -u -g audio,input,video,netdev alpine"
SSHDOPTS="-c openssh"
NTPOPTS="-c chrony"
DISKOPTS="-m sys /dev/sda"
LBUOPTS="none"
APKCACHEOPTS="none"
""";
}The generator is unit-tested with golden files. Whenever Alpine changes the answer file syntax, one test fails, and we update the generator and the golden file together.
The wiring
AlpineBaseContributor is [Injectable(ServiceLifetime.Singleton)]. It is automatically picked up by the Generate stage of the pipeline. Its [Order(10)] ensures it runs first; the DockerHostContributor (from Part 26) has [Order(20)] and adds its scripts on top of the source the Alpine contributor created.
The IAlpineVersionResolver (from Part 11) is used to warn if the pinned version is behind upstream:
public async Task<Result> CheckVersionDriftAsync(CancellationToken ct)
{
var current = _config.Packer.Version;
var latest = await _versions.GetLatestStableAsync(major: 3, ct);
if (latest.IsFailure) return latest.Map();
if (latest.Value.ToString() != current)
{
await _events.PublishAsync(new AlpineVersionDriftDetected(current, latest.Value.ToString(), _clock.UtcNow), ct);
}
return Result.Success();
}public async Task<Result> CheckVersionDriftAsync(CancellationToken ct)
{
var current = _config.Packer.Version;
var latest = await _versions.GetLatestStableAsync(major: 3, ct);
if (latest.IsFailure) return latest.Map();
if (latest.Value.ToString() != current)
{
await _events.PublishAsync(new AlpineVersionDriftDetected(current, latest.Value.ToString(), _clock.UtcNow), ct);
}
return Result.Success();
}The Validate stage of the pipeline runs the drift check. The drift is a warning, not an error — the user might be intentionally pinned. CI can be configured to escalate the warning to an error.
The test
public sealed class AlpineBaseContributorTests
{
[Fact]
public void contributor_adds_one_source_with_correct_iso_url()
{
var bundle = new PackerBundle();
var contributor = new AlpineBaseContributor(
Options.Create(new HomeLabConfig { Packer = new() { Distro = "alpine", Version = "3.21.0", DiskSize = 20480, Cpus = 2, Memory = 1024 } }),
new FakeAlpineVersionResolver());
contributor.Contribute(bundle);
bundle.Sources.Should().ContainSingle();
bundle.Sources[0].Name.Should().Be("alpine-3.21.0");
bundle.Sources[0].Properties["iso_url"].Should().Be(
"https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-virt-3.21.0-x86_64.iso");
}
[Fact]
public void contributor_uses_file_iso_checksum_format()
{
var bundle = new PackerBundle();
new AlpineBaseContributor(/* config */).Contribute(bundle);
bundle.Sources[0].Properties["iso_checksum"].ToString().Should().StartWith("file:");
}
[Fact]
public void contributor_writes_answers_to_http_directory()
{
var bundle = new PackerBundle();
new AlpineBaseContributor(/* config */).Contribute(bundle);
bundle.HttpFiles.Should().ContainSingle(f => f.FileName == "answers");
bundle.HttpFiles[0].Content.Should().Contain("setup-alpine");
}
[Fact]
public async Task version_drift_check_emits_event_when_pinned_behind_upstream()
{
var versions = new FakeAlpineVersionResolver();
versions.LatestStable = new AlpineVersion(3, 21, 1);
var bus = new RecordingEventBus();
var checker = new AlpineDriftChecker(
Options.Create(new HomeLabConfig { Packer = new() { Version = "3.21.0" } }),
versions, bus, new FakeClock(DateTimeOffset.UtcNow));
await checker.CheckVersionDriftAsync(CancellationToken.None);
bus.Recorded.OfType<AlpineVersionDriftDetected>().Should().ContainSingle();
}
}public sealed class AlpineBaseContributorTests
{
[Fact]
public void contributor_adds_one_source_with_correct_iso_url()
{
var bundle = new PackerBundle();
var contributor = new AlpineBaseContributor(
Options.Create(new HomeLabConfig { Packer = new() { Distro = "alpine", Version = "3.21.0", DiskSize = 20480, Cpus = 2, Memory = 1024 } }),
new FakeAlpineVersionResolver());
contributor.Contribute(bundle);
bundle.Sources.Should().ContainSingle();
bundle.Sources[0].Name.Should().Be("alpine-3.21.0");
bundle.Sources[0].Properties["iso_url"].Should().Be(
"https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-virt-3.21.0-x86_64.iso");
}
[Fact]
public void contributor_uses_file_iso_checksum_format()
{
var bundle = new PackerBundle();
new AlpineBaseContributor(/* config */).Contribute(bundle);
bundle.Sources[0].Properties["iso_checksum"].ToString().Should().StartWith("file:");
}
[Fact]
public void contributor_writes_answers_to_http_directory()
{
var bundle = new PackerBundle();
new AlpineBaseContributor(/* config */).Contribute(bundle);
bundle.HttpFiles.Should().ContainSingle(f => f.FileName == "answers");
bundle.HttpFiles[0].Content.Should().Contain("setup-alpine");
}
[Fact]
public async Task version_drift_check_emits_event_when_pinned_behind_upstream()
{
var versions = new FakeAlpineVersionResolver();
versions.LatestStable = new AlpineVersion(3, 21, 1);
var bus = new RecordingEventBus();
var checker = new AlpineDriftChecker(
Options.Create(new HomeLabConfig { Packer = new() { Version = "3.21.0" } }),
versions, bus, new FakeClock(DateTimeOffset.UtcNow));
await checker.CheckVersionDriftAsync(CancellationToken.None);
bus.Recorded.OfType<AlpineVersionDriftDetected>().Should().ContainSingle();
}
}What this gives you that bash doesn't
A bash script that builds an Alpine image is wget plus qemu-img plus a virt-install command plus a hand-rolled SSH wait loop. Or, more commonly, it is a Vagrantfile that uses a third-party box from Vagrant Cloud whose contents nobody has audited.
A typed Alpine contributor in a Packer bundle pipeline gives you, for the same surface area:
- A pinned version with a generated ISO URL and a referenced checksum
- An autoinstall answer file generated from the same C# config that produces the Packer source
- A drift check that warns when the pin is behind upstream
- Tests that exercise the contributor without invoking Packer
- Composition with overlays (Docker host, Podman host, GPU passthrough) via additional contributors
The bargain pays back the first time Alpine ships 3.22 and you upgrade by changing one config field — version: "3.22.0" — and rerun homelab packer build.