PHP runs in a Docker container and your host IDE never receives an Xdebug connection; how do you configure Xdebug 3 so step debugging works?
answer
- localhost is the container itself
- client_host=host.docker.internal
- outbound from container, no -p 9003
- discover_client_host sees the wrong address
- path mappings for breakpoints
basics
~20 sInside a container localhost is the container, so set xdebug.client_host to an address that reaches the host, such as host.docker.internal, keep discover_client_host off, let the IDE accept connections on 9003, and map container paths to local paths in the IDE.
solid answer
~40 sXdebug connects **out** from PHP to `client_host:client_port`, and inside the container the default `localhost` is the container itself, so the connection fails (`Could not connect to debugging client. Tried: localhost:9003`). Set `xdebug.client_host=host.docker.internal`, a name Docker Desktop provides; on a Linux engine you usually add it with `extra_hosts: host.docker.internal:host-gateway`, or use Xdebug's Linux-only `xdebug://gateway`. Publishing 9003 with `-p` is pointless, because nothing connects into the container; the host firewall must instead accept the inbound 9003. Keep `discover_client_host=0`, since the address PHP sees is a bridge or proxy, not your IDE. If the connection succeeds but breakpoints never hit, the IDE needs a path mapping from `/var/www/html` to the local project. `xdebug.log` shows which of these steps failed.
code
yaml · 9 linesservices:
app:
build: .
ports:
- "8080:80" # the web app; 9003 is NOT published
extra_hosts:
- "host.docker.internal:host-gateway" # needed on Linux engines
volumes:
- ./:/var/www/html # IDE maps ./ to /var/www/htmlgo deeper
Recall that inside a container localhost means the container, so client_host must point at the host where the IDE runs.
Explain why publishing 9003 does not help, which client_host values reach the host, and why discover_client_host misleads here.
Show a methodical diagnosis: xdebug_info(), trigger, listener and firewall, xdebug.log NOCON versus TIMEOUT, then path mappings.
Standardise a development image with debugging opt-in and documented host mapping, and keep Xdebug out of production images entirely.
## Why it fails by default Step debugging depends on a **connection that Xdebug opens from the PHP process to the IDE**. With the defaults, Xdebug dials `localhost:9003`. That works when PHP and the IDE share a machine, but a container has its **own network namespace**: inside it, `localhost` is the container, where nothing listens on 9003. Xdebug's log says exactly that: ``` [Step Debug] ERR: Could not connect to debugging client. Tried: localhost:9003 (through xdebug.client_host/xdebug.client_port). ``` So the fix is on the **Xdebug side**: tell it an address that, *from inside the container*, reaches the host running the IDE. ## Choosing client_host | Option | Where it works | Notes | |---|---|---| | `host.docker.internal` | Docker Desktop, and Linux engines once mapped | on Linux, typically added with `extra_hosts: ["host.docker.internal:host-gateway"]` | | `xdebug://gateway` | Linux containers only | Xdebug resolves the container's default gateway itself | | the host's LAN IP | anywhere reachable | breaks when the IP changes, for example on another network | | `xdebug://nameserver` | Linux only, not musl-based images such as Alpine | uses the configured private nameserver | Put the setting in the image's Xdebug ini file: ```ini xdebug.mode=debug xdebug.client_host=host.docker.internal xdebug.client_port=9003 xdebug.discover_client_host=0 xdebug.log=/tmp/xdebug.log ``` ## Three things that do not help 1. **Publishing port 9003** (`-p 9003:9003` or `ports:`) forwards connections *into* the container. Xdebug connects *out* of it, so publishing achieves nothing, and it can even take the port the IDE wants on the host. 2. **`discover_client_host=1`** makes Xdebug connect back to the address in `HTTP_X_FORWARDED_FOR` or `REMOTE_ADDR`. Through Docker's port publishing, that is usually a bridge gateway or proxy address, not the machine where your IDE listens. Xdebug then falls back to `client_host` after a failed attempt, which adds delay and noise. The option also accepts *anyone* who can reach the web server, so leave it off. 3. **Changing the IDE's port alone.** Xdebug still dials `client_port`, so both must agree, and 9003 on both sides is simplest. ## When the connection works but breakpoints do not Once the log shows `Connected to debugging client`, a remaining failure is usually **paths**. Xdebug reports file names as PHP sees them, such as `/var/www/html/src/Order.php`, while the IDE knows `~/code/shop/src/Order.php`. Without a **path mapping** in the IDE's debug configuration between the two roots, breakpoints are set on files that, from Xdebug's view, do not exist, and Xdebug logs a warning about a breakpoint in a missing file. Configure the mapping in the IDE; Xdebug 3.5 also has initial native path-mapping support, but the IDE-side mapping is the common setup. ## A checklist in order 1. `xdebug_info()` from a web request to the container: is `debug` among the modes, and what is `client_host`? 2. Is a trigger present (browser extension cookie, `XDEBUG_TRIGGER`), or is `start_with_request=yes`? 3. Is the IDE listening on 9003, and does the host firewall accept inbound connections on it? 4. `xdebug.log`: which address was tried, and was it refused (`NOCON`) or silently dropped (`TIMEOUT`)? 5. Connected but no stop: fix the IDE path mapping, and set `xdebug.log_level=10` to see breakpoint resolution. ## Debugging CLI commands inside the container Tests and console commands run inside the container too, and the same `client_host` applies. The trigger is an environment variable passed into the process, for example `docker compose exec -e XDEBUG_SESSION=1 app vendor/bin/phpunit`. `discover_client_host` never applies on the CLI, because there are no HTTP headers to read. If a CLI session does not start while web sessions do, compare `xdebug_info()` output from both: the CLI may read a different ini file. ## Keeping it out of production Build Xdebug into a development image or stage only. A production image with `mode=debug` and a reachable `client_host` is an open door for anyone who can send a trigger.
- Why does xdebug.log report TIMEOUT rather than NOCON in some container setups?`NOCON` means the connection was actively refused or could not be made, typically because nothing listens at that address. `TIMEOUT` means no answer arrived within `connect_timeout_ms`, which usually points to a firewall silently dropping the packets, or a wrong but routable address. Allow inbound 9003 on the host, check `client_host`, and raise the timeout only for genuinely slow networks.
- The log says 'Connected to debugging client' but no breakpoint is ever hit. What do you check?The IDE's path mapping. Xdebug reports container paths such as `/var/www/html/src`, and the IDE must translate them to the local project; without that, breakpoints point at files Xdebug does not know. Also confirm the breakpoint is on an executable line and the code path actually runs. `xdebug.log_level=10` logs breakpoint resolution.
saying these in an interview costs you the question
- Publish port 9003 on the container so the IDE can connect in
- localhost inside the container reaches the host
- discover_client_host=1 fixes container networking automatically
- A successful connection guarantees breakpoints will hit
- The IDE's port setting alone decides where Xdebug connects