Axle Staging Guard guide

How a copy is classified

  1. WP_ENVIRONMENT_TYPE, from wp-config.php or the server environment, when it is set. production turns the guards off; staging, development and local turn them on.
  2. Otherwise AXLE_STAGING_GUARD_PRODUCTION_HOSTS, a list of the live site’s domains. A domain on the list is production, anything else is staging. This is the most reliable option when the live site answers on several domains, and on multisite networks with mapped domains.
  3. Otherwise the production lock, set on the live site from Tools > Staging Guard or with wp axle-staging-guard lock-production. It stores sha256( 'axle-staging-guard|' . <live domain> ). Any copy whose Site Address domain hashes differently is staging. Search-replace rewrites domain names in the database but not their hashes, so the lock survives a database copy. www.example.com and example.com count as the same domain.
  4. With none of these, WordPress assumes production and the plugin stays off.

When the lock is what makes a copy staging, every admin screen shows a warning until each admin dismisses it for that domain, and Site Health lists it as recommended. That is right on a real staging copy, and it is the only sign you would get if the live site’s own address changed. Setting WP_ENVIRONMENT_TYPE on staging copies makes it explicit and removes the warning.

Changing the detected environment

add_filter( 'axle_staging_guard_environment', function ( array $environment ) {
	// [ 'type' => 'production|staging|development|local', 'source' => 'constant|hosts|lock|default' ]
	if ( str_ends_with( $_SERVER['HTTP_HOST'] ?? '', '.test' ) ) {
		return array( 'type' => 'local', 'source' => 'filter' );
	}
	return $environment;
} );

Email

Email is handled on the wp_mail filter (recipients, subject, Cc and Bcc) and pre_wp_mail (stopping a blocked email and reporting it as sent, so password resets and forms behave normally). SMTP plugins that send through wp_mail() are covered. A plugin that replaces wp_mail() entirely cannot be intercepted; the settings screen and Site Health both say so.

Outgoing requests

On a copy that is not live, requests made through the WordPress HTTP API (wp_remote_get(), wp_remote_post() and wp_remote_request(), which WordPress, WooCommerce gateways, CRM and marketing plugins, webhook plugins and license checks use) are checked on pre_http_request, before anything is sent. It runs first, so no other plugin’s short circuit sees a request that is stopped.

Modes

ModeWhat happens to a host that is not allowed
Allow allGoes through, nothing recorded. The default for sites that already had the plugin, so an update never changes what a staging site can reach
Log onlyGoes through and is recorded. The default for a fresh install
Block all except allowed hostsFails with a WP_Error (code axle_staging_guard_http_blocked) and is recorded

A fresh install starts on log only rather than block. Blocking everything on first activation also stops premium plugin updates and license checks, OAuth logins that exchange a token on the server, and oEmbed, which would leave a site that looks broken before anyone chose that. Log only changes nothing, and after a day of normal use the list shows which services the site calls, so the allowlist is written from facts. Site Health keeps recommending a decision while the mode is allow all.

Order of rules. For each request: the blocklist first, which wins in every mode and over the allowlist. Then the hosts that are always allowed. Then your allowlist. Then the mode.

Hosts that are always allowed

HostWhy
wordpress.org, *.wordpress.orgapi.wordpress.org is core, plugin and theme update checks, translations and the plugin directory. downloads.wordpress.org serves the update packages. Without them Dashboard > Updates, Site Health and plugin installs fail. They are read-only and act on nobody’s behalf
w.org, *.w.orgWordPress.org’s short domain: plugin and emoji assets (ps.w.org, s.w.org)
this site’s own host, and the www. variantLoopback requests: WP-Cron spawning, the REST API health check in Site Health, and loopback editing checks. Both Site Address and WordPress Address are used
localhost, 127.0.0.0/8, ::1, *.localhostSame machine: local object caches over HTTP, local search services, dev containers

Everything else is left to the allowlist on purpose: other private network addresses can be production services (a shared database API or queue), and the same goes for any third-party host. Payment, CRM and webhook hosts such as api.stripe.com, api.paypal.com, *.mailchimp.com, api.hubapi.com, *.salesforce.com, hooks.zapier.com, hook.make.com, hooks.slack.com and analytics endpoints are therefore blocked in block mode unless you list them.

Host patterns. api.example.com matches that host. *.example.com matches every subdomain at any depth, not example.com itself, so list both when you need both. Ports, schemes and paths do not count. A bare * and *.com are refused because they would switch the guard off. Pasting a whole URL works: only its host is kept.

The blocklist is optional and empty by default. Hosts on it are blocked in every mode, so "allow all except these" is possible. It also beats the allowlist.

What the list stores. One entry per distinct method, host, path and caller, with a count and first and last seen times, so a cron job in a loop is one row. It never stores query strings, bodies or headers. Path parts that look like secrets or identifiers (Slack and Discord webhook tokens, Telegram bot tokens, Mailchimp’s hash of an email address, anything with an @) are replaced with {token}, {hash} or {email}. The caller (plugin:woocommerce, mu-plugin:sync, theme:my-theme) is the nearest frame in the backtrace inside a plugin or theme folder, and is only worked out for requests that are stopped or recorded.

What it cannot see. Code that does not use the WordPress HTTP API: a PHP SDK that opens its own cURL handle or socket, file_get_contents() on a URL, and requests the browser makes. Test mode of a payment gateway is not special: sandbox.paypal.com or api.stripe.com with a test key are hosts like any other, so allow the ones you want to test against. If WP_HTTP_BLOCK_EXTERNAL is set, WordPress applies it after this check.

Privacy

The log can hold personal data: email addresses, subjects and, if you turn it on, message bodies, which may include password reset links. So:

  • message bodies are off by default
  • entries are deleted after 7 days (AXLE_STAGING_GUARD_LOG_DAYS)
  • the log takes part in Tools > Export Personal Data and Tools > Erase Personal Data
  • Settings > Privacy gets suggested text for your privacy policy

The request list holds no personal data by design: no query strings, bodies or headers, and path parts that look like email addresses, hashes or keys are masked. It is deleted on the same schedule, and the suggested privacy policy text mentions it, but it needs no export or erase entry. The live site keeps no log at all.

Multisite

On a network, only network admins (manage_network_options) can see and change Axle Staging Guard, so one site admin on a staging network cannot switch email back on for everyone. Put WP_ENVIRONMENT_TYPE or AXLE_STAGING_GUARD_PRODUCTION_HOSTS with every mapped domain in wp-config.php, and it covers the whole network.

WordPress VIP

VIP caches robots.txt. After the first deploy of Axle Staging Guard to a VIP staging environment, flush it from Settings > Reading by toggling "Discourage search engines" on and off.