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 16: Talking to Docker Compose v1 vs v2

"docker-compose and docker compose are not the same program. They share a YAML schema and nothing else."


Why

Docker Compose has had two implementations in recent memory. Compose v1 (docker-compose, with a hyphen) was a Python script invoked as a separate binary. Compose v2 (docker compose, with a space) is a Go plugin to the Docker CLI. Both read the same YAML schema. Both produce the same observable outcome for simple cases. They differ in:

  • Invocation: docker-compose up vs docker compose up
  • Exit codes for partial failure: v1 sometimes exits 0 even when one service fails to start; v2 is stricter
  • depends_on semantics: v1 ignores condition: service_healthy; v2 honours it
  • --compatibility flag: only meaningful in v2
  • Profile support: only in v2
  • Performance: v2 is dramatically faster (Go vs Python startup)
  • docker-compose.yaml discovery: subtly different rules for which file is picked up

HomeLab targets v2 by default (it is the only one Docker Inc. now supports), but a non-trivial number of homelab machines still have v1 installed and aliased. The wrapper has to handle both gracefully: detect which is available, prefer v2, warn loudly if v1 is in use.


The shape

public interface IDockerComposeClient
{
    ComposeVersion Version { get; }

    Task<Result<ComposeUpOutput>> UpAsync(
        string composeFile,
        string projectName,
        bool detach = true,
        IReadOnlyList<string>? profiles = null,
        TimeSpan? timeout = null,
        CancellationToken ct = default);

    Task<Result<ComposeDownOutput>> DownAsync(
        string composeFile,
        string projectName,
        bool removeVolumes = false,
        bool removeOrphans = false,
        CancellationToken ct = default);

    Task<Result<ComposePsOutput>> PsAsync(
        string composeFile,
        string projectName,
        CancellationToken ct = default);

    Task<Result<ComposeLogsOutput>> LogsAsync(
        string composeFile,
        string projectName,
        IReadOnlyList<string>? services = null,
        bool follow = false,
        CancellationToken ct = default);

    Task<Result<ComposeConfigOutput>> ConfigAsync(
        string composeFile,
        string projectName,
        bool resolveImageDigests = false,
        CancellationToken ct = default);
}

public enum ComposeVersion { V1, V2 }

The interface is the same for both versions. The implementation is two classes — DockerComposeV2Client (the default) and DockerComposeV1Client (the legacy fallback) — selected at startup by IComposeVersionResolver.

[BinaryWrapper("docker", HelpCommand = "compose --help")]
public partial class DockerComposeV2Client : IDockerComposeClient
{
    public ComposeVersion Version => ComposeVersion.V2;

    [Command("compose", SubCommand = "up")]
    public partial Task<Result<ComposeUpOutput>> UpAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        [Flag("-p")] string projectName,
        [Flag("-d", IsBoolean = true)] bool detach = true,
        [Flag("--profile", IsList = true)] IReadOnlyList<string>? profiles = null,
        [Flag("--timeout")] int? timeoutSeconds = null,
        CancellationToken ct = default);

    // bridge method that satisfies IDockerComposeClient signature
    Task<Result<ComposeUpOutput>> IDockerComposeClient.UpAsync(string composeFile, string projectName, bool detach, IReadOnlyList<string>? profiles, TimeSpan? timeout, CancellationToken ct)
        => UpAsync(new[] { composeFile }, projectName, detach, profiles, (int?)timeout?.TotalSeconds, ct);

    // ... etc
}

[BinaryWrapper("docker-compose", HelpCommand = "--help")]
public partial class DockerComposeV1Client : IDockerComposeClient
{
    public ComposeVersion Version => ComposeVersion.V1;

    [Command("up")]
    public partial Task<Result<ComposeUpOutput>> UpAsync(
        [Flag("-f", IsList = true)] IReadOnlyList<string> files,
        [Flag("-p")] string projectName,
        [Flag("-d", IsBoolean = true)] bool detach = true,
        // NB: no --profile in v1
        [Flag("--timeout")] int? timeoutSeconds = null,
        CancellationToken ct = default);

    Task<Result<ComposeUpOutput>> IDockerComposeClient.UpAsync(string composeFile, string projectName, bool detach, IReadOnlyList<string>? profiles, TimeSpan? timeout, CancellationToken ct)
    {
        if (profiles is { Count: > 0 })
            return Task.FromResult(Result.Failure<ComposeUpOutput>(
                "Compose v1 does not support --profile; upgrade to Compose v2"));
        return UpAsync(new[] { composeFile }, projectName, detach, (int?)timeout?.TotalSeconds, ct);
    }

    // ... etc
}

The two wrappers are parallel. The shared interface is the contract. The differences in invocation are isolated to the partial methods. The differences in feature set (profiles, healthcheck-aware depends_on, etc.) are surfaced as Result.Failure from the v1 implementation.


The wiring

[Injectable(ServiceLifetime.Singleton)]
public sealed class ComposeVersionResolver : IComposeVersionResolver
{
    private readonly DockerClient _docker;
    private readonly IBinaryDiscovery _discovery;

    public async Task<ComposeVersion> ResolveAsync(CancellationToken ct)
    {
        // Try v2 first: `docker compose version`
        var v2Result = await _docker.RunRawAsync(new[] { "compose", "version", "--short" }, ct);
        if (v2Result.IsSuccess) return ComposeVersion.V2;

        // Fall back to v1
        var v1Path = await _discovery.FindAsync("docker-compose", ct);
        if (v1Path.IsSuccess) return ComposeVersion.V1;

        throw new InvalidOperationException("Neither `docker compose` (v2) nor `docker-compose` (v1) found.");
    }
}

// Composition root chooses the implementation:
services.AddSingleton<IDockerComposeClient>(sp =>
{
    var version = sp.GetRequiredService<IComposeVersionResolver>().ResolveAsync(CancellationToken.None).GetAwaiter().GetResult();
    var log = sp.GetRequiredService<ILogger<HomeLabPipeline>>();

    if (version == ComposeVersion.V1)
        log.LogWarning("Using Docker Compose v1. v1 is deprecated; install v2 (`docker compose`) for full feature support.");

    return version switch
    {
        ComposeVersion.V2 => sp.GetRequiredService<DockerComposeV2Client>(),
        ComposeVersion.V1 => sp.GetRequiredService<DockerComposeV1Client>(),
        _ => throw new InvalidOperationException()
    };
});

Both implementations are [Injectable(ServiceLifetime.Singleton)]. The composition root picks one at startup, logs a warning if v1 is selected, and exposes it as IDockerComposeClient. Consumers see the interface only.


The test

public sealed class DockerComposeClientTests
{
    [Fact]
    public async Task v2_up_with_profiles_emits_profile_args_correctly()
    {
        var runner = new RecordingBinaryRunner();
        var client = new DockerComposeV2Client(runner);

        await ((IDockerComposeClient)client).UpAsync(
            composeFile: "compose.yaml",
            projectName: "devlab",
            profiles: new[] { "obs", "ci" },
            timeout: TimeSpan.FromSeconds(60));

        runner.LastArgs.Should().Equal(
            "compose", "up", "-f", "compose.yaml", "-p", "devlab", "-d",
            "--profile", "obs", "--profile", "ci", "--timeout", "60");
    }

    [Fact]
    public async Task v1_up_with_profiles_returns_failure()
    {
        var runner = new RecordingBinaryRunner();
        var client = new DockerComposeV1Client(runner);

        var result = await ((IDockerComposeClient)client).UpAsync(
            composeFile: "compose.yaml",
            projectName: "devlab",
            profiles: new[] { "obs" });

        result.IsFailure.Should().BeTrue();
        result.Errors.Should().Contain(e => e.Contains("v1 does not support --profile"));
        runner.LastCommand.Should().BeNull("the wrapper must not invoke docker-compose for an unsupported feature");
    }

    [Fact]
    public async Task version_resolver_prefers_v2_when_both_are_available()
    {
        var docker = new ScriptedDockerClient();
        docker.OnRunRaw(new[] { "compose", "version", "--short" }, exitCode: 0, stdout: "v2.27.0");

        var discovery = new ScriptedBinaryDiscovery();
        discovery.OnFind("docker-compose", path: "/usr/local/bin/docker-compose");

        var resolver = new ComposeVersionResolver(docker, discovery);
        var version = await resolver.ResolveAsync(CancellationToken.None);

        version.Should().Be(ComposeVersion.V2);
    }
}

What this gives you that bash doesn't

A bash script that uses Compose ships with a set -e and a hope. Either the script hard-codes docker-compose and breaks on machines that only have v2, or it hard-codes docker compose and breaks on machines that only have v1. The fix is a if command -v docker-compose; then ... block at the top, and the script grows a wrapper function, and you have just reinvented the binary wrapper, badly.

A typed two-implementation Compose wrapper gives you, for the same surface area:

  • Automatic version detection at startup
  • A loud warning if the legacy v1 is in use
  • A typed feature gate (profiles triggers a clear failure on v1) instead of a silent compose-spec drift
  • Same interface for both versions so consumer code does not branch
  • Architecture tests that prevent direct docker compose invocations from anywhere except the wrapper

The bargain pays back the first time a developer who installed Docker Desktop in 2024 hands their machine to a colleague who installed it in 2018 — both run HomeLab successfully, with one warning in the v1 colleague's logs.


⬇ Download