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 18: Talking to Podman Compose — The Diff That Matters

"podman-compose reads docker-compose.yaml. It does not run it the same way."


Why

Part 16 handled the Docker side: v1 vs v2, both reading the Compose spec. The Podman story is different: there are two options for "compose with Podman", and HomeLab supports both.

  1. podman-compose — a separate Python project, not maintained by Podman/RedHat. Reads docker-compose.yaml. Implements a subset of the Compose spec. Has known divergences in network model, secrets, healthchecks, and the version: field.
  2. podman compose (with a space) — a Podman built-in subcommand that delegates to either docker-compose or podman-compose depending on what is installed. Mostly a convenience.

The thesis of this part is: HomeLab targets podman-compose directly, with a wrapper that knows about the Compose-spec divergences and surfaces them as explicit failures or warnings. We do not pretend Docker Compose v2 and podman-compose are equivalent. They are not. The wrapper makes the divergence visible.


The shape

[BinaryWrapper("podman-compose", HelpCommand = "--help")]
public partial class PodmanComposeClient : IPodmanComposeClient
{
    [Command("up")]
    public partial Task<Result<PodmanComposeUpOutput>> UpAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        [Flag("-p")] string projectName,
        [Flag("-d", IsBoolean = true)] bool detach = true,
        // NB: --profile is supported by podman-compose ≥ 1.0.6
        [Flag("--profile", IsList = true)] IReadOnlyList<string>? profiles = null,
        CancellationToken ct = default);

    [Command("down")]
    public partial Task<Result<PodmanComposeDownOutput>> DownAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        [Flag("-p")] string projectName,
        [Flag("-v", IsBoolean = true)] bool removeVolumes = false,
        CancellationToken ct = default);

    [Command("ps")]
    public partial Task<Result<PodmanComposePsOutput>> PsAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        [Flag("-p")] string projectName,
        CancellationToken ct = default);

    [Command("config")]
    public partial Task<Result<PodmanComposeConfigOutput>> ConfigAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        CancellationToken ct = default);

    [Command("logs")]
    public partial Task<Result<PodmanComposeLogsOutput>> LogsAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        [Flag("-p")] string projectName,
        CancellationToken ct = default);
}

The methods that exist in DockerComposeV2Client and not in podman-compose (e.g. pause, unpause, top, wait) return Result.Failure from the IDockerComposeClient bridge methods we define on the engine adapter.


The divergence map

A non-exhaustive list of podman-compose quirks HomeLab handles:

1. The version: field is not interpreted

Docker Compose v2 ignores version: (it has been deprecated since 2020). podman-compose also ignores it but emits a warning. HomeLab strips version: from the generated compose file when the engine is podman, to keep the output clean.

2. condition: service_healthy works only in podman-compose ≥ 1.0.6

Earlier versions silently treat condition: service_healthy as condition: service_started, which means dependent services start immediately instead of waiting for the dependency to be healthy. This is the single most painful divergence in practice. HomeLab detects the version at startup and refuses to use service_healthy if the installed podman-compose is too old:

[Injectable(ServiceLifetime.Singleton)]
public sealed class PodmanComposeVersionGuard
{
    private readonly PodmanComposeClient _compose;
    private readonly Version _minimum = new(1, 0, 6);

    public async Task<Result> CheckAsync(CancellationToken ct)
    {
        var versionResult = await _compose.RawAsync(new[] { "--version" }, ct);
        if (versionResult.IsFailure) return versionResult.Map();

        var match = Regex.Match(versionResult.Value, @"podman-compose version (\d+\.\d+\.\d+)");
        if (!match.Success) return Result.Failure("could not parse podman-compose version");

        var version = Version.Parse(match.Groups[1].Value);
        if (version < _minimum)
            return Result.Failure(
                $"podman-compose {version} is too old; HomeLab requires ≥ {_minimum} for `condition: service_healthy`");

        return Result.Success();
    }
}

The guard runs in the Validate stage. The build fails fast.

3. secrets: are mapped to bind mounts, not Docker secrets

Docker has a first-class secrets primitive in Compose. podman-compose translates secrets: into bind mounts under the project's working directory. HomeLab knows this and adjusts: when the engine is podman, the compose contributor pattern (see Part 32) emits files to a known relative path and lets podman-compose mount them.

4. networks: driver options differ

Docker Compose accepts driver-specific options (driver_opts:) under each network. podman-compose ignores most of them silently. HomeLab strips driver_opts: from podman output and logs a warning if they were specified.

5. Pod vs network isolation

Podman puts all services in a pod by default (which means they share a network namespace and can talk to each other on localhost). Docker Compose creates a Docker network and gives each service a name resolvable inside it. The semantics overlap for most cases but differ at the edges: a service that binds to 127.0.0.1:8080 in Docker is unreachable from siblings; the same service in Podman is reachable from siblings via localhost:8080. HomeLab's compose contributors avoid binding to 127.0.0.1 for inter-service ports — they use 0.0.0.0 (or omit the bind address entirely), which works in both engines.

6. Healthcheck test: syntax

Docker accepts test: ["CMD", "curl", "-f", "http://localhost"] and test: ["CMD-SHELL", "..."]. podman-compose requires the CMD form for arrays and accepts shell strings without the prefix. HomeLab normalises both into the form podman-compose accepts when the engine is podman.


The wiring

PodmanComposeClient is [Injectable]. PodmanContainerEngine (from Part 17) consumes it. The engine selection happens at the composition root. Consumers see IContainerEngine only.

The IPodmanComposeClient interface has bridge methods to satisfy IDockerComposeClient, but they explicitly return Result.Failure for operations Podman Compose does not support:

[Injectable(ServiceLifetime.Singleton)]
public sealed class PodmanComposeAsDockerCompose : IDockerComposeClient
{
    private readonly PodmanComposeClient _inner;
    private readonly ILogger<PodmanComposeAsDockerCompose> _log;

    public ComposeVersion Version => ComposeVersion.V2; // closest equivalent

    public async Task<Result<ComposeUpOutput>> UpAsync(string composeFile, string projectName, bool detach, IReadOnlyList<string>? profiles, TimeSpan? timeout, CancellationToken ct)
    {
        if (timeout is not null)
            _log.LogWarning("podman-compose does not support --timeout; ignoring");

        var result = await _inner.UpAsync(new[] { composeFile }, projectName, detach, profiles, ct);
        return result.Map(o => new ComposeUpOutput(o.ContainersStarted));
    }

    public Task<Result<ComposePauseOutput>> PauseAsync(string composeFile, string projectName, CancellationToken ct)
        => Task.FromResult(Result.Failure<ComposePauseOutput>(
            "podman-compose does not support `pause` — use `podman pod pause` directly if you need this"));

    // ... etc
}

Every divergence becomes either a silent normalisation (with a logged warning) or an explicit Result.Failure. Nothing is hidden.


The test

public sealed class PodmanComposeDivergenceTests
{
    [Fact]
    public async Task version_guard_fails_for_old_podman_compose()
    {
        var compose = new ScriptedPodmanComposeClient();
        compose.OnRaw(new[] { "--version" }, exitCode: 0, stdout: "podman-compose version 1.0.3");
        var guard = new PodmanComposeVersionGuard(compose);

        var result = await guard.CheckAsync(CancellationToken.None);

        result.IsFailure.Should().BeTrue();
        result.Errors.Should().Contain(e => e.Contains("1.0.3") && e.Contains("1.0.6"));
    }

    [Fact]
    public async Task version_guard_passes_for_new_podman_compose()
    {
        var compose = new ScriptedPodmanComposeClient();
        compose.OnRaw(new[] { "--version" }, exitCode: 0, stdout: "podman-compose version 1.2.0");
        var guard = new PodmanComposeVersionGuard(compose);

        var result = await guard.CheckAsync(CancellationToken.None);

        result.IsSuccess.Should().BeTrue();
    }

    [Fact]
    public async Task pause_returns_failure_with_clear_message()
    {
        var bridge = new PodmanComposeAsDockerCompose(new ScriptedPodmanComposeClient(), Mock.Of<ILogger<PodmanComposeAsDockerCompose>>());

        var result = await bridge.PauseAsync("compose.yaml", "test", CancellationToken.None);

        result.IsFailure.Should().BeTrue();
        result.Errors.Should().Contain(e => e.Contains("does not support `pause`"));
    }

    [Fact]
    public async Task version_field_stripped_from_emitted_compose_when_engine_is_podman()
    {
        var compose = new ComposeFile { Version = "3.8" };
        var contributor = new GitLabComposeContributor();
        contributor.Contribute(compose);

        var serializer = new ComposeSerializer(engine: "podman");
        var yaml = serializer.Serialize(compose);

        yaml.Should().NotContain("version:");
        yaml.Should().Contain("services:");
    }
}

What this gives you that bash doesn't

A bash script that targets both Docker Compose and podman-compose is a minefield. The author tests it on Docker, ships it, and the first Podman user discovers condition: service_healthy is silently dropped on their version. The fix is a wiki page that nobody reads.

A typed podman-compose wrapper with explicit divergence handling gives you, for the same surface area:

  • A version guard that fails the build on incompatible versions
  • Silent normalisations (like version: stripping) with logged warnings
  • Explicit failures for operations Podman Compose does not support
  • Architecture tests that prevent direct podman-compose invocation outside the wrapper
  • Parity testing via the same IContainerEngine Theory tests we saw in Part 17

The bargain pays back the first time a divergence costs someone an evening of debugging — once. Forever after, the wrapper catches it.


⬇ Download