The Docker whale dressed as a nightclub bouncer, holding up a stop hand to a small wooden container that holds a 1.32 badge, beside a velvet rope and an API 1.40+ sign

We moved our Quarkus CI off GitHub-hosted runners and onto our own Actions Runner Controller (ARC) setup on Talos, with a Docker-in-Docker sidecar for the build jobs. Sensible move: own the runners, lose the minute limits. The container-build jobs were happy. Every test job died on the same line:

Could not find a valid Docker environment...
Status 400: client version 1.32 is too old. Minimum supported API version is 1.40
→ DevServicesDatasourceProcessor#launchDatabases: Previous attempts to find a Docker environment failed

The dind sidecar pulls the latest Docker, which is now Docker 29. Docker 29 refuses any client below API version 1.40. Testcontainers 1.21.3, what our Quarkus 3.35 build was resolving at the time, ships a docker-java that falls back to API 1.32, so the daemon returns a 400 before a single container starts.

The fix is one line, set a docker-java.properties on the test classpath:

# src/test/resources/docker-java.properties
api.version=1.44

The rest of this post is why it stayed hidden until we self-hosted, why that one line is only a stopgap, and the upgrade that makes it unnecessary.

The setup

We'd just moved CI to ARC on our own Talos cluster to get out from under GitHub-hosted minute limits. It's the first step in pulling our build infrastructure in-house: we're looking at Forgejo to get off GitHub entirely, runners and forge both. Container builds use containerMode: dind, so each ephemeral runner gets a Docker daemon as a sidecar. Our Quarkus services lean on Dev Services: when the tests start, Quarkus uses Testcontainers to spin up a real PostgreSQL container. That container has to talk to the dind daemon.

docker buildx, the container-build job, talked to dind fine. The test jobs did not.

The symptom

The build-and-test job failed during test bootstrap, not compilation. Compiling 176 files succeeded. Then Testcontainers couldn't get a Docker environment:

[org.testcontainers.dockerclient.DockerClientProviderStrategy] Could not find a valid Docker environment
EnvironmentAndSystemPropertyClientProviderStrategy: failed with exception
  BadRequestException (Status 400: client version 1.32 is too old.
  Minimum supported API version is 1.40, please upgrade your client to a newer version)

Notice how it doesn't say "no Docker found". Docker is there. It just rejected the API version.

Why it happened, and why hosted runners hid it

Docker has been raising the minimum API version its daemon will serve. Docker 29's floor is 1.40, and it's a hard floor. You can't drop it back to 1.32 with DOCKER_MIN_API_VERSION the way you used to.

Confirm the daemon's range from inside the runner:

$ docker version --format 'Server {{.Server.Version}} API {{.Server.APIVersion}} Min {{.Server.MinAPIVersion}}'
Server 29.5.2  API 1.54  Min 1.40

Testcontainers 1.21.3 ships a docker-java that defaults to API 1.32 when it can't negotiate something higher, and Docker 29 turns away every request with a 400.

The reason this is a self-hosted gotcha: the GitHub-hosted runners happened to run an older Docker whose floor still accepted 1.32. The moment we put CI on a freshly pulled docker:dind at Docker 29, the same code broke. Self-hosted runners tend to run newer Docker than the hosted images do, and a version-sensitive client like docker-java surfaces that on the first run.

The quick fix

If you're stuck on Testcontainers 1.x and need green CI today, force the API version. docker-java reads a docker-java.properties from the classpath; pin it to something inside the daemon's range (1.40 to 1.54):

# src/test/resources/docker-java.properties
api.version=1.44

Tests pick it up on the test classpath, where Dev Services and Testcontainers initialise, and the daemon stops rejecting the client. It fixes local runs on Docker 29 too.

The real solution

That pin treats the symptom. The cause is Testcontainers 1.x's old docker-java default. Upgrade to Testcontainers 2.0.2 or later and it goes away at the root: 2.x ships a docker-java that negotiates the API version with the daemon instead of falling back to 1.32.

We know because we tested it on the exact environment that threw the error. On Testcontainers 2.0.5, on the same Docker 29.5.2 dind, we deleted the docker-java.properties pin and ran the full suite. docker-java negotiated straight to API 1.54 and connected, no 400, all green. On 2.x the pin does nothing, so we removed it.

The order, then: pin to get unblocked if you're trapped on 1.x, upgrade to 2.x for the real fix, then delete the pin. And don't just bump the pin to a higher number and move on. A hardcoded version holds until Docker raises its floor past it, then you're back here with the same error, chasing your own value this time. Negotiation survives the next raise; a pinned number doesn't.

What we didn't use

  • Lower the daemon floor with DOCKER_MIN_API_VERSION. Not an option on Docker 29. The 1.40 floor is hard.
  • Pin the dind sidecar to an older Docker such as docker:24-dind. It works, since its floor still accepts 1.32, but it's a deliberate downgrade of your build Docker and it clutters the ARC values. Fixing the client beats freezing the daemon.

Lessons

  • Self-hosted is not hosted, where Docker versions are concerned. Hosted runners pin a tested toolchain. Your dind pulls the latest. Anything that negotiates Docker API versions (Testcontainers and docker-java, old compose, scripts that hard-code an API version) can break on the jump.
  • Read the 400. "client version X is too old, minimum is Y" is an API-floor rejection, not "Docker is missing". The fix is the client version, not the host.
  • A hardcoded API version is a trap, not a fix. Negotiation adapts to whatever the daemon supports; a pinned number breaks again the next time Docker raises its floor past it. Reach for the pin only when you're stuck on Testcontainers 1.x, then get off it.

References

This is one of a small series of Java-on-containers traps we've written up. See also why JAVA_OPTS_APPEND is a fallback, not an append on Red Hat UBI images, and the JDK 25 AOT cache portability trap.

Running Quarkus in CI and production and tired of walking into this kind of thing alone? Talk to us about managed Java hosting.