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 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);
    }
}

The contributor is a deterministic function of the config. It produces:

  • Variables: disk_size, alpine_version, build_password
  • A source: virtualbox-iso.alpine-3.21 with the right ISO URL, the right checksum reference, the right boot command, and the right SSH credentials
  • An HTTP file: the answers file that setup-alpine -e -f consumes

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"

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"
        """;
}

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();
}

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();
    }
}

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.


⬇ Download