A script that worked on three developer machines and failed in CI turned out to be running two different programs with almost the same name. Compose v2 is a rewrite in Go shipped as a Docker CLI plugin, and for most of 2021 both it and the original Python implementation are installed side by side on every machine.
The symptom
$ ./bin/reset-db
Error response from daemon: No such container: blog_mysql_1
$ docker-compose version
docker-compose version 1.29.2, build 5becea4c
$ docker compose version
Docker Compose version v2.0.0-rc.3
$ docker ps --format '{{.Names}}'
blog-mysql-1 # v2 spelling
blog_php_1 # started earlier, by v1Two containers from the same compose file with two different naming conventions, because two people had brought the stack up with two different binaries. Every script that constructed a container name by hand was broken for one of them.
Why it happens
v1 is a Python program installed separately from Docker; v2 is a Go binary shipped as docker-compose-plugin and invoked as a subcommand. They read the same file and are different implementations with different defaults.
Nothing on a machine tells you which one a script will get, because docker-compose may be a real binary, a shell alias to the plugin, or a shim installed by Docker Desktop that forwards to it. All three exist in the wild in 2021.
The fix
The differences that break things
container naming blog_php_1 → blog-php-1
because underscores are not valid in a
DNS label, and v2 uses container names
for service discovery consistently
exit codes v2 propagates the container exit code
from `up` more faithfully. a CI job
that passed silently now fails.
env_file paths resolved relative to the compose file
in both, but v1 had cases where it
used the working directory
dependency order v2 respects depends_on conditions more
strictly, which surfaces missing onesThe exit code change is the one that is genuinely good and initially looks like a regression: a CI job that had been reporting success while a container failed now reports the failure. Two of ours had been doing that for over a year.
Not constructing container names
# broken by the rename, and by scaling, and by a project
# directory being renamed
docker exec blog_php_1 php artisan migrate
# works on both, and on a scaled service
docker compose exec -T php php artisan migrate
# and when a raw id is genuinely needed
cid=$(docker compose ps -q php | head -1)
docker inspect "" --format '{{.State.Health.Status}}'
Every script constructing a name had a latent coupling to an implementation detail, and the rename simply exposed it. compose exec and ps -q have always been the supported way to reach a service and they survive the rename, a directory rename and scaling to more than one replica.
The -T flag matters in any non-interactive context: without it Compose allocates a TTY and a script running under CI or cron gets a confusing error about the input device. It is the single most common cause of a compose command that works locally and not in a pipeline.
Pinning the version so nobody has a choice
#!/usr/bin/env bash
# bin/compose — the only way the project invokes compose
set -euo pipefail
if ! docker compose version >/dev/null 2>&1; then
echo 'Compose v2 is required. Install docker-compose-plugin.' >&2
exit 1
fi
required='2.0'
actual=$(docker compose version --short)
if [ "$(printf '%sn' "" "" | sort -V | head -1)" != "" ]; then
echo "Compose + required, found " >&2
exit 1
fi
exec docker compose "$@"
A wrapper script is a blunt instrument and it is the only thing that reliably stops the mixed-version problem, because a documented requirement in a README is a requirement half the team will not read. Everything in the repository — Makefile targets, CI steps, developer scripts — goes through it.
Failing with an instruction rather than an error is the part that makes it acceptable to work with. The version comparison with sort -V is the least awkward pure-shell way to do this and is worth copying rather than rewriting.
What v2 makes possible
services:
php: {}
mysql: {}
mailpit:
profiles: [dev]
elasticsearch:
profiles: [search]
# COMPOSE_PROFILES=dev docker compose up
# → php, mysql, mailpit. not elasticsearch.
# and depends_on with a condition, which v1 supported
# only in the deprecated version 2 file format:
php:
depends_on:
mysql: { condition: service_healthy }
Profiles are the feature that changed the file most: nine services of which three are needed on a normal day, and up starting all nine was a minute of waiting and two gigabytes of memory for nothing. A service with no profile always starts, which makes the default minimal and the extras opt-in.
The health condition on depends_on had existed in the version 2 file format, was dropped in version 3, and returns properly in the compose specification. Three years of people writing wait-for scripts were the consequence of that gap.
Verifying it worked
$ ./bin/compose version --short
2.0.1
$ grep -rn 'docker-compose ' bin/ .github/ Makefile | wc -l
0
$ grep -rEn '(blog|app)_[a-z]+_[0-9]' bin/ .github/ | wc -l
0
$ ./bin/compose --profile dev up -d && ./bin/compose ps
NAME SERVICE STATUS
blog-mysql-1 mysql running (healthy)
blog-php-1 php running
blog-mailpit-1 mailpit runningGrepping for the old container-name pattern is the check that finds the remaining scripts, and it caught two that nobody had run in months. Both were in a deployment path that would have failed at the worst moment.
What this costs
Documentation everywhere that says the old thing — the README, the onboarding page, three runbooks and every blog post the team has ever bookmarked. Fixing the repository is an afternoon and the mental habit takes a year, which is why the wrapper script matters more than the search-and-replace.
v2 is also still a release candidate in mid-2021, and pinning to a minimum version means occasionally hitting a bug that v1 did not have. Two of those appeared during the transition and both were fixed within a fortnight, which is the right expectation to set rather than promising a drop-in replacement.