Skip to content

feat(env): Serve local wp-env over HTTPS - #1287

Merged
gabrielcld2 merged 4 commits into
developfrom
feature/local-https-proxy
Sep 22, 2026
Merged

gabrielcld2 merged 4 commits into
developfrom
feature/local-https-proxy

Conversation

@utkarshcloudinary

Copy link
Copy Markdown
Collaborator

The local environment ran on plain HTTP, which hid behaviour that only appears under TLS: is_ssl() picks the delivery URL scheme (php/class-delivery.php), auth cookies only get the Secure flag over HTTPS (php/class-media.php), and the admin enforces FORCE_SSL_ADMIN. Those differences surfaced only once code reached a real HTTPS site.

Approach

An nginx container terminates TLS in front of wp-env, using a certificate from a locally generated CA. wp-env itself is unchanged and still owns core version switching, the PHPUnit suite and the tests environment, so this adds a proxy rather than replacing the tooling with a hand-rolled docker-compose.yml.

  • Host names. cloudinary.local.wpenv.net and tests.cloudinary.local.wpenv.net. All subdomains of local.wpenv.net resolve to 127.0.0.1 over public DNS, so there is no /etc/hosts step. The approach is borrowed from the XWP VIP site template.
  • Certificates. A container builds mkcert and issues the certificate into .wp-env/certs/ (gitignored) on first start. Only trusting the CA needs mkcert on the host, via npm run env:install-cert, once.
  • Standard ports. The proxy binds 80 and 443, so no port number leaks into WP_HOME, redirects, cookies or asset URLs.
  • Proxy awareness. A mu-plugin promotes X-Forwarded-Proto onto $_SERVER['HTTPS']. It cannot live in wp-config.php, because wp-env rewrites that file on every start.
  • Loopback. The CA is installed into the WordPress and CLI containers, so self-requests verify the certificate instead of skipping the check, and exercise the same path as production.
  • CI. Runners have no CA and no proxy, so the workflow passes --config .wp-env.ci.json and sets WP_BASE_URL to the plain-HTTP URL.

Three wp-env behaviours needed working around, each documented at the point of the workaround:

  • wp-env force-appends its port to WP_HOME and WP_SITEURL (post-process-config.js:147) with no opt-out, so a filter strips it.
  • wp_plugin_directory_constants() runs at wp-settings.php:497, before mu-plugins load, so plugin asset URLs kept the port and 404'd. WP_CONTENT_URL and WP_PLUGIN_URL are set directly in .wp-env.json, which wp-env leaves alone.
  • @wordpress/e2e-test-utils-playwright reads WP_BASE_URL from the environment rather than Playwright's baseURL, defaulting to http://localhost:8889, which sent auth requests around the proxy. The config now sets that variable as the single source of truth.

The second commit fixes three issues found in review, including one pre-existing bug: the container lookup matched on a name pattern loose enough to match other projects' containers, so the scripts edited an unrelated environment's /etc/hosts and certificate store. Containers are now identified by this repository's bind mount. The same flaw existed in fix-loopback.sh before this branch.

QA notes

Setup (once):

npm run env:start
npm run env:install-cert   # asks for your password

If ports 80 or 443 are held by another local project, stop it first; the script names the container holding the port.

Verify HTTPS and the certificate:

  1. Open https://cloudinary.local.wpenv.net/wp-admin. Confirm no certificate warning and no redirect loop, and that you can log in as admin / password.
  2. Confirm the URL has no :8888 in it, before or after login.
  3. In DevTools → Network, hard-reload the Cloudinary dashboard. Every request should be https://, with no port and no failures. Plugin CSS, JS, SVGs and the icon font should all return 200.
  4. In DevTools → Application → Cookies, confirm wordpress_logged_in_* and wordpress_sec_* have Secure and HttpOnly set.
  5. Confirm http://cloudinary.local.wpenv.net redirects to HTTPS.

Verify loopback verifies the certificate rather than skipping it:

npx wp-env run cli wp eval '$r = wp_remote_get( home_url( "/wp-json/" ), array( "sslverify" => true ) ); echo is_wp_error( $r ) ? "ERR: " . $r->get_error_message() : "OK " . wp_remote_retrieve_response_code( $r );'

Expect OK 200. Repeat with tests-cli for the tests site.

Verify the scripts stay inside this project. With another Docker project running, run npm run env:start twice, then confirm the unrelated containers are untouched and this project's are not duplicated:

docker exec <other-project-container> grep -c cloudinary /etc/hosts   # expect 0

Run the following in the repo root; each of the four containers should print 1, not 3:

bash -c 'source .wp-env/scripts/config.sh; for c in $(wp_env_containers); do docker exec "$c" grep -c "cloudinary.local.wpenv.net" /etc/hosts; done'

Verify teardown and a cold start:

npm run env:destroy   # proxy stops too; certificates survive, so no re-trust
npm run env:start     # everything reconfigures automatically

Test suites:

npm run test:e2e                              # 32 tests, over HTTPS
npm run test:unit                             # 29 tests
WP_BASE_URL=http://localhost:8889 npm run test:e2e   # the CI path

Always start e2e through the npm scripts: they set NODE_EXTRA_CA_CERTS, which Playwright's Node-side client needs because it ignores the OS trust store.

Reviewer notes:

  • .wp-env.ci.json duplicates .wp-env.json minus the HTTPS settings. wp-env rejects unknown keys, so the explanation lives in the README and the workflow rather than in the file. Shared values need manual syncing.
  • The proxy binds 80 and 443, so this project cannot run alongside another that holds those ports.

The local environment ran on plain HTTP, which hid behaviour that only
appears under TLS: is_ssl() picks the delivery URL scheme, auth cookies
only get the Secure flag over HTTPS, and the admin enforces
FORCE_SSL_ADMIN. Those differences surfaced only in production.

Add an nginx proxy that terminates TLS in front of wp-env, using a
certificate issued by a locally generated CA. Host names resolve through
public wildcard DNS, so no /etc/hosts entry is needed, and the proxy
binds the standard ports so no port number leaks into generated URLs.

Loopback REST requests verify the certificate rather than skipping the
check, so they exercise the same path as production.

CI keeps running over plain HTTP through .wp-env.ci.json, because
runners have no local CA and no proxy container.
Three problems found in review of the HTTPS proxy scripts.

Container lookup matched on a name pattern, but wp-env derives its
Compose project name from a hash of the config path, so the pattern was
loose enough to match other projects. The scripts edited /etc/hosts and
the certificate store of an unrelated container. Identify containers by
the bind mount of this repository instead.

sed -i cannot write /etc/hosts, because Docker bind-mounts it and sed
works by renaming a temporary file over the target. The failure was
discarded, so stale host entries accumulated while the script reported
success. Filter through a temporary file and copy the contents back.

Upstream ports were hard-coded, so a developer using wp-env's supported
port overrides got an nginx upstream error. Read the port from the
environment, then the config files, then fall back to the defaults.
The unit job installed @wordpress/env on its own into a scratch prefix
to avoid a full npm ci. That install resolved dependency ranges fresh
against npm rather than obeying package-lock.json, so the job depended
on whatever upstream had published that day.

A broken @wp-playground/cli 3.1.55 release, pulled in transitively by
@wordpress/env, pinned a @php-wasm/node-8-1 version that was never
published. Every run of the job failed while the lockfile-based jobs
were unaffected.

Use npm ci, which installs the locked tree and cannot break because of
a third-party release. Its postinstall hook also runs composer install,
so the separate Composer step is no longer needed. Enable npm caching,
which this job alone was missing and which explains the slow npm ci
timings that motivated the original workaround.

Also stop exporting NODE_EXTRA_CA_CERTS unconditionally for e2e runs.
CI has no certificate, so Node logged "Ignoring extra certs" on every
run; a small wrapper now sets it only when the file exists.
@utkarshcloudinary
utkarshcloudinary marked this pull request as ready for review September 21, 2026 13:46

@gabrielcld2 gabrielcld2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@utkarshcloudinary approach works well, just noticed one potential issue regarding port

Comment thread .wp-env/mu-plugins/https-proxy.php Outdated
return $url;
}

return preg_replace( '#^(https://[^/:]+):(?:8888|8889)#', '$1', $url );

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: This strips the literal 8888/8889 ports, not the WP_ENV_PORT which this PR makes customisable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed in 1484b73.

Replaced the literal 8888|8889 match with logic that derives the public origin from WP_CONTENT_URL (which wp-env never rewrites) and replaces whatever port is present, so any custom port is handled without this file knowing about it. It now also leaves URLs on other hosts untouched, which the old pattern did not guard against.

Verified with port: 8901 in .wp-env.override.json: wp-env wrote WP_HOME=https://cloudinary.local.wpenv.net:8901 into wp-config.php, and home_url() returned https://cloudinary.local.wpenv.net. The old regex would have left :8901 in every generated URL.

Comment thread .wp-env/scripts/fix-loopback.sh Outdated

if [ $? -eq 0 ]; then
if docker exec "$CONTAINER" bash -c \
"grep -q 'Listen 8888' /etc/apache2/ports.conf || (echo 'Listen 8888' >> /etc/apache2/ports.conf && apache2ctl graceful)" 2>/dev/null; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Same problem of the port being literal 8888, instead of supporting custom ones.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1484b73. The listener now uses $WP_ENV_PORT from config.sh, which resolves the port from WP_ENV_PORT, then .wp-env.override.json, then .wp-env.json, then the default.

This was the more serious of the two: with port: 8901 the old code opened Listen 8888 inside the container while wp-env published 8901, so loopback requests failed outright. Verified after the fix that the container has Listen 8901 and that port 8888 correctly refuses connections.

PR review found two places that still assumed the default ports after
this branch made them configurable.

The Apache listener was hard-coded to 8888, so with a custom port
wp-env published one port while Apache opened another and loopback
requests failed. It now uses the resolved port from config.sh.

The URL filter matched a literal 8888 or 8889, so a custom port stayed
in every generated URL. It now derives the public origin from
WP_CONTENT_URL, which wp-env never rewrites, and replaces whatever port
is present. It also leaves URLs on other hosts untouched, which the
previous pattern did not guard against.
@gabrielcld2
gabrielcld2 merged commit 45987e5 into develop Sep 22, 2026
7 checks passed
This was referenced Sep 22, 2026
@gabrielcld2
gabrielcld2 deleted the feature/local-https-proxy branch September 23, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants