artetecha / upsun-wp
Must-use plugin integrating WordPress with the Upsun platform: environment awareness, router-cache friendliness, safe preview clones, deploy migrations, Site Health checks, and a wp upsun CLI command.
Package info
Type:wordpress-muplugin
pkg:composer/artetecha/upsun-wp
Requires
- php: >=8.1
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-07-31 14:54:11 UTC
README
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). |
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.
Configuration
Constants (wp-config friendly)
UPSUN_MU_DISABLE— kill switch for the whole plugin.UPSUN_DISABLE_CLOUDFLARE,UPSUN_DISABLE_SECURITY_HEADERS,UPSUN_DISABLE_ENVIRONMENT_INDICATOR,UPSUN_DISABLE_PAGE_CACHE,UPSUN_DISABLE_UPDATES_POLICY,UPSUN_DISABLE_SITE_HEALTH,UPSUN_DISABLE_PREVIEW_PROTECTION,UPSUN_DISABLE_SMTP,UPSUN_DISABLE_DASHBOARD,UPSUN_DISABLE_CRON_HEARTBEAT,UPSUN_DISABLE_SAFE_PREVIEWS,UPSUN_DISABLE_WRITABLE_PATHS,UPSUN_DISABLE_MOUNT_USAGE— per-module switches.UPSUN_DISABLE_INTEGRATION_WOOCOMMERCE,UPSUN_DISABLE_INTEGRATION_WOOCOMMERCE_STRIPE,UPSUN_DISABLE_INTEGRATION_WORDFENCE,UPSUN_DISABLE_INTEGRATION_UPDRAFTPLUS,UPSUN_DISABLE_INTEGRATION_WP_ROCKET— per-integration switches.UPSUN_DISABLE_FETCHER_THIMPRESS— turns off the built-in ThimPress vendored-update fetcher (it is already inert without thim-core).UPSUN_DISABLE_FETCHER_TRANSIENT— turns off the universal fallback fetcher. Note this disables generic vendored-update resolution entirely: with no fallback, only packages claimed by a specific fetcher can be resolved.UPSUN_MIGRATIONS_DIR— directory of deploy migrations (see below); unset = feature idle.UPSUN_MU_FORCE— boot modules and integrations off-platform (testing against fakedPLATFORM_*variables).
Defined by the plugin, readable by consumers: UPSUN_MU_PLUGIN_DIR (the plugin's own directory) and UPSUN_MU_PLUGIN_VERSION (also returned by Upsun\version()).
Filters
Module boot is deferred to muplugins_loaded priority 0, so any mu-plugin can register these regardless of load order.
Frozen at 1.0. These names change only through the deprecation policy. The seven renames and eight toggle replacements introduced in 0.7 had their shims removed at 1.0 — see Upgrading from 0.x if you are coming from 0.6 or earlier.
| Filter | Type | Default | Purpose |
|---|---|---|---|
upsun_modules |
array<string, class-string> |
all modules | Add/remove/replace modules. |
upsun_module_enabled |
bool, string $id |
true |
Toggle a single module by id (page-cache, smtp, …). The conditional counterpart to UPSUN_DISABLE_{MODULE}, which is read first and wins. |
upsun_integration_enabled |
bool, string $id |
true |
Toggle a single integration by id (woocommerce, wp-rocket, …). Counterpart to UPSUN_DISABLE_INTEGRATION_{ID}. |
upsun_fetcher_enabled |
bool, string $id |
true |
Toggle a built-in vendoring fetcher by id (thimpress, transient). Counterpart to UPSUN_DISABLE_FETCHER_{ID}. |
upsun_integrations |
array<string, class-string> |
all integrations | Add/remove/replace third-party plugin integrations. |
upsun_page_cache_ttl |
int |
600 |
Shared-cache TTL in seconds; <= 0 disables the header. |
upsun_cloudflare_ip_ranges |
string[] |
bundled CF v4+v6 CIDRs | Cloudflare ranges used by the raw-origin restoration path and the origin guard. Override to refresh the bundled list without a plugin release. |
upsun_cloudflare_origin_secret |
string |
'' (from CLOUDFLARE_ORIGIN_SECRET) |
Shared secret a CF Transform Rule injects on proxied requests. When set, production requests missing/mismatching it get a 403 (bypass guard). Empty = guard disabled. Read from an env var; never hard-code. |
upsun_cloudflare_origin_secret_header |
string |
'HTTP_X_ORIGIN_SECRET' |
The $_SERVER key carrying the origin secret (i.e. X-Origin-Secret). |
upsun_cloudflare_zone_id |
string |
'' (from CLOUDFLARE_ZONE_ID) |
Cloudflare zone id for purge calls. |
upsun_cloudflare_api_token |
string |
'' (from CLOUDFLARE_API_TOKEN) |
Cloudflare API token for purge calls — scope it to Zone → Cache Purge only. |
upsun_cloudflare_auto_purge |
bool |
false |
Purge a post's URL(s) from the Cloudflare edge when its cache is cleaned. |
upsun_cloudflare_post_purge_urls |
string[] |
[ permalink ] |
The URLs purged for a changed post when auto-purge is on. |
upsun_security_headers |
array<string,string> |
the baseline set (+ HSTS when applicable) | The response headers sent on the front end. Add keys (e.g. a Content-Security-Policy) or set one to '' to drop it. CR/LF in values is stripped. |
upsun_security_hsts |
bool |
production only | Whether this app emits HSTS itself. Only consulted when on HTTPS and not fronted by Cloudflare (when fronted, HSTS is deferred to the edge regardless). |
upsun_security_hsts_value |
string |
max-age=15552000 (180 days) |
The Strict-Transport-Security value. Lengthen max-age / add includeSubDomains/preload once you're sure. |
upsun_page_cache_bypass_cookie_patterns |
string[] |
WP/Woo/session regexes | Cookie names that mark a request personalised. |
upsun_page_cache_strip_cookies |
string[] |
[] |
Cookie-name prefixes whose Set-Cookie headers are stripped from anonymous responses. |
upsun_page_cache_skip |
bool |
false |
Skip cache headers for the current request (plugin-specific dynamic pages). |
upsun_page_cache_debug_headers |
bool |
false |
Emit X-Upsun-MU: page-cache on cacheable responses. |
upsun_updates_notice_text |
string |
"Updates are managed with Composer on Upsun." | The replacement auto-update copy. |
upsun_site_health_tests |
array |
built-in checks | Add/remove health checks (shared with wp upsun doctor). |
upsun_preview_noindex |
bool |
true |
Disable noindex on non-production (e.g. an indexable staging domain). |
upsun_configure_smtp |
bool |
true |
Keep the plugin away from PHPMailer (a mailer plugin owns SMTP). |
upsun_dashboard_panels |
array<string, {title, render, context?}> |
5 built-in panels | Add/remove dashboard panels. context (normal | side | column3 | column4, default normal) sets the initial column; users can drag panels anywhere and their layout persists. |
upsun_dashboard_menu_position |
int |
2 |
Admin-menu position. The default is pinned directly below Dashboard after all plugins register (several plugins squat the top slot, and core breaks the ties with a hash lottery); any other value is passed to add_menu_page unpinned. |
upsun_dashboard_menu_icon |
string |
Upsun mark (base64 SVG) | Menu icon: a data URI, image URL, or dashicon class. |
upsun_environment_indicator_login_banner |
bool |
true |
Hide the login-screen environment banner. |
upsun_cron_heartbeat_schedule |
string |
'hourly' |
WP-Cron schedule for the heartbeat event (staleness thresholds scale with it). |
upsun_safe_previews_mail |
string |
'intercept' |
Preview mail policy: intercept (log, never send), allow, or redirect:qa@example.com. Malformed values fail safe to intercept. Complements (does not replace) the platform's own "Outgoing emails" toggle: Upsun blocks its SMTP proxy on previews by default, but that toggle never reaches external SMTP/API mailer plugins configured in the cloned data — wp_mail interception covers those too. The Preview safety status reports both layers. |
upsun_woocommerce_stripe_test_mode |
bool |
true |
Stop forcing WooCommerce Stripe into test mode on previews. |
upsun_woocommerce_pause_webhooks |
bool |
true |
Stop pausing WooCommerce webhook deliveries on previews. |
upsun_safe_previews_actions |
array<string, {label, register, status}> |
3 built-in protections | Add protections for your own integrations (CRMs, other gateways) or remove built-ins. register runs at muplugins_loaded on previews; status at render time. |
upsun_safe_previews_boot_check |
bool |
false |
Fallback for projects that cannot edit their hooks: check the environment stamp on every boot and sanitize inline when it is stale. Prefer the post_deploy hook. |
upsun_cache_check_route_cache |
array{enabled, default_ttl, cookies, known} |
documented router defaults | Mirror your route's cache block from .upsun/config.yaml (set known: true) so wp upsun cache-check reports your real cookie allowlist — Upsun does not expose it at runtime. |
upsun_preview_sanitizers |
array<string, {label, enabled, run}> |
4 built-ins, all disabled | Add your own DB-writing sanitizers (idempotent, dry-run aware) or remove built-ins. They run inside the sanitize flow, before upsun_preview_sanitize. |
upsun_sanitize_anonymize_user_emails |
bool |
false |
Rewrite every user email to user-{ID}@upsun-preview.invalid on sanitize (one idempotent UPDATE; usernames keep working for login). |
upsun_sanitize_anonymize_user_passwords |
bool|string |
false |
true sets every password to password; a template like 'password-{ID}' gives per-user passwords (legacy-MD5 hashes, rehashed by WP on first login). Pair with Upsun's HTTP access control — known passwords on a reachable preview are a door. |
upsun_sanitize_preserved_emails |
string[] |
[] |
Users exempt from BOTH anonymizers: exact addresses or '@domain' suffixes. |
upsun_sanitize_deactivate_plugins |
string[] |
[] |
Plugin basenames deactivated on sanitize (empty = disabled). |
upsun_sanitize_scrub_options |
array<string, mixed> |
[] |
Options scrubbed on sanitize: option name (optionally with a dotted sub-key path like gateway_settings.live_secret_key) => replacement; null deletes/unsets. |
upsun_migrations_dir |
?string |
UPSUN_MIGRATIONS_DIR constant |
Directory of deploy migrations; null = feature idle. |
upsun_mount_usage_thresholds |
array{int, int} |
[80, 95] |
Used-percent thresholds for the disk-usage check (warn, fail). |
upsun_writable_paths_requirements |
array<string, {label, active, paths, note?}> |
contributed by Integrations | Declare where a plugin writes (paths relative to wp-content; active evaluated at check time). The check and wp upsun mounts do the rest. |
Actions
| Action | When it fires |
|---|---|
upsun_preview_sanitize (?string $previous, string $current) |
When wp upsun sanitize runs (typically --if-needed from the post_deploy hook after a clone or data sync, detected via the upsun_environment_stamp option), from the dashboard "Run sanitize actions now" button, or at boot if upsun_safe_previews_boot_check is enabled. Scrub or reconfigure site-specific integrations here; callbacks must be idempotent. |
Upgrading from 0.x
The API is frozen as of 1.0. Filters, constants, the action, the helper
functions, the extension interfaces, and the wp upsun subcommands with their
--format=json field names will not change without a deprecation cycle — see the
policy.
1.0 removed the shims 0.7 introduced. If you are coming from 0.6 or earlier, seven filters were renamed and eight per-module toggles were replaced; the old names worked throughout 0.7 and are now gone. Coming from 0.7 with no deprecation notices in your logs, there is nothing to do.
| Removed in 1.0 | Use instead |
|---|---|
upsun_mu_modules |
upsun_modules |
upsun_writable_path_requirements |
upsun_writable_paths_requirements |
upsun_disk_usage_thresholds |
upsun_mount_usage_thresholds |
upsun_login_banner |
upsun_environment_indicator_login_banner |
upsun_sanitize_anonymize_passwords |
upsun_sanitize_anonymize_user_passwords |
upsun_safe_previews_pause_webhooks |
upsun_woocommerce_pause_webhooks |
upsun_safe_previews_stripe_test_mode |
upsun_woocommerce_stripe_test_mode |
upsun_cloudflare_enabled, upsun_security_headers_enabled, upsun_environment_indicator_enabled, upsun_dashboard_enabled, upsun_cron_heartbeat_enabled, upsun_safe_previews_enabled, upsun_writable_paths_enabled, upsun_mount_usage_enabled |
upsun_module_enabled (bool $enabled, string $id) — covers all 13 modules |
Migrating a per-module toggle:
// Before add_filter( 'upsun_dashboard_enabled', '__return_false' ); // After add_filter( 'upsun_module_enabled', fn ( $enabled, $id ) => 'dashboard' !== $id && $enabled, 10, 2 );
Also gone since 0.6: upsun_cloudflare_restore_remote_addr, and the
REMOTE_ADDR rewriting it gated. The Upsun router resolves the real client IP
before PHP runs, so on this platform the filter's only reachable effect was
letting a forged CF-Connecting-IP override a correct value on a direct origin
hit. The cloudflare module verifies the client IP instead.
Scope
What this package commits to at 1.0, and what it deliberately leaves alone.
In scope. Environment awareness; router-cache friendliness; read-only
filesystem UX; safe preview clones; deploy migrations; Upsun-specific Site Health
and wp upsun doctor checks; the vendoring toolkit for premium packages; and
shared-cache invalidation through Upsun\purge_paths() — Cloudflare-backed
today, with upsun_purge_backends open for Fastly, Varnish, or the Upsun router
itself if it ever ships purging.
Out of scope, and why.
- Multisite. Delegated to
upsun/wp-ms-dbu. The domain-mapping and per-site-DB concerns are a project of their own, and nobody consuming this has asked. - Maintenance mode and an activity log. Both are site-policy features, not platform integration. A consumer can build either on the public filters.
- ElasticPress auto-wiring. Deferred until a consumer actually runs a search service; guessing at the mapping would age badly.
- Purging the Upsun router cache directly. No API exists. Pages expire by TTL
or on redeploy, and
purge_paths()says so plainly when nothing else fronts the site rather than pretending otherwise. - A wordpress.org listing. mu-plugins are not activatable and the loader-shim install step does not fit the plugin-directory model. Distribution is Composer-first, permanently.
- Anything site-specific. The line that has governed the package since 0.1: if it is true of one site only, it belongs in that site's own mu-plugin, wired through these filters.
Deploy migrations
Ordered, once-per-database changes that ship with your code. Point
UPSUN_MIGRATIONS_DIR at a directory of PHP files named
YYYYMMDD_NNNN_short_name.php, each returning a callable:
<?php // migrations/20260712_0001_enable_ip_sessions.php return static function () { update_option( 'learn_press_store_ip_customer_session', 'yes' ); };
Run wp upsun migrate from the deploy hook (before traffic): pending
migrations apply in filename order, each success is recorded in a
non-autoloaded option, and the first failure (throwable or return false)
exits non-zero so the deploy aborts. Completion markers live in the
database on purpose — a preview cloned from production carries them along
with the already-migrated data, so nothing re-runs. A shared health check
warns everywhere when migrations are pending and fails on misnamed files.
Vendoring premium plugins
A read-only filesystem plus DISALLOW_FILE_MODS means premium plugins and
themes can't self-update, so they're vendored as Composer path packages.
wp upsun vendor <slug> does the mechanical onboarding step for you: it reads
the installed plugin/theme header and writes a ready-to-commit package to
<to>/<slug>/ — a generated composer.json (name <vendor>/<slug>, type
wordpress-plugin/wordpress-theme, version/homepage/author from the
header, composer/installers required) alongside a copy of the source.
wp upsun vendor learnpress-stripe --to=private-packages/plugins --vendor=keds-plugin
# → private-packages/plugins/learnpress-stripe/{composer.json, …source}
Add the target as a Composer path repository and require the generated
name. This is a local/onboarding command (it writes files, so run it against
a writable checkout, not the read-only production runtime), and it works
off-platform.
Staying current is the other half: wp upsun vendor --check-updates reads
the update_plugins/update_themes transients and lists everything with a
pending update, flagging each as wporg (Composer/wpackagist handles it via
a version bump) or external (premium/vendored — Composer will not catch
it; you must re-vendor). The vendored_updates health check surfaces the
same, warning through Site Health / the dashboard / wp upsun doctor when
external updates are pending. Best-effort by nature: premium plugins that
suppress their own update check under DISALLOW_FILE_MODS won't appear.
And wp upsun vendor <slug> --update re-vendors the new version in place:
a fetcher resolves the authenticated download, then the package is
downloaded, extracted, and written to <to>/<slug>/ with its composer.json
merged over the upstream one — so runtime-load-bearing keys (like
fluentcampaign-pro's extra.wpfluent.namespace) survive, and the existing
package's vendor namespace is kept. --update-all does every resolvable
one; --dry-run previews.
wp upsun vendor learnpress-stripe --update --to=private-packages/plugins
Fetchers are the pluggable piece, and two ship built in. TransientFetcher
handles the whole class of standard licensed updaters — it reads the
authenticated package URL the updater already put in the transient.
ThimPressFetcher (0.6.0) resolves ThimPress packages — the Eduma theme,
thim-core, LearnPress add-ons — through thim-core's own catalog and license.
As with the integrations, a built-in fetcher is always shipped but
conditionally active: ThimPressFetcher no-ops (and dispatch falls
through to the transient fallback) on any site without thim-core, and can be
turned off with UPSUN_DISABLE_FETCHER_THIMPRESS. For another vendor,
register your own through the upsun_vendor_fetchers filter.
Credentials are never taken from env or config: a fetcher reads them from the site's own state (the transient, or a vendor's registration record in the DB), so the token never leaves the environment it already lives in.
wp upsun doctor, Site Health, and the dashboard report the active fetchers
in priority order (the vendor_fetchers check), so you can see which resolver
would handle a package and whether its backing source is detected. For CI,
--dry-run --format=json emits the pending re-vendor plans as a JSON array
({slug,type,from,to,fetcher}) — a stable contract; the resolved download URL
is never emitted (it carries the token). Because discovery needs the activated
DB and the writes need a writable filesystem, --update runs where both hold
(a local/CI checkout with the license, or the container writing to a mount);
raising a PR per package stays your CI's job.
Helper functions
Upsun\is_upsun(), Upsun\environment_name(), Upsun\environment_type(), Upsun\is_production(), Upsun\is_preview_environment(), Upsun\branch(), Upsun\project_id(), Upsun\application_name(), Upsun\primary_route(), Upsun\routes(), Upsun\relationship( string $name ), Upsun\version() — all safe to call off-platform.
Upsun\purge_paths( array $paths = array() ) — new in 1.0. Invalidate paths
from whatever shared cache fronts the site, without your code having to know
which one that is:
$result = Upsun\purge_paths( array( '/', '/news/' ) ); if ( ! $result['purged'] ) { error_log( 'Upsun purge: ' . $result['message'] ); }
Returns { purged: bool, backend: ?string, urls: string[], message: string }.
Paths resolve against the primary route; absolute URLs pass through; an empty
array means "everything the backend can invalidate". The cloudflare module
registers a backend when purge credentials are set, and upsun_purge_backends
takes others (first to return true wins, false passes the request on). With
nothing fronting the site it reports that plainly — the Upsun router cache has
no purge API, and this says so instead of returning a misleading success.
These functions are the plugin's only supported PHP entry point for
reading the environment. The Upsun\ classes behind them — including
Upsun\Environment — are marked @internal: they may change in any release.
Extension points are the filters above and the four interfaces (Upsun\Module,
Upsun\Integration, Upsun\Fetcher, Upsun\FetcherStatus); the reporting
helpers a consumer may legitimately call (ModuleRegistry::status(),
IntegrationRegistry::status(), Vendor::fetcher_status(), the shared
check() methods, and the high-level Vendor:: operations behind
wp upsun vendor) are the exceptions, and are not marked internal.
WP-CLI
wp upsun info # project / environment / branch / routes
wp upsun doctor # health checks; exits 1 on failure (deploy-hook friendly)
wp upsun relationships # service relationships (credentials never printed)
wp upsun relationships --health # live probes: MySQL ping, Redis INFO, HTTP/cluster status
wp upsun cache flush # object cache only — the router cache has no purge API
wp upsun cloudflare status # is Cloudflare fronting this env? purge creds set?
wp upsun cloudflare purge --all # purge the whole Cloudflare zone
wp upsun cloudflare purge --url=https://example.com/ # ...or specific URLs (repeatable)
wp upsun cache-check /some/page # why is/isn't this page router-cacheable?
wp upsun cache-check / --cookie="a=1" # ...and what do these request cookies change?
wp upsun cache-check / --auth=user:pass # for previews behind HTTP access control
wp upsun mounts # declared mounts + ready-to-paste YAML for missing ones
wp upsun migrate # apply pending deploy migrations; non-zero exit aborts the deploy
wp upsun migrate --dry-run
wp upsun sanitize # fire the preview sanitize actions (refuses on production)
wp upsun sanitize --if-needed # post_deploy-hook mode: stamp-aware, safe everywhere
wp upsun sanitize --dry-run
wp upsun sanitize --enable="anonymize-user-emails,anonymize-user-passwords:password-{ID}"
# force sanitizers for this run only (project-level policy
# when placed in the post_deploy hook); filters still work
wp upsun vendor <slug> # export an installed premium plugin/theme as a Composer package
wp upsun vendor eduma --type=theme --to=private-packages/themes --vendor=keds-theme
wp upsun vendor --check-updates # installed plugins/themes with a pending update (flags premium)
wp upsun vendor learnpress-stripe --update --to=private-packages/plugins # re-vendor the new version
# ThimPress packages (Eduma, thim-core, LP add-ons) resolve via the built-in fetcher
wp upsun vendor --update-all --to=private-packages/plugins --dry-run # preview all resolvable updates
wp upsun vendor learnpress-stripe --update --dry-run --format=json # machine-readable plan for CI (no token)
All commands except wp upsun cloudflare and wp upsun vendor print "Not running on Upsun." and exit 0 off-platform. cloudflare is host-agnostic (it talks to the Cloudflare API using CLOUDFLARE_* credentials); vendor is a local/onboarding tool that reads installed plugins/themes and writes a package to a writable target, so both also run from CI or a local shell.
Development
composer install
composer test # PHPUnit, no WordPress install required
The unit suite is standalone, with minimal WordPress function stubs.
Integration harness
A second suite runs against real WordPress and a real database: it builds a
throwaway consumer project the way
Installation describes,
installs WordPress, and asserts the off-platform no-op, on-platform module boot,
the deprecation notices core emits for the 0.7 renames (with WP_DEBUG on), the
UPSUN_MU_DISABLE kill switch, the wp upsun commands, and the response headers
the modules emit — over HTTP, through the PHP built-in server.
docker run --rm -d -p 3306:3306 -e MARIADB_ROOT_PASSWORD=root \
-e MARIADB_DATABASE=wp --name upsun-wp-db mariadb:11
bash tests/integration/run.sh
docker rm -f upsun-wp-db
WP_CORE selects the WordPress version (6.0.*, ^7.0, …) and KEEP=1 leaves
the install in place for inspection; see the header of
tests/integration/run.sh for the rest.
Supported versions
PHP 8.1–8.5 and WordPress 6.0+, both enforced in CI: the unit suite runs on every PHP version in that range, and the integration harness runs five curated PHP × WordPress corners including the 6.0/8.1 floor.
Reference and contributing
- docs/api-reference.md — the authoritative,
versioned reference for the whole public surface: every filter with its type
and default, the action, the constants, the helpers, the four extension
interfaces, and each
wp upsunsubcommand with its flags and--format=jsonfields. Generated from the source bybin/api-reference.php, with a test that fails if it drifts. The tables in this README are the introduction; that file is the contract. - CHANGELOG.md — what changed per release.
- CONTRIBUTING.md — how to add a module, an integration, or a fetcher, and the test conventions.
Security
Report vulnerabilities privately — see SECURITY.md for the process, the supported versions, and what is in scope.
docs/threat-model.md documents the four privileged
surfaces and the guards on each: the vendoring engine (it downloads code that
you then commit and execute — https on every redirect hop, zip-slip and expansion
caps, credentials only ever from site state, and the authenticated URL never
printed), the Cloudflare origin guard, the DB-writing sanitizers
(production-refusing and opt-in), and SMTP. It also states the risks
knowingly accepted — most importantly that any plugin loaded into WordPress can
read a secret passed through a filter, so prefer the environment-variable path
for CLOUDFLARE_API_TOKEN and friends.
Roadmap
See ROADMAP.md for the versioned plan. The v0.2 and v0.3
milestones shipped (the "Upsun" wp-admin dashboard, SafePreviews, integrations
architecture, wp upsun cache-check/migrate/mounts/relationships --health,
opt-in sanitizers, writable-path and mount-usage advisors), and the plugin was
extracted to this repository and published on Packagist. Latest: the
cloudflare module (0.4.x) — Cloudflare-fronting awareness (health check +
dashboard), edge cache purge, and an optional origin guard, for sites proxied by
Cloudflare in front of the Upsun router (the router already provides the real
client IP, so the module verifies rather than rewrites it), and the
security-headers module (0.4.2) — baseline response headers on the HTML
document (which config.yaml can't reach), with HSTS emitted directly or
deferred to Cloudflare when it fronts the request. The vendoring toolkit
(0.5.0) added wp upsun vendor — export, --check-updates, and programmatic
re-vendoring via --update through a pluggable Fetcher registry. Most recent
(0.6.0): a built-in ThimPressFetcher (conditionally active, like the
integrations) so Eduma/thim-core/LearnPress sites re-vendor with no custom
fetcher; a --dry-run --format=json resolve contract for CI (the token is
never emitted); and vendor_fetchers reporting in wp upsun doctor, Site
Health, and the dashboard. Router cache purge remains blocked on a platform
purge API — though the cloudflare module now purges the edge cache when
Cloudflare fronts the site.