Make WordPress Core

Opened 4 hours ago

Last modified 3 hours ago

#65484 accepted defect (bug)

Loopback requests fail in the wordpress-develop Docker environment

Reported by: westonruter's profile westonruter Owned by: westonruter's profile westonruter
Milestone: 7.1 Priority: normal
Severity: normal Version:
Component: Build/Test Tools Keywords: has-patch
Focuses: Cc:

Description

For the longest time, loopback requests have failed in the built-in Docker dev environment with an error:

cURL error 7: Failed to connect to localhost port 8889 after 4 ms: Could not connect to server

This is easily reproduced via WP-CLI:

npm run env:cli eval "var_dump(wp_remote_get(home_url()));"

But it also is reflected in Site Health, where the loopback_requests failure is surfaces as a critical issue. This also prevents the page_cache test from working.

In order to get WP Cron to work, I've had to resort to adding define( 'ALTERNATE_WP_CRON', true ) to my wp-config.php which is very annoying because whenever cron needs to run, accessing a URL results in a redirect with a ?doing_wp_cron=1773273481.4603710174560546875000 query parameter added to the URL.

Attachments (1)

loopback-test-failure.png (93.1 KB) - added by westonruter 4 hours ago.
Loopback test failure

Download all attachments as: .zip

Change History (4)

@westonruter
4 hours ago

Loopback test failure

#1 @westonruter
4 hours ago

  • Owner set to westonruter
  • Status changed from assigned to accepted

I put Claude on the problem and it very helpfully came up with the following mu-plugin:

<?php
/**
 * Plugin Name: Fix Docker Loopback Requests
 * Description: Routes WordPress loopback HTTP requests (Site Health, wp_remote_get(home_url()), cron, etc.) to the Docker host gateway. Inside the php/cli containers "localhost" is the container's own loopback where nothing listens on the published port, so requests to home_url() fail with "cURL error 7: Could not connect to server". The docker-compose `extra_hosts: localhost:host-gateway` mapping is shadowed by Docker's default `127.0.0.1 localhost` entry, so this forces the gateway resolution at the cURL layer instead.
 *
 * This is a development-environment-only shim and should never ship to production.
 */

// Only run in the local Docker dev environment.
if ( ! function_exists( 'wp_get_environment_type' ) || 'local' !== wp_get_environment_type() ) {
        return;
}

add_action(
        'http_api_curl',
        /**
         * Pins loopback requests to the Docker host gateway via CURLOPT_RESOLVE.
         *
         * @param resource|\CurlHandle $handle The cURL handle.
         * @param array<string, mixed> $args   The HTTP request arguments.
         * @param string               $url    The request URL.
         */
        static function ( $handle, array $args, string $url ) {
                $host = wp_parse_url( $url, PHP_URL_HOST );
                if ( ! is_string( $host ) ) {
                        return;
                }

                // Only rewrite requests aimed at this site (loopback), not arbitrary outbound requests.
                $home_host = wp_parse_url( home_url(), PHP_URL_HOST );
                if ( $host !== $home_host ) {
                        return;
                }

                // host.docker.internal resolves to the host gateway, which reaches the published web-server port.
                $gateway = gethostbyname( 'host.docker.internal' );
                if ( 'host.docker.internal' === $gateway ) {
                        return; // Not running under Docker Desktop / gateway unavailable.
                }

                $port = wp_parse_url( $url, PHP_URL_PORT );
                if ( ! $port ) {
                        $port = ( 'https' === wp_parse_url( $url, PHP_URL_SCHEME ) ) ? 443 : 80;
                }

                curl_setopt( $handle, CURLOPT_RESOLVE, array( "{$host}:{$port}:{$gateway}" ) );
        },
        10,
        3
);

Claude's full analysis:

Loopback requests fail in the Docker local-env (cURL error 7)

Summary: Loopback HTTP requests fail in the Docker-based local development environment. The Site Health "loopback request" test fails, and so does any in-PHP loopback, e.g. npm run env:cli -- eval "var_dump(wp_remote_get(home_url()));", which returns cURL error 7: Failed to connect to localhost port 8000 after 4 ms: Could not connect to server.

Root cause: The web server (nginx) runs in a separate container (wordpress-develop) that publishes 8000:80 on the host, while php-fpm and WP-CLI run in their own containers (php, cli). home_url() is http://localhost:8000, but inside the php/cli containers localhost is the container's own loopback, where nothing listens on port 8000, so the request is refused. [a629d1cc6c] attempted to address this by adding extra_hosts: localhost:host-gateway so that localhost would resolve to the host gateway (which does reach the published port — 192.168.65.254:8000 returns 200). However, that mapping is shadowed: Docker always writes 127.0.0.1 localhost and ::1 localhost first in /etc/hosts, so the resolver returns the loopback addresses ahead of the gateway entry. cURL connects to ::1/127.0.0.1, gets an immediate connection-refused, and never falls through to the gateway. In short, extra_hosts cannot override localhost because Docker's own entries take precedence.

Diagnostic evidence: Inside the cli container, getent ahosts localhost returns 127.0.0.1 before 192.168.65.254, and a verbose curl shows only ::1 and 127.0.0.1 being tried before failing. Reaching the gateway directly (192.168.65.254:8000 or host.docker.internal:8000) returns 200, and pinning the loopback with CURLOPT_RESOLVE (localhost:8000:192.168.65.254) also returns 200 — confirming the route works and only name resolution of localhost is the problem.

Possible fix direction: Force gateway resolution at the cURL layer rather than via /etc/hosts. For requests aimed at the site's own host, set CURLOPT_RESOLVE to the host gateway (resolved at runtime via host.docker.internal), scoped to the local environment. Verified working as a proof of concept:

<?php
add_action(
        'http_api_curl',
        static function ( $handle, $args, $url ) {
                if ( 'local' !== wp_get_environment_type() ) {
                        return;
                }
                $host = wp_parse_url( $url, PHP_URL_HOST );
                if ( ! is_string( $host ) || $host !== wp_parse_url( home_url(), PHP_URL_HOST ) ) {
                        return;
                }
                $gateway = gethostbyname( 'host.docker.internal' );
                if ( 'host.docker.internal' === $gateway ) {
                        return; // Gateway unavailable (not Docker Desktop).
                }
                $port = wp_parse_url( $url, PHP_URL_PORT );
                if ( ! $port ) {
                        $port = ( 'https' === wp_parse_url( $url, PHP_URL_SCHEME ) ) ? 443 : 80;
                }
                curl_setopt( $handle, CURLOPT_RESOLVE, array( "{$host}:{$port}:{$gateway}" ) );
        },
        10,
        3
);

After applying this, wp_remote_get(home_url()) returns HTTP 200 and WP_Site_Health::can_perform_loopback() returns status=good. This covers Site Health, cron spawning, and any other loopback request. Note the existing extra_hosts: localhost:host-gateway lines in docker-compose.yml are ineffective and could be removed if a different mechanism is adopted.

#2 @westonruter
4 hours ago

I think we can make this fix available to everyone by adding such an mu-plugin to tools/local-env and mounting it via docker-compose.yml.

This ticket was mentioned in PR #12219 on WordPress/wordpress-develop by @westonruter.


3 hours ago
#3

  • Keywords has-patch added

## Problem

Loopback HTTP requests fail in the local Docker environment. The Site Health "loopback request" test reports a failure, and any in-PHP loopback fails too:

$ npm run env:cli -- eval "var_dump( wp_remote_get( home_url() ) );"
cURL error 7: Failed to connect to localhost port 8000 after 4 ms: Could not connect to server

The web server (nginx) runs in a separate container from php/cli. home_url() is http://localhost:8000, but inside those containers localhost is the container's own loopback, where nothing listens on the published port — so the request is refused.

The existing extra_hosts: localhost:host-gateway mapping (added in [a629d1cc6c]) is meant to address this, but it has no effect when cURL resolves localhost via glibc's getaddrinfo(), which special-cases that name to loopback and never consults the /etc/hosts gateway entry. (The legacy gethostbyname() path *does* return the gateway, but the HTTP stack doesn't use it.) The host gateway itself is reachable — host.docker.internal:8000 returns 200 — so only name resolution of localhost is broken.

## Fix

Add a development-only mu-plugin (tools/local-env/mu-plugins/fix-docker-loopback.php) that, for requests aimed at the site's own host, pins the cURL handle to the Docker host gateway via CURLOPT_RESOLVE (resolved at runtime through host.docker.internal). It is gated on wp_get_environment_type() === 'local' and no-ops where the gateway is unavailable.

The php container copies the shim into the (gitignored) wp-content/mu-plugins directory on startup, mirroring the existing object-cache.php drop-in pattern, so it covers Site Health, cron spawning, and wp_remote_get( home_url() ) from both php and cli.

The extra_hosts lines are left in place — they are the intended mechanism and remain effective on resolvers that honor them (e.g. a c-ares-based libcurl); this shim backs them up where they do not. composer.json is also updated to suggest ext-curl, which the shim relies on.

## Testing

With the environment running:

  • wp_remote_get( home_url() )HTTP 200
  • WP_Site_Health::can_perform_loopback()status=good

PHPCS and PHPStan (level 10, via phpstan-diff) both pass on the new file.

## Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8
Used for: Diagnosing the loopback failure, drafting the mu-plugin and docker-compose.yml changes, and verifying the fix end-to-end. All changes were reviewed and edited by me.

Note: See TracTickets for help on using tickets.