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.

Maintainers

Package info

github.com/artetecha/upsun-wp

Homepage

Type:wordpress-muplugin

pkg:composer/artetecha/upsun-wp

Transparency log

Statistics

Installs: 282

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-07-31 14:53 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 runsdeploy 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 faked PLATFORM_* 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 upsun subcommand with its flags and --format=json fields. Generated from the source by bin/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.