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 27: The Podman Host Overlay

"Podman is Docker without the daemon. Until you discover the things the daemon was actually doing for you. The contributor handles the difference."


Why

Part 26 added Docker. This part adds Podman as the parallel option. The two contributors are symmetric — same shape, same five concerns, same post-processor. The differences are confined to:

  1. Package (podman instead of docker)
  2. Daemonless socket activation (podman.socket via systemd-user, or an OpenRC equivalent on Alpine)
  3. Configuration file (containers.conf and registries.conf instead of daemon.json)
  4. Rootless setup (subuid / subgid mappings, slirp4netns, fuse-overlayfs)
  5. No addgroup user docker equivalent — Podman runs as the calling user

The thesis of this part is: the Podman overlay is structurally identical to the Docker overlay, with five well-bounded differences. Both ship as [Injectable] contributors. The composition root picks one based on config.engine.


The shape

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

    public bool ShouldContribute() => _config.Engine == "podman";

    public void Contribute(PackerBundle bundle)
    {
        if (!ShouldContribute()) return;

        bundle.Scripts.Add(new PackerScript("install-podman.sh", InstallPodmanScript()));
        bundle.Scripts.Add(new PackerScript("containers.conf", ContainersConfContent()));
        bundle.Scripts.Add(new PackerScript("registries.conf", RegistriesConfContent()));
        bundle.Scripts.Add(new PackerScript("setup-rootless.sh", SetupRootlessScript()));

        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "file",
            Properties = new()
            {
                ["source"] = "scripts/containers.conf",
                ["destination"] = "/tmp/containers.conf"
            }
        });
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "file",
            Properties = new()
            {
                ["source"] = "scripts/registries.conf",
                ["destination"] = "/tmp/registries.conf"
            }
        });
        bundle.Provisioners.Add(new PackerProvisioner
        {
            Type = "shell",
            Properties = new()
            {
                ["scripts"] = new[]
                {
                    "scripts/install-podman.sh",
                    "scripts/setup-rootless.sh",
                },
                ["execute_command"] = "{{ .Vars }} sh '{{ .Path }}'"
            }
        });

        bundle.PostProcessors.Add(new PackerPostProcessor
        {
            Type = "vagrant",
            Properties = new()
            {
                ["output"] = "output-vagrant/{{.BuildName}}-podmanhost-{{.Provider}}.box",
                ["compression_level"] = 9
            }
        });
    }

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

        sed -i 's|^#\(.*community\)|\1|' /etc/apk/repositories
        apk update
        apk add --no-cache podman podman-compose fuse-overlayfs slirp4netns shadow ca-certificates curl

        # Configuration files
        mkdir -p /etc/containers
        install -m 0644 /tmp/containers.conf /etc/containers/containers.conf
        install -m 0644 /tmp/registries.conf /etc/containers/registries.conf

        # Enable podman socket via OpenRC
        # Alpine doesn't ship podman.socket; we use a small OpenRC service that runs `podman system service`
        cat > /etc/init.d/podman-socket <<'EOF'
        #!/sbin/openrc-run
        name="podman socket"
        description="Podman REST API socket"
        command=/usr/bin/podman
        command_args="system service --time=0 unix:///run/podman/podman.sock"
        command_background=true
        pidfile="/run/podman-socket.pid"
        depend() {
            need net
        }
        start_pre() {
            mkdir -p /run/podman
        }
        EOF
        chmod +x /etc/init.d/podman-socket

        rc-update add podman-socket default
        service podman-socket start

        podman version
        """;

    private string ContainersConfContent() => """
        [containers]
        log_driver = "k8s-file"
        log_size_max = 10485760

        [engine]
        events_logger = "file"
        runtime = "crun"

        [network]
        default_network = "podman"
        network_backend = "netavark"
        """;

    private string RegistriesConfContent() => """
        unqualified-search-registries = ["docker.io", "quay.io", "ghcr.io"]

        [[registry]]
        location = "docker.io"
        prefix = "docker.io"

        [[registry.mirror]]
        location = "registry.frenchexdev.lab"
        insecure = true
        """;

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

        # Set up subuid/subgid for the alpine user (rootless mode)
        usermod --add-subuids 100000-165535 --add-subgids 100000-165535 alpine || \
            (echo 'alpine:100000:65536' >> /etc/subuid && echo 'alpine:100000:65536' >> /etc/subgid)

        # Verify the rootless setup as the alpine user
        su - alpine -c 'podman info' || true
        """;
}

The script wires up the OpenRC service that runs podman system service --time=0 unix:///run/podman/podman.sock. That socket is the equivalent of /var/run/docker.sock for HomeLab's purposes — Traefik's docker-compatibility provider can talk to it, the IDockerClient wrapper from Part 15 does not (it talks to the real Docker CLI), and the Podman wrapper from Part 17 talks to it via the --remote flag if needed.


Wiring the engine selection

Both contributors are registered. Only one runs per build, based on config.engine:

[Injectable(ServiceLifetime.Singleton)]
public sealed class HostOverlayPicker : IPackerBundleContributor
{
    private readonly HomeLabConfig _config;
    private readonly DockerHostContributor _docker;
    private readonly PodmanHostContributor _podman;

    public int Order => 20;

    public void Contribute(PackerBundle bundle)
    {
        switch (_config.Engine)
        {
            case "docker": _docker.Contribute(bundle); break;
            case "podman": _podman.Contribute(bundle); break;
            default: throw new InvalidOperationException($"unknown engine: {_config.Engine}");
        }
    }
}

Or, more idiomatically, both contributors check ShouldContribute() and the pipeline runs them all in order. The picker is a small optimisation that makes the intent visible.


The test

public sealed class PodmanHostContributorTests
{
    [Fact]
    public void contributor_does_nothing_when_engine_is_docker()
    {
        var bundle = new PackerBundle();
        var c = new PodmanHostContributor(Options.Create(new HomeLabConfig
        {
            Engine = "docker", Name = "x", Topology = "single", Packer = new() { Version = "3.21" }
        }));
        c.Contribute(bundle);

        bundle.Scripts.Should().NotContain(s => s.FileName.Contains("podman"));
    }

    [Fact]
    public void contributor_adds_install_script_when_engine_is_podman()
    {
        var bundle = new PackerBundle();
        var c = new PodmanHostContributor(Options.Create(new HomeLabConfig
        {
            Engine = "podman", Name = "x", Topology = "single", Packer = new() { Version = "3.21" }
        }));
        c.Contribute(bundle);

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

    [Fact]
    public void contributor_adds_rootless_setup_script()
    {
        var bundle = new PackerBundle();
        var c = new PodmanHostContributor(Options.Create(PodmanConfig()));
        c.Contribute(bundle);

        bundle.Scripts.Should().Contain(s => s.FileName == "setup-rootless.sh");
        bundle.Scripts.First(s => s.FileName == "setup-rootless.sh").Content.Should().Contain("subuid");
    }

    [Fact]
    public void contributor_adds_openrc_socket_service()
    {
        var bundle = new PackerBundle();
        var c = new PodmanHostContributor(Options.Create(PodmanConfig()));
        c.Contribute(bundle);

        var install = bundle.Scripts.First(s => s.FileName == "install-podman.sh").Content;
        install.Should().Contain("/etc/init.d/podman-socket");
        install.Should().Contain("podman system service");
    }

    private static HomeLabConfig PodmanConfig() => new()
    {
        Engine = "podman", Name = "x", Topology = "single",
        Packer = new() { Distro = "alpine", Version = "3.21" }
    };
}

What this gives you that bash doesn't

A bash script that adds Podman to an Alpine VM is the same shape as the Docker script, but with subtly different commands. Every team that has tried to support both has ended up with two parallel scripts that drift, or one script with if [[ "$ENGINE" == "podman" ]]; then ... fi blocks that grow new edge cases every quarter.

A typed PodmanHostContributor next to a typed DockerHostContributor gives you, for the same surface area:

  • Two parallel contributors with the same five concerns and the same shape
  • A picker that selects one at runtime based on config
  • Tests that exercise both contributors against the same expectations
  • Architecture tests that force any new container engine to follow the same pattern (an IPackerBundleContributor with engine-aware behaviour)

The bargain pays back the first time you switch a single VM from Docker to Podman, rebuild the box, and watch DevLab come up identically with one fewer daemon and one fewer privileged user.


⬇ Download