Getting Xdebug 3 working through a Docker/Nginx stack is the kind of task that takes an afternoon if you don't know the networking pitfalls. This guide cuts straight to a working configuration, explains every setting, and covers both PhpStorm and Emacs (DAP Mode) as IDE targets.
How Xdebug 3 Initiates Connections
In Xdebug 3 the PHP process connects to your IDE, not the other way around. Your IDE listens on a port (default 9003); when a debug session starts, Xdebug opens a TCP connection to the configured host and port. In Docker this means host.docker.internal (Docker Desktop, Mac/Windows) or your host's bridge IP (Linux).
Docker Compose Stack
# docker-compose.yml
services:
nginx:
image: nginx:1.27-alpine
ports:
- "8080:80"
volumes:
- ./web:/var/www/html:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- php
php:
build:
context: .
dockerfile: docker/php/Dockerfile
volumes:
- ./web:/var/www/html
- ./docker/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini:ro
environment:
# Toggle Xdebug without rebuilding the image
XDEBUG_MODE: "${XDEBUG_MODE:-off}"
XDEBUG_CONFIG: "client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003"
extra_hosts:
# Linux Docker Engine: map host.docker.internal explicitly
- "host.docker.internal:host-gateway"Keep XDEBUG_MODE=off in your .env for day-to-day development and flip it to debug only when you need it. Xdebug adds measurable overhead even in debug mode with no active session.
PHP Dockerfile
# docker/php/Dockerfile
FROM php:8.3-fpm-alpine
# Install Xdebug via PECL (Alpine-compatible)
RUN apk add --no-cache $PHPIZE_DEPS linux-headers \
&& pecl install xdebug-3.4.0 \
&& docker-php-ext-enable xdebug \
&& apk del $PHPIZE_DEPS
# Other extensions
RUN docker-php-ext-install pdo_mysql opcache
WORKDIR /var/www/htmlPin the Xdebug version (xdebug-3.4.0) to keep builds reproducible. The linux-headers package is required by Xdebug on Alpine.
Xdebug ini Configuration
; docker/php/xdebug.ini
[xdebug]
zend_extension=xdebug.so
; Mode is controlled via XDEBUG_MODE environment variable at runtime.
; The ini value is the fallback when the env var is absent.
xdebug.mode = off
; Where Xdebug should connect (overridden by XDEBUG_CONFIG env var)
xdebug.client_host = host.docker.internal
xdebug.client_port = 9003
; Start a debug session on every request when mode=debug.
; For on-demand sessions use trigger instead (browser extension or ?XDEBUG_SESSION=1)
xdebug.start_with_request = yes
; IDE key — must match the server name configured in your IDE
xdebug.idekey = PHPSTORM
; Log level: 0=off, 7=all. Enable temporarily to diagnose connection issues.
xdebug.log = /tmp/xdebug.log
xdebug.log_level = 0
; Increase depth for complex Drupal objects
xdebug.var_display_max_depth = 5
xdebug.var_display_max_data = 2048Nginx Configuration
Nginx passes PHP requests to PHP-FPM — no special Xdebug configuration is needed in Nginx itself. A standard Drupal/PHP-FPM block:
# docker/nginx/default.conf
server {
listen 80;
server_name _;
root /var/www/html/web;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
# Pass the IDE key for trigger-mode debugging
fastcgi_param XDEBUG_SESSION_START $arg_XDEBUG_SESSION_START;
fastcgi_param HTTP_COOKIE $http_cookie;
fastcgi_read_timeout 300; # Long timeout for paused debug sessions
}
location ~ /\. {
deny all;
}
}On-Demand Debugging with Triggers
Using start_with_request = yes starts a debug session for every PHP request, which floods your IDE. Switch to trigger mode for shared or CI environments:
xdebug.start_with_request = trigger
xdebug.trigger_value = "" ; Any value triggers a session when emptyThen trigger sessions via:
- Browser extension: Xdebug Helper for Chrome/Firefox
- URL parameter:
https://mysite.ddev.site/path?XDEBUG_SESSION=1 - Cookie:
XDEBUG_SESSION=PHPSTORM - CLI:
XDEBUG_SESSION=1 vendor/bin/drush cache:rebuild
PhpStorm Configuration
1. Configure the Server
- Open Settings → PHP → Servers
- Add a server: Name
drupal-local, hostlocalhost, port8080 - Enable Use path mappings: map your local
./webto/var/www/html/web
2. Start Listening
Click the telephone icon in the toolbar (Start Listening for PHP Debug Connections) or press Alt+8. PhpStorm now listens on port 9003.
3. Set a Breakpoint and Load a Page
Click the gutter next to any PHP line. Load the page. PhpStorm will intercept the connection and pause execution.
Environment Variable for CLI Debugging
# Debug a Drush command
docker compose exec \
-e XDEBUG_MODE=debug \
-e XDEBUG_SESSION=PHPSTORM \
php vendor/bin/drush cache:rebuildEmacs DAP Mode Configuration
DAP Mode for Emacs uses @xdebug/vscode-php-debug as its debug adapter:
npm install -g @xdebug/vscode-php-debug(use-package dap-mode
:ensure t
:config
(require 'dap-php)
(dap-mode 1)
(dap-ui-mode 1)
(dap-tooltip-mode 1)
;; Register a reusable debug template
(dap-register-debug-template
"PHP: Listen (Docker)"
(list :type "php"
:request "launch"
:name "PHP: Listen (Docker)"
:port 9003
;; Map container path -> host path
:pathMappings (ht ("/var/www/html" (expand-file-name "~/projects/mysite")))))
:bind (:map dap-mode-map
("" . dap-debug)
("" . dap-next)
("" . dap-step-in)
("" . dap-step-out)
("" . dap-breakpoint-toggle)
("C-c q" . dap-disconnect))) The :pathMappings hash-table maps container paths (keys) to local absolute paths (values). The ht function from the ht.el package creates it; alternatively:
:pathMappings (let ((m (make-hash-table :test 'equal)))
(puthash "/var/www/html"
(expand-file-name "~/projects/mysite") m)
m)Starting a Session in Emacs
- Start the Docker stack with
XDEBUG_MODE=debug - Set a breakpoint with F9
- Press F5, select "PHP: Listen (Docker)" — Emacs now listens on 9003
- Load the page — execution pauses at the breakpoint
- Use F6/F7/F8 to step; inspect locals in
*dap-ui-locals*
Debugging CLI Scripts and Drush in Emacs
# Start listening in Emacs first (F5 → PHP: Listen), then:
docker compose exec \
-e XDEBUG_MODE=debug \
-e XDEBUG_SESSION=emacs \
php php /var/www/html/vendor/bin/drush cache:rebuildDiagnosing Connection Problems
Enable Xdebug Log
xdebug.log = /tmp/xdebug.log
xdebug.log_level = 7docker compose exec php tail -f /tmp/xdebug.logKey log messages:
I: [Step Debug] INFO: Connecting to configured address/port: host.docker.internal:9003— Xdebug is trying to connectW: [Step Debug] WARN: Connection attempt failed— IDE is not listening; check firewall or wrong portI: [Step Debug] INFO: Connected to debugging client— success
Linux: host.docker.internal Not Resolving
Add extra_hosts: ["host.docker.internal:host-gateway"] to the PHP service in docker-compose.yml (shown above). Alternatively, find the bridge IP:
docker network inspect bridge | grep Gateway
# Then set: xdebug.client_host=172.17.0.1Verify Xdebug Is Loaded
docker compose exec php php -m | grep xdebug
docker compose exec php php -r "var_dump(phpversion('xdebug'));"Profiling with Xdebug
Switch mode to profile to generate Cachegrind files:
XDEBUG_MODE=profile docker compose exec php \
php /var/www/html/vendor/bin/drush queue:run my_queue; In xdebug.ini when profiling
xdebug.output_dir = /tmp/xdebug-profiles
xdebug.profiler_output_name = cachegrind.out.%p.%rOpen the generated cachegrind.out.* file in KCachegrind (Linux) or Webgrind (Docker-friendly browser UI).
Summary Checklist
- Pin the Xdebug version in your Dockerfile; install via PECL with
linux-headerson Alpine - Control
XDEBUG_MODEvia an environment variable — never hardcodemode = debugin ini for shared images - Add
extra_hosts: ["host.docker.internal:host-gateway"]for Linux Docker Engine so containers can reach the host - Set
fastcgi_read_timeout 300in Nginx to prevent timeout during a paused debug session - Use
start_with_request = triggeron shared environments;yesonly on your local machine - PhpStorm: configure a Server with path mappings matching your Docker volumes; listen on port 9003
- Emacs: install
@xdebug/vscode-php-debugvia npm, configuredap-register-debug-templatewith:pathMappings - Enable
xdebug.log_level = 7temporarily to diagnose any connection failure - For profiling, switch to
XDEBUG_MODE=profileand inspect output with KCachegrind or Webgrind