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 17: Talking to Podman — Rootless, Daemonless, Different

"Podman is a Docker without the daemon. Until you discover the things the daemon was actually doing for you."


Why

Podman is the second container engine HomeLab supports as a first-class backend. Some teams prefer it for two reasons:

  1. No daemon. Podman runs each container as a child of the calling process. There is no dockerd to start, no socket to secure, no privileged service. The "daemon" attack surface goes away.
  2. Rootless by default. Containers run as the calling user, with user namespaces. There is no docker group whose members are effectively root.

In return, you give up:

  • The Docker socket, which a thousand tools rely on (Traefik's docker provider, Watchtower, dive, ctop, lazydocker, …)
  • docker compose as a first-party plugin (Podman has podman-compose, which is a separate Python project with subtle differences — see Part 18)
  • Some rootless edge cases around port binding < 1024, networking primitives (macvlan, host), bind mounts to system paths, and overlay storage on certain filesystems
  • Healthcheck semantics that differ subtly between Docker and Podman in older Podman versions

The thesis of this part is: Podman gets a wrapper that is structurally identical to DockerClient, plus an IContainerEngine abstraction that lets HomeLab pick the engine at runtime via a single config field. The wrapper is symmetric. The differences are isolated to the contracts the abstraction enforces.


The shape

[BinaryWrapper("podman", HelpCommand = "--help", VersionCommand = "version --format json")]
public partial class PodmanClient : IPodmanClient
{
    [Command("ps")]
    public partial Task<Result<PodmanPsOutput>> PsAsync(
        [Flag("--all", Aliases = "-a")] bool all = false,
        [Flag("--filter")] IReadOnlyList<string>? filter = null,
        [Flag("--format")] string? format = null,
        CancellationToken ct = default);

    [Command("run")]
    public partial Task<Result<PodmanRunOutput>> RunAsync(
        [PositionalArgument(Position = 0)] string image,
        [PositionalArgument(Position = 1, IsList = true)] IReadOnlyList<string>? command = null,
        [Flag("--name")] string? name = null,
        [Flag("-d", IsBoolean = true)] bool detach = false,
        [Flag("--rm")] bool remove = false,
        [Flag("-e", IsKeyValue = true)] IReadOnlyDictionary<string, string>? env = null,
        [Flag("-v", IsList = true)] IReadOnlyList<string>? volume = null,
        [Flag("-p", IsList = true)] IReadOnlyList<string>? port = null,
        [Flag("--network")] string? network = null,
        [Flag("--restart")] string? restart = null,
        [Flag("--userns")] string? userns = null,           // ← podman-specific (rootless mapping)
        CancellationToken ct = default);

    [Command("system", SubCommand = "service")]
    public partial Task<Result<PodmanSystemServiceOutput>> SystemServiceAsync(
        [Flag("--time")] int? timeoutSeconds = 0,
        [PositionalArgument] string? socketUri = null,
        CancellationToken ct = default);

    [Command("generate", SubCommand = "systemd")]
    public partial Task<Result<PodmanGenerateSystemdOutput>> GenerateSystemdAsync(
        [PositionalArgument] string nameOrId,
        [Flag("--name")] bool useName = true,
        [Flag("--files")] bool toFiles = false,
        CancellationToken ct = default);

    // ... etc, ~80 commands
}

The shape is deliberately parallel to DockerClient. The same [BinaryWrapper] source generator emits both. The Podman-specific commands (system service to expose a socket, generate systemd to make systemd units, play kube to consume Kubernetes manifests) get their own methods. Everything else mirrors the Docker surface.


The IContainerEngine abstraction

The point of having both is letting HomeLab pick at runtime:

public interface IContainerEngine
{
    string Name { get; }
    bool IsRootless { get; }
    bool HasDaemon { get; }
    string SocketPath { get; }

    Task<Result> RunComposeAsync(string composeFile, string projectName, CancellationToken ct);
    Task<Result> StopComposeAsync(string projectName, CancellationToken ct);
    Task<Result<EngineHealthReport>> HealthCheckAsync(CancellationToken ct);
    Task<Result<string>> InspectAsync(string nameOrId, CancellationToken ct);
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class DockerContainerEngine : IContainerEngine
{
    public string Name => "docker";
    public bool IsRootless => false;
    public bool HasDaemon => true;
    public string SocketPath => "/var/run/docker.sock";

    private readonly IDockerComposeClient _compose;
    private readonly IDockerClient _docker;
    public DockerContainerEngine(IDockerComposeClient compose, IDockerClient docker) { _compose = compose; _docker = docker; }

    public Task<Result> RunComposeAsync(string composeFile, string projectName, CancellationToken ct)
        => _compose.UpAsync(composeFile, projectName, detach: true, profiles: null, timeout: TimeSpan.FromMinutes(10), ct).Map();
    // ... etc
}

[Injectable(ServiceLifetime.Singleton)]
public sealed class PodmanContainerEngine : IContainerEngine
{
    public string Name => "podman";
    public bool IsRootless => true;
    public bool HasDaemon => false;
    public string SocketPath => $"/run/user/{UidResolver.CurrentUid()}/podman/podman.sock";

    private readonly IPodmanComposeClient _compose;
    private readonly IPodmanClient _podman;
    public PodmanContainerEngine(IPodmanComposeClient compose, IPodmanClient podman) { _compose = compose; _podman = podman; }

    public Task<Result> RunComposeAsync(string composeFile, string projectName, CancellationToken ct)
        => _compose.UpAsync(composeFile, projectName, ct).Map();
    // ... etc
}

Both engines are [Injectable], both implement IContainerEngine, both are registered. The composition root selects one based on config.engine:

services.AddSingleton<IContainerEngine>(sp =>
{
    var config = sp.GetRequiredService<IOptions<HomeLabConfig>>().Value;
    return config.Engine switch
    {
        "docker" => sp.GetRequiredService<DockerContainerEngine>(),
        "podman" => sp.GetRequiredService<PodmanContainerEngine>(),
        _ => throw new InvalidOperationException($"Unknown engine: {config.Engine}")
    };
});

The pipeline's Apply stage talks to IContainerEngine, not to DockerClient or PodmanClient directly. Switching engines is one config field; the rest of HomeLab does not change.


The parity matrix

Feature Docker Podman HomeLab handles
docker runpodman run identical
docker compose uppodman-compose up ⚠️ separate binary wrapper level
Rootless containers ⚠️ rootless mode ✓ default engine flag
systemd units manual podman generate systemd engine method
--privileged identical
Bind mount to system path ⚠️ root only engine flag
Port < 1024 ⚠️ root only engine flag
Network: host ✓ rootless quirks tested per topology
Network: macvlan ⚠️ root only engine flag
Network: bridge identical
Healthcheck condition: service_healthy ⚠️ podman-compose ≥ 1.0.6 wrapper warns
--gpus all (NVIDIA) ✓ via CDI engine path
Image pull from registry with auth identical
Build images ✓ via buildah engine method

The matrix is encoded as [Trait]s on integration tests, so CI can run the same test against both engines and report which features work where.


The test

public sealed class IContainerEngineParityTests
{
    public static IEnumerable<object[]> Engines() => new[]
    {
        new object[] { typeof(DockerContainerEngine) },
        new object[] { typeof(PodmanContainerEngine) }
    };

    [Theory, MemberData(nameof(Engines))]
    [Trait("category", "integration")]
    public async Task engine_can_run_a_simple_alpine_container(Type engineType)
    {
        var sp = TestServices.WithEngine(engineType);
        var engine = (IContainerEngine)sp.GetRequiredService(engineType);

        var result = await engine.RunComposeAsync(
            composeFile: "fixtures/alpine-echo.yaml",
            projectName: "parity-test",
            ct: CancellationToken.None);

        result.IsSuccess.Should().BeTrue($"engine {engine.Name} must run a basic container");
    }

    [Theory, MemberData(nameof(Engines))]
    public async Task engine_reports_its_capabilities_truthfully(Type engineType)
    {
        var engine = TestServices.WithEngine(engineType).GetRequiredService(engineType) as IContainerEngine;

        if (engine!.Name == "podman")
        {
            engine.IsRootless.Should().BeTrue();
            engine.HasDaemon.Should().BeFalse();
        }
        else
        {
            engine.IsRootless.Should().BeFalse();
            engine.HasDaemon.Should().BeTrue();
        }
    }
}

The Theory + MemberData pattern lets us run the same assertions against both engines, in CI, and produce a clear pass/fail matrix showing which features work where. When a future Podman release adds support for rootless port < 1024, one test moves from Skip to Pass, and the matrix updates without code changes.


What this gives you that bash doesn't

A bash script that supports both Docker and Podman either (a) hard-codes one and ignores the other, (b) ships two parallel scripts that drift, or (c) has a 50-line if podman; then ... else ... fi block at the top that grows new edge cases every quarter.

A typed IContainerEngine abstraction with two backing wrappers gives you, for the same surface area:

  • One config field (engine: docker | podman) to switch
  • Two parallel wrappers generated from the same [BinaryWrapper] pattern
  • A shared interface that the rest of HomeLab targets
  • A capability matrix encoded in test traits
  • Failure clarity when a specific feature doesn't work on a specific engine (the Result.Failure is explicit, not silent)

The bargain pays back the first time a security-conscious team says "we don't allow privileged daemons" — you switch one config field and the lab is rootless, daemonless, and still working.


⬇ Download