01 Generic first
A feature lands here only if it helps any WordPress site on Upsun. Site-specific behavior belongs in your project, via public filters.
WordPress, running like it's 2026
A single must-use plugin that teaches WordPress everything about the platform it runs on:
environment awareness, router-cache friendliness, read-only-filesystem UX, Site Health
checks that understand Upsun, and a wp upsun CLI command.
Off-platform it fully no-ops — local dev and CI need no special-casing.
$ composer require artetecha/upsun-wp
Real subcommands. Real checks. See the CLI →
Why bother
Traditional WordPress operations were designed for a single mutable server. Upsun gives you immutable builds, instant production clones, and config from the environment — this plugin closes the gap so WordPress actually behaves that way.
git push → build → deploy. Immutable, repeatable, one-command rollback.wp-config.php, copied around by hand.PLATFORM_* directly and never defines constants — your wp-config.php stays the single owner.Cache-Control headers drive the platform router’s cache. wp upsun cache-check explains any page’s verdict.wp upsun migrate. A failure aborts the deploy before traffic switches.Design principles
A feature lands here only if it helps any WordPress site on Upsun. Site-specific behavior belongs in your project, via public filters.
Off-platform — local, CI — the plugin loads and does nothing. Malformed platform data degrades silently, never fatals.
The plugin never defines configuration constants. It reads env and constants, and fills runtime-behavior gaps only.
No feature that pretends an API exists. Limitations are documented instead of papered over — see the router-cache purge story in the roadmap.
PHPUnit against WP stubs — no WordPress install required — plus an integration harness that boots real WordPress against a real database and asserts the headers over HTTP. CI runs both across PHP 8.1→8.5 and WordPress 6.0 to 7.x.
Flagship module
Upsun previews are byte-for-byte clones of production — including live payment keys, webhook URLs, and CRM credentials. Click around a naive clone and it will happily email real customers and notify real fulfillment systems.
SafePreviews neuters live outbound integrations on every preview, at runtime, from boot — no DB writes, nothing to undo:
wp_mail is intercepted (or redirected to an inbox you choose) on every preview request.upsun_safe_previews_actions, and one-time DB scrubbing hooks into upsun_preview_sanitize.hooks:
post_deploy: |
wp upsun sanitize --if-needed
One line catches every clone and every data re-sync — post_deploy
is the only hook that runs on both. Fresh clones are detected via an environment stamp;
already-sanitized previews no-op; production just refreshes the stamp.
Opt-in DB sanitizers (all disabled by default): anonymize user emails, anonymize passwords, deactivate listed plugins, scrub listed options — declared once, in versioned config, applied identically to every child environment.
Thirteen modules
Each module is independently switchable — a UPSUN_DISABLE_* constant in
wp-config.php or the upsun_modules filter — and ships with
its own unit tests.
environment-indicator
Color-coded admin-bar badge (branch · environment type) with a Console link, a dashboard widget, and a matching login-screen banner.
page-cache
Emits Cache-Control: public, s-maxage={ttl} on anonymous, session-free views so the router can cache them; optionally strips cookie spoilers. Commerce bypass patterns come from the integrations layer.
cloudflare
The Upsun router already hands your app the real client IP, so this doesn’t meddle with REMOTE_ADDR — it detects fronting from Cloudflare’s own headers and flags any mismatch in a health check. Adds the edge cache purge the router cache never had — from the CLI (wp upsun cloudflare purge) or from code through the backend-agnostic Upsun\purge_paths() — plus an optional origin guard. Inert where Cloudflare isn’t in front.
security-headers
Upsun’s web.locations headers only decorate static files — the HTML document takes its headers from the app. This module emits nosniff, Referrer-Policy and X-Frame-Options where they matter. HSTS is set once: emitted directly on a direct-Upsun site, or deferred to Cloudflare when it’s fronting — and it tells you which.
safe-previews
Mail interception, payment test mode, webhook pausing — live integrations neutered at boot on every preview. The full story ↑
updates-policy
Disables the in-app auto-update machinery (Composer is the update path), replaces the toggles with a note, and removes core Site Health tests that would fail by design.
site-health
Object-cache round-trip, cron configuration, writable mounts, preview search visibility — in Site Health, plus an “Upsun” section in the Info tab. Same registry powers wp upsun doctor.
dashboard
Top-level “Upsun” page built from real core meta boxes — collapsible, draggable, per-user layout. Environment, services, health, caching, module status. Actions, not settings: configuration stays in code.
cron-heartbeat
A recurring event stamps a timestamp; staleness (2× schedule warns, 4× fails) and overdue-event counts surface in Site Health, the dashboard, and wp upsun doctor.
preview-protection
X-Robots-Tag: noindex, nofollow and robots meta on non-production — without touching blog_public, because that database is a production clone.
smtp
Points PHPMailer at the on-platform relay (PLATFORM_SMTP_HOST) unless a mailer plugin already configured SMTP.
writable-paths
Integrations declare where plugins write; the check compares that against your declared mounts, and wp upsun mounts prints ready-to-paste YAML for anything missing. Advisory by design — on Upsun the fix is a mount.
mount-usage
Live disk total/free from the mount filesystem — warns at 80% used, fails at 95%, because a full mount is a rude way to discover a quota. A per-mount size breakdown, computed daily, lands in a “Disk & mounts” dashboard panel.
wp upsun
Deploy-hook friendly: meaningful exit codes, machine-readable
--format=json|yaml|csv, and every command prints
“Not running on Upsun.” and exits 0 off-platform.
$ wp upsun cache-check /shop/ --cookie="tracking=1"
Verdict for /shop/:
cacheable — s-maxage=600
Set-Cookie spoilers ..... none
Bypass patterns ......... no match
Router fetch ............ HIT (age 214s)
wp upsun info
Project, environment, branch, and routes at a glance.
wp upsun doctor
Runs every health check; exits non-zero on failure — wire it into your deploy hook as a gate.
wp upsun relationships
Services this app can talk to — credentials never printed. --health probes each one live: MySQL ping, Redis INFO, HTTP/cluster status.
wp upsun cache flush
Flushes the object cache — and is honest that the router cache has no purge API.
wp upsun cache-check <url>
Answers the #1 WordPress-on-Upsun support question — “why isn’t my page cached?” — with an itemized verdict.
wp upsun mounts
Declared mounts, plus ready-to-paste YAML for every writable path your plugins need but don’t have.
wp upsun migrate
Applies pending deploy migrations in order, once per database. --dry-run to preview; a failure aborts the deploy.
wp upsun sanitize
Fires preview sanitization (refuses on production). --if-needed is stamp-aware and safe on every environment.
wp upsun cloudflare
Is Cloudflare fronting this env? purge the edge cache by zone or URL. Host-agnostic — runs anywhere the CLOUDFLARE_* credentials are set.
wp upsun vendor <slug>
Exports a premium plugin/theme as a Composer package. --check-updates flags pending premium updates; --update re-vendors the new version in place.
Plays well with others
Everything the plugin knows about a specific third-party plugin lives in one class — and contributes exclusively through the same public filters you can use. Every built-in integration is proof the public API is sufficient. Dormant no-ops when the target plugin is absent.
Cart-cookie cache bypasses, checkout cache skips, Stripe test-mode forcing, webhook pausing, writable-path declarations.
Opt-in, DB-writing, idempotent — all disabled by default. Enable them
per-project in the post_deploy hook so the policy is versioned and applied
identically to every child environment.
anonymize-user-emails → user-{ID}@upsun-preview.invalidanonymize-user-passwords — one password, or a {ID} templatedeactivate-plugins — your list, previews onlyscrub-options — options or dotted sub-keys, e.g. gateway.live_secret_keyEvery serious WP-on-Upsun project reinvents this — so it’s in the box. Drop ordered files in a directory, each returning a callable:
20260701_0001_switch_smtp_plugin.php
20260708_0001_seed_new_terms.php
Run by wp upsun migrate in the deploy hook. Completion markers live in the
database, so clones don’t re-run them — and the first failure exits non-zero and aborts
the deploy before traffic switches.
A read-only filesystem means premium plugins can’t self-update — so they’re vendored as
Composer path packages. wp upsun vendor <slug> does the mechanical
onboarding: it reads the installed header and writes a ready-to-commit package with a
generated composer.json alongside the source.
vendor <slug> — export an installed plugin/theme as a packagevendor --check-updates — pending updates, flagged wporg vs externalvendor <slug> --update — re-vendor the new version in place
--update uses a pluggable fetcher registry to download,
extract, and re-vendor — merging over the upstream composer.json. Credentials
come from the site’s own state, never from env or config.
New in 0.6.0: a built-in ThimPressFetcher (Eduma, thim-core,
LearnPress add-ons) — conditionally active like the integrations, off without thim-core;
a --dry-run --format=json resolve contract for CI (the token is never emitted);
and a vendor_fetchers report in wp upsun doctor, Site Health, and
the dashboard.
Get started
For any Composer-managed WordPress, straight from
Packagist
(artetecha/upsun-wp).
{
"require": {
"artetecha/upsun-wp": "^0.6"
},
"extra": {
"installer-paths": {
"composer-mu-plugins/{$name}": ["artetecha/upsun-wp"]
}
}
}
The package stages outside wordpress/ because the core extraction owns that tree (and Composer installs artetecha/* first); copy it in — with the loader shim WordPress needs at the mu-plugins root — at build time:
"post-install-cmd": [
"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"
]
Content directory outside the core install dir (Bedrock-style)? The plain type:wordpress-muplugin route works as-is — see the README.
One line makes every clone and data re-sync detectable and sanitized. Safe on every environment.
hooks:
post_deploy: |
wp upsun sanitize --if-needed
That’s it. On Upsun, thirteen modules light up. On your laptop and in CI, the plugin loads and does nothing — zero special-casing, zero config files to fake.
Companion repo
wordpress-upsun-starter is a complete WordPress-on-Upsun project skeleton with everything on this page already wired. Create a project from it, push, and you have a running WordPress with previews, caching, health checks, and safe clones from commit one.
.upsun/config.yaml — app, services, mounts, and hooks preconfiguredwp-config.php — credentials, URLs, and salts from the platformpost_deploy hook already wiredRoadmap
wp upsun sanitizewp upsun cache-checkwp upsun mountswp upsun migratewp upsun relationships --health) + mount-usage module. v0.3 complete.cloudflare module: edge cache purge (wp upsun cloudflare purge) and an optional origin guardcloudflare fronting detection reworked for Upsun: the router already provides the real client IP, so detect via Cloudflare’s headers and verify rather than rewrite REMOTE_ADDRsecurity-headers module: baseline headers on the HTML document (which config.yaml can’t reach), HSTS emitted directly or deferred to Cloudflare when it fronts the requestwp upsun vendor exports an installed plugin/theme as a Composer package, --check-updates flags premium updates Composer won’t catch, and --update re-vendors the new version via a pluggable Fetcher registry (built-in TransientFetcher), merging over the upstream composer.json. Credentials from site state, never env.ThimPressFetcher (Eduma, thim-core, LearnPress add-ons), conditionally active like the integrations; a --dry-run --format=json resolve contract for CI (the download token is never emitted); and a vendor_fetchers report in wp upsun doctor, Site Health, and the dashboard@internal); a WordPress integration harness landed alongside the unit suite, with CI across PHP 8.1→8.5 and WordPress 6.0 to 7.x; and the last pre-1.0 window was used to fix the names that were wrong — six filters renamed, eight per-module toggles replaced by one upsun_module_enabled that covers all modules instead of eight. Nothing breaks: every old name still works and warns until 1.0https package download could be redirected to http and silently downgraded — fixed by re-checking the scheme at every redirect hop — plus bounds on download and expansion size, and a private work directory. A written threat model states the guards and the risks knowingly accepted, and an API reference generated from the source — with a test that fails if it drifts — replaces trusting the docs to keep upUpsun\purge_paths() ships — invalidate paths from whatever shared cache fronts the site without your code knowing which one, Cloudflare-backed today and pluggable for the rest. The boundary this package has always claimed is now proven by a second production site consuming it through the public API alonecloudflare module purges the edge cache.
As far as this can be solved, 1.0 solves it: Upsun\purge_paths()
dispatches to pluggable backends — Cloudflare's edge today, others through
upsun_purge_backends — and reports plainly when nothing fronts the site
rather than pretending to have worked.
upsun/wp-ms-dbu until there’s demand.