feat(env): Serve local wp-env over HTTPS - #1287
Conversation
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.
gabrielcld2
left a comment
There was a problem hiding this comment.
@utkarshcloudinary approach works well, just noticed one potential issue regarding port
| return $url; | ||
| } | ||
|
|
||
| return preg_replace( '#^(https://[^/:]+):(?:8888|8889)#', '$1', $url ); |
There was a problem hiding this comment.
issue: This strips the literal 8888/8889 ports, not the WP_ENV_PORT which this PR makes customisable.
There was a problem hiding this comment.
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.
|
|
||
| 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 |
There was a problem hiding this comment.
issue: Same problem of the port being literal 8888, instead of supporting custom ones.
There was a problem hiding this comment.
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.
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 theSecureflag over HTTPS (php/class-media.php), and the admin enforcesFORCE_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.cloudinary.local.wpenv.netandtests.cloudinary.local.wpenv.net. All subdomains oflocal.wpenv.netresolve to127.0.0.1over public DNS, so there is no/etc/hostsstep. The approach is borrowed from the XWP VIP site template..wp-env/certs/(gitignored) on first start. Only trusting the CA needs mkcert on the host, vianpm run env:install-cert, once.WP_HOME, redirects, cookies or asset URLs.X-Forwarded-Protoonto$_SERVER['HTTPS']. It cannot live inwp-config.php, because wp-env rewrites that file on every start.--config .wp-env.ci.jsonand setsWP_BASE_URLto the plain-HTTP URL.Three wp-env behaviours needed working around, each documented at the point of the workaround:
WP_HOMEandWP_SITEURL(post-process-config.js:147) with no opt-out, so a filter strips it.wp_plugin_directory_constants()runs atwp-settings.php:497, before mu-plugins load, so plugin asset URLs kept the port and 404'd.WP_CONTENT_URLandWP_PLUGIN_URLare set directly in.wp-env.json, which wp-env leaves alone.@wordpress/e2e-test-utils-playwrightreadsWP_BASE_URLfrom the environment rather than Playwright'sbaseURL, defaulting tohttp://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/hostsand certificate store. Containers are now identified by this repository's bind mount. The same flaw existed infix-loopback.shbefore this branch.QA notes
Setup (once):
npm run env:start npm run env:install-cert # asks for your passwordIf 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:
admin/password.:8888in it, before or after login.https://, with no port and no failures. Plugin CSS, JS, SVGs and the icon font should all return 200.wordpress_logged_in_*andwordpress_sec_*haveSecureandHttpOnlyset.http://cloudinary.local.wpenv.netredirects to HTTPS.Verify loopback verifies the certificate rather than skipping it:
Expect
OK 200. Repeat withtests-clifor the tests site.Verify the scripts stay inside this project. With another Docker project running, run
npm run env:starttwice, then confirm the unrelated containers are untouched and this project's are not duplicated:Run the following in the repo root; each of the four containers should print
1, not3: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:
Test suites:
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.jsonduplicates.wp-env.jsonminus 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.