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:
- Package (
podmaninstead ofdocker) - Daemonless socket activation (
podman.socketvia systemd-user, or an OpenRC equivalent on Alpine) - Configuration file (
containers.confandregistries.confinstead ofdaemon.json) - Rootless setup (subuid / subgid mappings, slirp4netns, fuse-overlayfs)
- No
addgroup user dockerequivalent — 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
""";
}[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}");
}
}
}[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" }
};
}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
IPackerBundleContributorwith 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.