mu-plugin is the
WordPress-side glue for the FrankenPress stack. Four request-path
components plus one off-request-path WP-CLI surface, all platform-
housekeeping — anything else (URL fixers, object cache, metrics, WC
log handlers) is optional and is the consumer site’s responsibility.
| Component | What it does |
|---|---|
| S3UploadsBootstrap | Configures humanmade/s3-uploads from FP_S3_* env vars. Refuses uploads when S3 isn’t fully configured (no silent local-disk fallback). |
| SouinInvalidator | DELs Souin’s Redis cache entries directly on save_post, clean_post_cache, switch_theme, etc. — bypasses cache-handler’s broken HTTP invalidation. |
| SiteHealth | Suppresses the Site Health tests whose failure is intentional under the immutable-image lockdown (background_updates, FS-write probes, plugin_theme_auto_updates) and adds a passing FrankenPress-branded test that explains why. Also adds an SMTP-reachability test when SMTPMailer is configured. |
| SMTPMailer | Wires the global PHPMailer to send via SMTP from FP_SMTP_* env vars. The runtime image ships no MTA, so without this every wp_mail() call fails silently. Transport-agnostic (Postmark, SendGrid, Mailgun, AWS SES, in-cluster relay). Opt-in: no-op when FP_SMTP_HOST is unset. |
| SnapshotExporter | Captures site state (fp.snapshot/v5) and uploads to a per-tenant S3 snapshot bucket on a daily wp-cron event at site-local midnight + an admin button under Tools → Snapshot Export. Drives the prod-side half of fp pull. Opt-in: no-op when FP_SNAPSHOT_BUCKET is unset. |
wp fp CLI (WP-CLI only) | Registers wp fp snapshot + wp fp apply subcommands for the designer flow. Adapter-scoped, WXR-based, additive. Schema fp.snapshot/v5. Apply-time AuthorRemapper rewrites imported post_author to the local admin (gated on FP_APPLY_REMAP_AUTHORS, set transiently by the apply pipeline). Only loads under WP_CLI — zero overhead on web requests. |
frankenpress/mu-plugin. Latest release: v0.15.1.
S3UploadsBootstrap
MapsFP_S3_* env vars to humanmade/s3-uploads’ S3_UPLOADS_* constants
and auto-loads the s3-uploads plugin from composer’s vendor / plugins
dir. In-cluster, refuses media uploads via wp_handle_upload_prefilter
when required env vars are missing. Out-of-cluster (no
KUBERNETES_SERVICE_HOST), the bootstrap auto-skips entirely and
WordPress writes to local disk — see FP_S3_DISABLED below.
Why refuse rather than fall back?
In a containerized deploy, local disk is ephemeral (gone on pod restart) and inconsistent across replicas (write to pod A, read from pod B → 404). Silently writing uploads to local disk would produce broken behaviour that surfaces minutes-to-hours later. A hard upload failure surfaces the misconfiguration immediately. Out-of-cluster (docker-compose, bare local) those concerns don’t apply: there’s one container, the disk persists for the lifetime of the volume, and admin install flows (block-plugin installers, theme zip uploads) need a real writable path because thes3:// stream
wrapper doesn’t support every ZipArchive / unzip_file operation.
The bootstrap auto-skips out-of-cluster for that reason. Force it
back on with FP_S3_DISABLED=0 to exercise the S3 path against MinIO.
Env vars
| Var | Default | Required |
|---|---|---|
FP_S3_BUCKET | — | ✓ |
FP_S3_KEY | — | ✓ |
FP_S3_SECRET | — | ✓ |
FP_S3_REGION | us-east-1 | |
FP_S3_BUCKET_URL | — | optional CDN URL — auto-sets WP_CONTENT_URL if undefined |
FP_S3_ENDPOINT | — | optional, for non-AWS S3-compatible (MinIO, R2, GCS XML) |
FP_S3_OBJECT_ACL | (empty — no ACL) | optional S3 object ACL. Leave unset for buckets with Object Ownership = “Bucket owner enforced” (the AWS default since April 2023, ACLs disabled). Set to public-read / private / authenticated-read only for ACL-enabled buckets. See troubleshooting → 0-byte uploads. |
FP_S3_DISABLED | auto: on in-cluster, off out-of-cluster | tri-state. Truthy (1/true/yes/on) → off. Falsy (0/false/no/off) → force on. Unset → default gates on KUBERNETES_SERVICE_HOST (kubelet-injected on every pod): production stays on, local dev skips the s3:// stream wrapper so admin install flows hit local disk. Force on locally with FP_S3_DISABLED=0. |
Endpoint filter (v0.1.1)
humanmade/s3-uploads documentsS3_UPLOADS_ENDPOINT but doesn’t apply
it to the AWS SDK by itself — you have to register a filter. v0.1.1 of
this plugin registers s3_uploads_s3_client_params automatically when
the endpoint constant is defined, with use_path_style_endpoint: true
(MinIO and most S3-compatibles don’t support virtual-host bucket
addressing).
SouinInvalidator
Connects directly to Redis andDELs Souin’s cache entries on
WordPress lifecycle events.
Hooks
The invalidator covers every WP write event whose output is templated into cached pages. Bounded changes invalidate the affected URL; changes with site-wide blast radius flush the entire HTTP cache. TTL expiry is the safety net for anything missed.| Category | Hooks | Action |
|---|---|---|
| Post lifecycle | save_post, wp_trash_post, before_delete_post, deleted_post, clean_post_cache | DEL post URL + tag + home |
| Global-impact post types | save_post for wp_navigation, wp_block, wp_template, wp_template_part, wp_global_styles | invalidate_all |
| Comments | comment_post, transition_comment_status | DEL parent post |
| Term lifecycle | created_term, edited_term, delete_term | invalidate_all |
| User lifecycle | profile_update, user_register, deleted_user | DEL author archive + home |
| Site-wide | switch_theme, permalink_structure_changed, wp_update_nav_menu, customize_save_after, activated_plugin, deactivated_plugin | invalidate_all |
| Options | updated_option for blogname, home, siteurl, permalink_structure, sidebars_widgets, widget_* | invalidate_all |
Env vars
| Var | Default | Purpose |
|---|---|---|
FP_SOUIN_REDIS_HOST | redis | Redis hostname |
FP_SOUIN_REDIS_PORT | 6379 | Redis port |
FP_SOUIN_REDIS_PASSWORD | (empty) | Redis AUTH password |
FP_SOUIN_REDIS_DB | 0 | Logical database |
FP_SOUIN_REDIS_TIMEOUT | 1.0 | Connect timeout (seconds) |
FP_SOUIN_DISABLED | false | Truthy → no-op the invalidator (cache then expires only by TTL) |
Why direct Redis DEL?
Souin’s documented HTTP invalidation APIs (PURGE, POST-CRUD, the
/api.souin/* admin endpoint) are broken in cache-handler v0.16.0:
PURGEreturns 200 but caches the PURGE response itself instead of invalidating the GET.POSTlikewise — caches the POST response.- The admin endpoint at
/api.souin/...returns 404 even when the API basepath is configured.
runtime/PHASE-0.md.
Failure mode
Ifext-redis isn’t loaded, or the connection fails, or
FP_SOUIN_DISABLED is set, the invalidator is a silent no-op.
Errors are logged via error_log but never raised — a broken cache
layer must not break WordPress itself.
SiteHealth
Tweaks WordPress’s Site Health screen so it reports signal, not noise, on a FrankenPress site. Removes the four lockdown-related tests (background_updates, update_temp_backup_writable,
available_updates_disk_space, plugin_theme_auto_updates) — their
failure is correct behaviour under the immutable-image lockdown — and
adds a passing FrankenPress-branded test that explains the lockdown
to anyone reading the dashboard.
When FP_SMTP_HOST is set (i.e. SMTPMailer has been opted into), this
component also registers a frankenpress_smtp_reachability Site Health
test that does a 2-second TCP connect to the configured SMTP server.
Failure produces a critical red badge on the wp-admin Site Health
screen — the canonical “is my email actually working” check.
SMTPMailer
ReadsFP_SMTP_* environment variables and hooks phpmailer_init to
override the global PHPMailer instance with SMTP. Transport-agnostic —
the same env contract works for Postmark, SendGrid, Mailgun, AWS SES,
or any in-cluster relay (mailpit, mailhog, postfix-relay).
For end-to-end recipes, see Operations → Email.
Env vars
| Var | Default | Purpose |
|---|---|---|
FP_SMTP_HOST | (unset) | SMTP hostname (e.g. smtp.postmarkapp.com). Component is a no-op when unset. |
FP_SMTP_PORT | 587 | TCP port |
FP_SMTP_ENCRYPTION | tls | tls (STARTTLS), ssl (implicit TLS), none (local dev only) |
FP_SMTP_USERNAME | (unset) | SMTP auth username |
FP_SMTP_PASSWORD | (unset) | SMTP auth password |
FP_SMTP_FROM_EMAIL | (WP admin_email) | wp_mail_from filter target |
FP_SMTP_FROM_NAME | (WP blogname) | wp_mail_from_name filter target |
FP_SMTP_DISABLED | false | Truthy → bootstrap is a no-op. Local-dev escape hatch only. The Helm chart never sets this; chart-side smtp.enabled: false covers the same need by simply not injecting the env. |
Failure mode
IfFP_SMTP_HOST is unset, the component is a silent no-op and
wp_mail() falls through to PHP’s mail() — which fails on the
runtime image because no MTA is shipped. That’s the intentional
default for sites that haven’t opted into SMTP yet.
When the host is set but unreachable / auth fails, wp_mail() returns
false and the failure is logged via error_log. The component
never retries, queues, or falls back to mail() — surfacing the
failure beats hiding it. The SiteHealth frankenpress_smtp_reachability
test surfaces the same failure on the Site Health dashboard.
Plugin coexistence
A site that composer-installs a competing SMTP plugin (WP Mail SMTP, FluentSMTP, the official Postmark plugin) overrides SMTPMailer’s config — those plugins run as regular plugins after must-use plugins and re-hookphpmailer_init at the same priority. Last-writer wins.
Correct behaviour: the user opted into the plugin explicitly; we don’t
fight them.
SnapshotExporter
Drives the prod-side half offp pull.
Two entry points, one code path:
- Daily wp-cron event (
frankenpress_snapshot_export) fires at site-local midnight (wp_timezone()). Picked up by the K8s wp-cron CronJob’swp cron event run --due-nowinvocation, so capture runs in a WP-CLI process where\WP_CLI::runcommandis available (the innerwp exportneeds it). - Admin button under Tools → Snapshot Export schedules a one-shot event on the same hook. Shows “queued” — the actual capture runs in the next wp-cron tick (~60s).
wp fp snapshot machinery via a shared
Cli\Snapshot\Factory. Upload uses Aws\S3\S3Client::putObject per
file — the AWS SDK is already a transitive dep through humanmade/
s3-uploads, no composer change needed.
Required env vars
Wire format + cadence
Samefp.snapshot/v5 envelope used by wp fp snapshot. PII allowlist
unchanged: posts/pages/attachments + FSE templates + allowlisted
options. No wp_users / wp_comments / wp_usermeta. Captured
attachments are wp_posts rows only — designers consume the actual
binaries from the production uploads bucket directly via
humanmade/s3-uploads.
The bucket is provisioned by tg_frankenpress (one per production
site, named <site>-production-snapshots-<region>-<account>,
7-day lifecycle). The service-user IAM policy is scoped to
s3:PutObject + s3:ListBucket only — no read, no delete. Worst
case under a pod compromise is junk in the bucket that the lifecycle
reaps.
The chart’s snapshotExport.* values map directly to these env vars
(/components/charts).
wp fp CLI
Off the request path — loads only underWP_CLI. Zero overhead on
web requests. Provides the two subcommands the designer
flow relies on:
Fse
adapter detects when the active theme is an FSE block theme
(wp_is_block_theme()) and declares scope:
- Owned post types (templates.json, upsert by slug):
wp_template,wp_template_part,wp_global_styles,wp_navigation - Option keys (options.json, upsert):
blogname,blogdescription,show_on_front,page_on_front,page_for_posts,permalink_structure,site_icon,site_logo,custom_logo - Attachment refs (attachments.json + binaries): attachments
referenced by the logo options OR by inline
wp:imageblock markup inside captured owned posts (e.g. an image block with"id":42referencing an attachment). Other uploads are skipped — they’re treated as content. - Theme mods: for the active stylesheet only
post_name + post_type so designer iteration on existing
templates propagates. Attachments upsert by _wp_attached_file
(the relative path under uploads/, which is stable across
environments). Options upsert by key. The captured-ID → local-ID
remap rewrites site_logo values + inline block-attr "id":N
references + wp-image-N CSS classes during apply so attachment
references land pointing at the right local post.
Binaries ship in the snapshot. Designer-asset attachments
travel as bundled files in snapshot/uploads/<rel-path>/. At
apply time, files are copied into wp_get_upload_dir()['basedir']
— with S3UploadsBootstrap active that’s an s3:// stream
wrapper write so files land directly in S3.
Idempotent. Apply stamps fp_snapshot_applied_ref and
fp_snapshot_applied_sha256 options on success; subsequent
invocations with the same snapshot short-circuit (Success: apply skipped).
Schema: fp.snapshot/v4. Earlier v2 / v3 snapshots are
rejected by the Restorer — re-capture under a v0.11.0+ install
to migrate.
For the full workflow (Site Editor → snapshot → commit → deploy), see
Designer flow.
Install
site-template ships this dep
preconfigured. The plugin lands at
web/app/mu-plugins/mu-plugin/ and is auto-loaded by
roots/bedrock-autoloader
via the 00-stack.php loader.