Upsun
The Upsun mu-plugin for WordPress: environment awareness, router-cache friendliness, safe preview clones, deploy migrations, and a wp upsun CLI. Fully no-ops off-platform.
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/artetecha/upsun-wp/archive/refs/heads/main.zipReadme
upsun-wp — the Upsun mu-plugin for WordPress
Platform integration for WordPress running on Upsun: environment awareness, router-cache friendliness, safe preview clones, deploy migrations, Cloudflare front-end support, Upsun-specific Site Health checks, and a wp upsun CLI command.
Site & docs: upsun.artetecha.com
The plugin detects Upsun at runtime (PLATFORM_APPLICATION_NAME + PLATFORM_ENVIRONMENT) and fully no-ops anywhere else — local development and CI need no special-casing. It reads platform variables directly and never defines WordPress configuration constants: your wp-config.php stays the single owner of database credentials, URLs, salts, and WP_ENVIRONMENT_TYPE.
This is a generic plugin for any WordPress project on Upsun; site-specific behavior belongs in the consuming project via the filters below — never in this package. It is used in production by two unrelated sites — an LMS/commerce site and a second migrated to the starter model at 1.0 — both consuming it exclusively through the public filter/constant API, which is what validates that the generic-vs-site-specific boundary holds. A companion starter repository — a deploy-ready Composer WordPress on Upsun, pre-wired for this plugin — is live.
Installation (Composer-managed WordPress)
Three steps: require the package, route the install path (and copy the loader shim), and wire the post_deploy hook.
1. Require the package
// composer.json
{
"require": {
"artetecha/upsun-wp": "^1.0"
}
}
2. Route the install path and copy the loader shim. WordPress does not scan mu-plugin subdirectories, so a shim always has to reach the mu-plugins root; where the package itself may install depends on your layout.
Content directory OUTSIDE the core install dir (Bedrock-style): the
standard route works — the package lands in mu-plugins/upsun/ (via its
installer-name) and only the shim needs copying:
"extra": {
"installer-paths": {
"web/app/mu-plugins/{$name}": ["type:wordpress-muplugin"]
}
},
"scripts": {
"post-install-cmd": [
"cp web/app/mu-plugins/upsun/upsun-loader.php web/app/mu-plugins/upsun-loader.php"
]
}
Content directory INSIDE the core install dir (johnpbloch-style
wordpress/wp-content/...): do not route this package into
wordpress/. Composer installs independent packages in alphabetical
order; artetecha/* sorts before johnpbloch/*, and the WordPress core
extraction replaces the entire install dir — silently deleting anything
placed there earlier. Route the package to a staging directory and copy it
in with the shim:
"extra": {
"installer-paths": {
"composer-mu-plugins/{$name}": ["artetecha/upsun-wp"],
"wordpress/wp-content/mu-plugins/{$name}": ["type:wordpress-muplugin"]
}
},
"scripts": {
"postbuild": [
"mkdir -p wordpress/wp-content/mu-plugins",
"rm -rf wordpress/wp-content/mu-plugins/upsun",
"cp -R composer-mu-plugins/upsun wordpress/wp-content/mu-plugins/upsun",
"cp composer-mu-plugins/upsun/upsun-loader.php wordpress/wp-content/mu-plugins/upsun-loader.php"
],
"post-install-cmd": "@postbuild",
"post-update-cmd": "@postbuild"
}
(Add /composer-mu-plugins/ and the copied files to .gitignore; scripts
run after every install, so the copy is always fresh.)
3. Wire preview sanitize into the post_deploy hook. Data syncs redeploy an environment without a code change, so only the post_deploy hook runs — deploy does not, which makes post_deploy the only hook that can catch every clone and resync. Add one line to .upsun/config.yaml that is safe on every environment (production refreshes the stamp that makes its clones detectable; already-sanitized previews no-op):
hooks:
post_deploy: |
wp upsun sanitize --if-needed
This line is also where your sanitization policy lives: --enable forces
the opt-in DB-writing sanitizers for the run, so the whole policy is declared
at project level in versioned config and applied identically to every child
environment (or vary it per environment type with a small script):
hooks:
post_deploy: |
wp upsun sanitize --if-needed --enable="anonymize-user-emails,anonymize-user-passwords:password-{ID}"
Skipping this step does not weaken the runtime preview protections (mail interception, payment test mode, webhook pausing are active on every preview request from boot) — it only means the one-time upsun_preview_sanitize consumer actions never fire. The "Preview safety" health check (Site Health, the Upsun dashboard, wp upsun doctor) warns on every environment until the wiring is in place. If you cannot edit your hooks, enable the per-boot fallback via the upsun_safe_previews_boot_check filter.
Modules
| Module | What it does |
|---|---|
cloudflare |
For sites proxied by Cloudflare in front of the Upsun router. The Upsun router already resolves the real client IP into REMOTE_ADDR (verified: REMOTE_ADDR == CF-Connecting-IP == X-Client-IP, and Cloudflare's edge never appears in REMOTE_ADDR/X-Forwarded-For), so this module does not rewrite it — that would be redundant and, on a direct origin hit, spoofable. It detects Cloudflare via the CF-Ray/CF-Connecting-IP headers and adds a health check + dashboard panel that confirm fronting and that REMOTE_ADDR agrees with CF-Connecting-IP. Adds wp upsun cloudflare purge — the edge invalidation the Upsun router cache never had — and registers the backend behind Upsun\purge_paths() so consumer code can invalidate without knowing which CDN is in front, with optional auto-purge of a post's URL on change, and an optional shared-secret origin guard (off by default) that rejects production requests bypassing Cloudflare. Inert where Cloudflare isn't fronting, so it's safe to leave enabled everywhere. |
security-headers |
Emits baseline security response headers on the front end — X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: SAMEORIGIN. These protect the HTML document, which on Upsun can't be covered from config.yaml (its web.locations headers only decorate static files; dynamic passthru responses get headers from the app). HSTS is handled deliberately: when the cloudflare module detects the request is proxied, the edge owns HSTS and this module defers (no duplicate header) — otherwise, on a direct-Upsun production site over HTTPS, it emits HSTS itself. Either way there's exactly one source, and Site Health + the dashboard say which. CSP is intentionally left to consumers (it's inherently per-site). Header set is filterable via upsun_security_headers. |
environment-indicator |
Color-coded admin-bar badge (branch · environment type) with an Upsun Console link, a dashboard widget with environment metadata, and a matching banner on the login screen. |
page-cache |
Emits Cache-Control: public, max-age=0, s-maxage={ttl} on anonymous, session-free page views so the Upsun router can cache them; optionally strips configured Set-Cookie headers (e.g. LMS guest sessions) to keep responses cacheable. Built-in bypass patterns cover core session cookies; commerce patterns come from the Integrations layer. wp upsun cache-check <url> (also a form in the dashboard Caching panel) explains any page's verdict: effective TTL, Set-Cookie spoilers, bypass-pattern matches, the route cookie allowlist (declared via upsun_cache_check_route_cache — Upsun does not expose it at runtime), and whether the fetch was a router HIT/MISS/BYPASS. |
updates-policy |
Disables the in-app auto-update machinery (the filesystem is read-only; Composer is the update path), replaces the auto-update toggles with a note, and removes the core Site Health tests that would fail by design. |
site-health |
Upsun-specific Site Health checks: object cache round-trip, cron configuration, writable mounts, preview search visibility, deploy migrations, live relationship health (MySQL ping, Redis INFO, HTTP/cluster status), disk usage, pending vendored/premium updates, and the active vendored-update fetchers; plus an "Upsun" section in the Info tab. |
preview-protection |
Sends X-Robots-Tag: noindex, nofollow and robots meta on non-production environments, without touching the blog_public option (the database is a production clone). |
smtp |
Points PHPMailer at the on-platform relay (PLATFORM_SMTP_HOST, port 25) unless a mailer plugin already configured SMTP. |
dashboard |
A top-level "Upsun" page in wp-admin (manage_options) styled like the WP Dashboard: panels are real meta boxes in the core dashboard grid — collapsible, draggable between columns, layout persisted per user. Panels: environment, services (credentials never rendered), health checks, resolved caching config, module status; plus operational actions (flush object cache). Extensible via upsun_dashboard_panels; deliberately actions-not-settings — configuration stays in code. |
cron-heartbeat |
Proves cron executes, not just that it is configured: schedules a recurring event that stamps a timestamp option, and reports staleness (plus overdue-event counts) through Site Health, the dashboard, and wp upsun doctor. |
mount-usage |
Disk and mount visibility: live disk total/free from the mount filesystem (warn at 80% used, fail at 95% — full mounts are a rude way to discover a quota), plus a per-mount size breakdown computed daily via WP-Cron (walking uploads is expensive) and shown with its age in a "Disk & mounts" dashboard panel and the shared checks. |
writable-paths |
Advises on the writable-path needs of known plugins: Integrations declare where plugins write, the check compares that against the mounts declared in PLATFORM_APPLICATION, and wp upsun mounts prints ready-to-paste mount YAML for anything missing. Advisory-only by design — on Upsun the fix is a mount, not a runtime path redirection. |
safe-previews |
Neuters live outbound integrations on preview clones, runtime-only (never DB writes): intercepts wp_mail (or redirects it) built-in; the WooCommerce integrations contribute Stripe test-mode forcing and webhook pausing through the same registry. Fresh clones and data syncs are detected via an environment stamp and sanitized by wp upsun sanitize --if-needed in the post_deploy hook (installation step 3), which runs the opt-in DB-writing sanitizers (anonymize user emails/passwords, deactivate listed plugins, scrub listed options — all disabled by default, enabled via filters) and fires upsun_preview_sanitize so consumers can scrub their own integrations; registries extensible via upsun_safe_previews_actions and upsun_preview_sanitizers. Adds a "Preview safety" health check and dashboard panel that warn when the hook wiring is missing. |
Integrations
Everything the plugin knows about one specific third-party plugin lives in a
dedicated class under src/Integrations/ — the single place to answer "what
does this plugin do about X?". Integrations contribute exclusively through
the same public filters consumers use (never privileged internal calls), so
every built-in integration doubles as proof the public API is sufficient.
They register at muplugins_loaded before regular plugins load; every
contribution is a dormant no-op when its target plugin is absent, and the
dashboard's Modules panel reports each integration's boot state plus whether
the target was detected.
| Integration | Target | Contributions |
|---|---|---|
woocommerce |
WooCommerce | Session/cart cookies as page-cache bypass patterns; cart/checkout/account pages as page-cache skips; webhook-delivery pause as a SafePreviews protection. |
woocommerce-stripe |
WooCommerce Stripe gateway | Test mode forced at option-read time on previews as a SafePreviews protection (cloned live keys stay untouched and unused). Plus a "Stripe keys" dashboard panel: in test mode, both test keys are probed against Stripe (cached 12h, keyed on the key value) and reported per key — valid / INVALID / missing / unverified — with a warning that checkout will fail when a key is dead or absent; test keys that Stripe rejects leave the gateway available and silently broken, which the admin otherwise never shows. Live mode is reported, never probed. |
wordfence |
Wordfence | Advisory: declares wp-content/wflogs as a writable-path requirement. |
updraftplus |
UpdraftPlus | Advisory: declares wp-content/updraft as a writable-path requirement. |
wp-rocket |
WP Rocket | Advisory: declares wp-content/cache and wp-content/wp-rocket-config; notes the advanced-cache.php root drop-in (not mountable — copy at build time). |
Toggles mirror modules: the upsun_integrations filter, or
UPSUN_DISABLE_INTEGRATION_{ID} constants (e.g.
UPSUN_DISABLE_INTEGRATION_WOOCOMMERCE, UPSUN_DISABLE_INTEGRATION_WP_ROCKET). To support a plugin the package doesn't know, use the public
filters directly from your own mu-plugin — that is exactly what the built-in
integrations do.