Part 16: Talking to Docker Compose v1 vs v2
"
docker-composeanddocker composeare 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 upvsdocker compose up - Exit codes for partial failure: v1 sometimes exits 0 even when one service fails to start; v2 is stricter
depends_onsemantics: v1 ignorescondition: service_healthy; v2 honours it--compatibilityflag: only meaningful in v2- Profile support: only in v2
- Performance: v2 is dramatically faster (Go vs Python startup)
docker-compose.yamldiscovery: 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 }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
}[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()
};
});[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);
}
}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 (
profilestriggers 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 composeinvocations 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.