Xdebug 3 Setup with Docker, Nginx, and PhpStorm or Emacs

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/html

Pin 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  = 2048

Nginx 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 empty

Then 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

  1. Open Settings → PHP → Servers
  2. Add a server: Name drupal-local, host localhost, port 8080
  3. Enable Use path mappings: map your local ./web to /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:rebuild

Emacs 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

  1. Start the Docker stack with XDEBUG_MODE=debug
  2. Set a breakpoint with F9
  3. Press F5, select "PHP: Listen (Docker)" — Emacs now listens on 9003
  4. Load the page — execution pauses at the breakpoint
  5. 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:rebuild

Diagnosing Connection Problems

Enable Xdebug Log

xdebug.log = /tmp/xdebug.log
xdebug.log_level = 7
docker compose exec php tail -f /tmp/xdebug.log

Key log messages:

  • I: [Step Debug] INFO: Connecting to configured address/port: host.docker.internal:9003 — Xdebug is trying to connect
  • W: [Step Debug] WARN: Connection attempt failed — IDE is not listening; check firewall or wrong port
  • I: [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.1

Verify 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.%r

Open 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-headers on Alpine
  • Control XDEBUG_MODE via an environment variable — never hardcode mode = debug in 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 300 in Nginx to prevent timeout during a paused debug session
  • Use start_with_request = trigger on shared environments; yes only on your local machine
  • PhpStorm: configure a Server with path mappings matching your Docker volumes; listen on port 9003
  • Emacs: install @xdebug/vscode-php-debug via npm, configure dap-register-debug-template with :pathMappings
  • Enable xdebug.log_level = 7 temporarily to diagnose any connection failure
  • For profiling, switch to XDEBUG_MODE=profile and inspect output with KCachegrind or Webgrind

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.