skip to content

Your Testcontainers suite passes locally but cannot reach containers on a CI runner using a remote Docker daemon. How do you fix it?

level: seniorimportance: should knowfreq 45%

answer

  1. Loopback is not where the daemon is
  2. Derive addresses, never hardcode them
  3. The API port is not the container port
  4. One variable overrides the reported host
  5. Firewall rules forget the ephemeral range

basics

~20 s

Published ports land on the machine running the daemon, not on the machine running the tests. Ask the container for its host and mapped port instead of assuming localhost, and set TESTCONTAINERS_HOST_OVERRIDE when the reachable address differs from the DOCKER_HOST address.

solid answer

~50 s

When the daemon is remote, `localhost` in the test JVM is no longer where the container is. Testcontainers derives the container's address from the daemon endpoint — for a `tcp://` `DOCKER_HOST` that is the daemon's host name — so the first fix is to stop hardcoding addresses and always build connection strings from the container's reported host and mapped port. The second fix covers the case where the address the daemon reports is not the address your test JVM can reach: a nested daemon whose published ports are visible under a service alias, or a NAT'd network. `TESTCONTAINERS_HOST_OVERRIDE` (the `tc.host` property) tells the library which host name to hand back. After that, verify the ports are actually reachable: firewalls and security groups frequently allow the daemon's API port while blocking the ephemeral range where container ports are published, which shows up as wait strategies timing out.

code

java · 7 lines
java
// Portable across laptop, socket-mounted runner and remote daemon
postgres.start();
String jdbcUrl = "jdbc:postgresql://" + postgres.getHost()
        + ":" + postgres.getMappedPort(5432) + "/test";

// Broken as soon as the daemon is not on this machine
String wrong = "jdbc:postgresql://localhost:5432/test";

go deeper

for a junior

Remember to build every connection string from the container's reported host and mapped port after it starts, never from a hardcoded localhost and a fixed port.

for a middle

Explain why loopback stops working once the daemon is remote, how the reported host is derived from the daemon endpoint, and what the host-override setting actually changes.

for a senior

Show a disciplined diagnosis: confirm the container started, print the reported address, test raw reachability, then look at network rules for the published-port range rather than editing wait timeouts.

for a principal

Own the portability contract — the same suite must run against a laptop daemon, a socket-mounted runner and a remote worker with no code changes — and make the network rules for published ports part of the runner platform's definition.

## Why local and CI diverge With a daemon on the same machine, a container's published port is bound on that machine's loopback, so `localhost:<mapped port>` works and code that hardcodes `localhost` survives. Move the daemon anywhere else — a `docker:dind` service container, a shared build daemon, a managed remote worker — and that assumption silently breaks. The daemon publishes the port on **its own** host. The test JVM is somewhere else. Nothing is listening on the test JVM's loopback. ## The two things to get right **Ask the container, do not assume.** Every connection string a test builds — JDBC URL, broker bootstrap address, base URL for an HTTP client — must come from the container's own accessors for its host and its mapped port, evaluated *after* the container has started. Testcontainers computes the host from the resolved daemon endpoint: a Unix socket means loopback, while a `tcp://` `DOCKER_HOST` yields the daemon's host name. Ports are assigned at start time, so anything captured before start is wrong regardless of the daemon's location. A suite written this way is portable across every model without change; that is the single highest-value habit for CI portability. **Override the host when the derived one is unreachable.** Sometimes the address the library derives is correct for the daemon and useless for you. Examples: a nested daemon reachable at a service alias while ports are published on a different interface; a daemon behind NAT or a tunnel where the API endpoint and the data path differ; a daemon on a host with several interfaces, only one of which is routable from the job. `TESTCONTAINERS_HOST_OVERRIDE`, or `tc.host` in `~/.testcontainers.properties`, sets the host name the library reports for containers. It changes only the address handed back to your tests; it does not move where ports are published. ## Diagnosing in the right order The symptom is usually a wait strategy timing out or a connection refused at the first client call, and it is tempting to blame the wait. Work outward instead. 1. **Did the container start at all?** If the daemon never accepted the create call, this is a discovery or authentication problem, not a networking one — a different failure with a different message. 2. **What address does the code use?** Print the host and mapped port the container reports and compare them with what the client was given. A hardcoded `localhost` or a fixed port in a configuration file is the most common cause and needs no further investigation. 3. **Is that address reachable from the test JVM?** Try a plain TCP connect from inside the job to that host and port. A refused or timed-out connection here means either the wrong host name — set the override — or a blocked path. 4. **Is the port range open?** This is the subtle one. Firewall and security-group rules are typically written for the Docker API port and forget that containers publish on ephemeral high ports. The API works, containers start, and every connection to a mapped port hangs. Fix the rule or pin the ports you need. ## Related traps in the same family A container that must reach *another* container is a different problem with a different answer: put them on a shared user-defined network and address each other by alias, over the internal port, since the mapped host port is not the path between containers. And bind mounts are resolved on the daemon's filesystem, so with a remote daemon a local path does not exist there at all — stream the content through the Docker API instead. Both are the same underlying lesson: with a remote daemon, "here" and "where the containers are" are different machines, and every assumption that conflates them fails. ## What good looks like A suite whose connection strings are always derived at runtime, one environment variable to fix host derivation where the topology requires it, and firewall rules that account for published ports. Once those are in place the same tests run unchanged on a laptop, on a socket-mounted runner and against a remote daemon — which is the whole point of the exercise.

  • Does the host override change where the daemon publishes ports?
    No. It changes only the host name Testcontainers reports back to your tests when they ask a container for its address. The daemon still publishes ports exactly where it always did; the override exists for topologies where that publishing address is correct but not the one your test JVM can route to.
  • Why is a mapped host port the wrong address for container-to-container traffic?
    Because the mapping exists for clients on the daemon's host. Between containers you attach them to a shared user-defined network and address each other by network alias on the original internal port — no mapping involved. Using the mapped port forces traffic out and back in, and often is not routable at all.
  • The daemon accepts container creation but every connection to a mapped port hangs. What do you suspect first?
    A network rule that permits the Docker API port and blocks the ephemeral range where container ports are published. The API working proves connectivity to the daemon, not to the data path. Test a raw TCP connect to the reported host and mapped port to confirm before touching test code.

saying these in an interview costs you the question

  • Hardcodes localhost and blames the wait strategy
  • Reads the mapped port before the container starts
  • Assumes the API port being open means all ports are open
  • Uses a mapped host port for container-to-container traffic
  • Bind-mounts a local path and expects a remote daemon to see it

context