WordPress, running like it's 2026

Make WordPress a
platform-native citizen
on Upsun.

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.

  • v1.0.0
  • MIT
  • PHP 8.1+
  • WordPress 6.0+
  • Composer-first
  • Zero runtime deps
shell
$ composer require artetecha/upsun-wp
[email protected] — feature/checkout

Real subcommands. Real checks. See the CLI →

Why bother

Retire the WordPress workflow you inherited.

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.

The old way
The Upsun way
Deploys
FTP uploads and cowboy edits on the live box. “Did anyone back up first?”
git push → build → deploy. Immutable, repeatable, one-command rollback.
Updates
One-click plugin updates in wp-admin, straight into production, no record of what changed.
Composer owns every version in a lockfile. The filesystem is read-only — the plugin disables the in-app updaters and removes the Site Health tests that would fail by design.
Staging
A hand-built staging site that drifted from production months ago.
Every branch becomes a full environment — code, database, files — cloned from production in minutes.
Clone safety
Staging quietly emails real customers and talks to live payment APIs.
SafePreviews neuters live outbound integrations at boot: mail intercepted, Stripe forced to test mode, webhooks paused.
Config
Credentials hardcoded per server in wp-config.php, copied around by hand.
Config comes from the environment. The plugin reads PLATFORM_* directly and never defines constants — your wp-config.php stays the single owner.
Caching
A zoo of page-cache plugins fighting each other with drop-ins.
Honest Cache-Control headers drive the platform router’s cache. wp upsun cache-check explains any page’s verdict.
DB changes
“Run this SQL on prod after you deploy” — a sticky note as release engineering.
Ordered, once-per-database deploy migrations via wp upsun migrate. A failure aborts the deploy before traffic switches.

Design principles

Boring rules, applied ruthlessly.

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.

02 No-op everywhere else

Off-platform — local, CI — the plugin loads and does nothing. Malformed platform data degrades silently, never fatals.

03 wp-config wins

The plugin never defines configuration constants. It reads env and constants, and fills runtime-behavior gaps only.

04 Honest about the platform

No feature that pretends an API exists. Limitations are documented instead of papered over — see the router-cache purge story in the roadmap.

05 Every module tested twice

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

Preview environments are perfect clones.
That’s the feature — and the hazard.

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:

  • Mail interceptionwp_mail is intercepted (or redirected to an inbox you choose) on every preview request.
  • Stripe test mode, forced — at option-read time. Cloned live keys stay untouched and unused; missing test keys fail safe.
  • Webhook delivery paused — WooCommerce stops notifying external systems from clones.
  • Your integrations too — the same protection registry is open via upsun_safe_previews_actions, and one-time DB scrubbing hooks into upsun_preview_sanitize.
.upsun/config.yaml
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.

production writes stamp
preview sees foreign stamp → sanitizes once

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

Everything WordPress needs to know about its platform.

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

Always know where you are

Color-coded admin-bar badge (branch · environment type) with a Console link, a dashboard widget, and a matching login-screen banner.

page-cache

Router-cache friendliness

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

Cloudflare-fronting, done right

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

Headers on the page config can’t reach

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

Clones that can’t hurt anyone

Mail interception, payment test mode, webhook pausing — live integrations neutered at boot on every preview. The full story ↑

updates-policy

Read-only, explained

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

Checks that understand Upsun

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

A platform home in wp-admin

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

Proof cron actually runs

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

Invisible to search engines

X-Robots-Tag: noindex, nofollow and robots meta on non-production — without touching blog_public, because that database is a production clone.

smtp

Mail that just works

Points PHPMailer at the on-platform relay (PLATFORM_SMTP_HOST) unless a mailer plugin already configured SMTP.

writable-paths

Mounts, advised

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

See the disk before it’s full

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

An ops toolbox inside WP-CLI.

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.

shell
$ 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

Integrations, sanitizers, migrations, vendoring.

Integrations

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.

  • WooCommerce
  • WooCommerce Stripe
  • Wordfence
  • UpdraftPlus
  • WP Rocket

Cart-cookie cache bypasses, checkout cache skips, Stripe test-mode forcing, webhook pausing, writable-path declarations.

Sanitizers

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-emailsuser-{ID}@upsun-preview.invalid
  • anonymize-user-passwords — one password, or a {ID} template
  • deactivate-plugins — your list, previews only
  • scrub-options — options or dotted sub-keys, e.g. gateway.live_secret_key

Deploy migrations

Every serious WP-on-Upsun project reinvents this — so it’s in the box. Drop ordered files in a directory, each returning a callable:

migrations/
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.

Vendoring premium plugins

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 package
  • vendor --check-updates — pending updates, flagged wporg vs external
  • vendor <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

Three steps. No step four.

For any Composer-managed WordPress, straight from Packagist (artetecha/upsun-wp).

  1. 1 Require the package

    composer.json
    {
      "require": {
        "artetecha/upsun-wp": "^0.6"
      },
      "extra": {
        "installer-paths": {
          "composer-mu-plugins/{$name}": ["artetecha/upsun-wp"]
        }
      }
    }
  2. 2 Copy it into the build

    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:

    composer.json → scripts
    "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.

  3. 3 Wire the post_deploy hook

    One line makes every clone and data re-sync detectable and sanitized. Safe on every environment.

    .upsun/config.yaml
    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

Don’t have a Composer-managed WordPress yet?
Start from one.

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 preconfigured
  • Env-driven wp-config.php — credentials, URLs, and salts from the platform
  • Composer-managed core and plugins with installer paths set up
  • This mu-plugin preinstalled, loader shim and post_deploy hook already wired

Use the template on GitHub

the whole onboarding
$ composer create-project artetecha/wordpress-upsun-starter my-site
$ cd my-site
$ upsun project:create
$ upsun push
 
✔ WordPress deployed — previews, caching, and safe clones included.

Roadmap

Shipped, planned, and honestly blocked.

Shipped

  • 0.1 — core modules: environment indicator, page cache, updates policy, Site Health, preview protection, SMTP
  • 0.2.0 — the “Upsun” wp-admin dashboard
  • 0.2.1 — cron heartbeat + login-screen banner
  • 0.2.2 — SafePreviews + wp upsun sanitize
  • 0.2.4wp upsun cache-check
  • 0.3.0 — integrations architecture
  • 0.3.1 — writable-path advisor + wp upsun mounts
  • 0.3.2 — opt-in DB sanitizers
  • 0.3.3 — deploy migrations, wp upsun migrate
  • 0.3.4 — relationship health (wp upsun relationships --health) + mount-usage module. v0.3 complete.
  • 0.3.5 — extraction complete: this repo + Packagist, the starter template live, and a first-deploy fix caught by it
  • 0.4.0cloudflare module: edge cache purge (wp upsun cloudflare purge) and an optional origin guard
  • 0.4.1cloudflare 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_ADDR
  • 0.4.2security-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 request
  • 0.5.0 — premium plugin vendoring toolkit: wp 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.
  • 0.6.0 — built-in 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
  • 0.7.0 — the road to 1.0, and no new features on purpose. Every public filter, constant, command and interface was audited and given a freeze verdict; the internals stopped pretending to be API (@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.0
  • 0.8.0 — a security review of the privileged surfaces, and the documentation a 1.0 promise needs. The vendoring engine downloads code you then commit and execute, so it got the hard look: an https 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 up
  • 1.0.0the API is frozen. Every filter, constant, command and interface now changes only through a published deprecation policy: deprecate in a minor, remove in the next major. The 0.7 rename shims are gone, and Upsun\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 alone

Planned

  • Post-1.0, features land on demand and behind the deprecation policy. The scope statement says what is deliberately out — multisite, maintenance mode, activity log, ElasticPress auto-wiring — and why
  • Elasticsearch / OpenSearch → ElasticPress auto-wiring — deferred until a consumer runs a search service

Blocked — and saying so

  • Router cache purge. The Upsun router exposes no purge API, so this plugin doesn’t pretend one exists. Partially unblocked in 0.4.0: when Cloudflare fronts the site, the cloudflare 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.
  • Multisite — delegated to upsun/wp-ms-dbu until there’s demand.