WordPress performance and hardening on Nix
What the platform mu-plugins do for speed and security on SQLite-backed WordPress sites, and how to audit a MySQL dump before you restore it.
On this page
The wordpress-nix platform ships a set of must-use plugins (mu-plugins) that tune the database, harden the public surface, and cooperate with an edge page cache, so a site needs no plugin for any of it. Use it alongside WordPress on FrankenPHP with wordpress-nix. Read it when you want to know what those files do, what they assume, and how to vet a MySQL dump from a host you do not control before you migrate it.
How the platform plugins are delivered
The files live in mu-plugins/ in the platform repository and are named platform-*.php. On every deploy the platform copies the current platform-*.php files into the site's wp-content/mu-plugins/, replacing the old copies. Anything else in that directory is left alone. Git mode is the exception. It serves a read-only tree that you provide, so build that tree with lib.mkSiteDocroot, which includes the platform mu-plugins (see WordPress on FrankenPHP with wordpress-nix).
That has one practical consequence: do not edit a platform-*.php file inside a site. Your change is overwritten on the next deploy. If you need different behavior, change it in the platform and let every site pick it up through a flake input bump.
Performance keys for SQLite engines
platform-performance-keys.php adds composite indexes to the core meta and posts tables when a site runs on one of the platform's SQLite-backed engines (a local file, Turso, or D1). It is the SQLite counterpart of what the Index WP MySQL For Speed plugin does on MySQL.
WordPress ships single-column keys on the meta tables: meta_key alone and the object id alone. A query that joins on both, such as a meta_query on the front page, has to scan one side. A composite key fixes that. The platform adds these:
| Table | Key name | Columns |
|---|---|---|
postmeta | meta_key_post_id | meta_key(191), post_id |
usermeta | meta_key_user_id | meta_key(191), user_id |
termmeta | meta_key_term_id | meta_key(191), term_id |
commentmeta | meta_key_comment_id | meta_key(191), comment_id |
posts | post_parent_type_status | post_parent, post_type, post_status |
posts | author_type_status_date | post_author, post_type, post_status, post_date |
The repository records one measurement: a front-page meta_query on the first migrated site dropped from 600 ms to under 80 ms once the postmeta key was in place. Treat that as one query on one site, not a promise. The platform's own notes quote two figures for it (37 ms in the source comment, 77 ms in the README), so the safe reading is "from 600 ms to well under 100 ms".
The plugin deliberately does not copy the MySQL plugin's central trick of making the composite key the clustered primary key. SQLite clusters a table by rowid, so there is no equivalent, and those rewritten primary keys are exactly what restore-core-keys has to undo during a migration.
How and when the keys are added
The keys are created with
ALTER TABLE ... ADD KEYthrough the database driver, never with a rawCREATE INDEX. That way the driver's emulated information schema knows about them and a later table rebuild keeps them.It runs on the admin plane only (
admin_init, and during cron), because the public plane never writes.It records the applied version in the
platform_performance_keysoption, so it does the work once. A key that already exists is skipped, and a "Duplicate key name" answer from the primary counts as success.If any key fails, the plugin writes a line to the PHP error log and leaves the option unset, so it tries again on the next admin request.
Note
The feature switches itself off on MySQL, and on any SQLite driver that cannot add an index in place. In practice that means wordpress-sqlite-anywhere 1.2 or later. On an older driver, ALTER TABLE rebuilds the whole table, which on Turso means copying wp_postmeta through a very slow insert path.
To confirm it ran, check the option from the service user:
sudo -u wordpress wp option get platform_performance_keysIt reads 1 once the keys are applied. This document does not verify the output of a live SHOW INDEX against a running site, so use that as a second check if you need one.
The hardening mu-plugins
Each file does one job. These are the ones that matter for security and request handling.
| File | What it does |
|---|---|
platform-user-enumeration.php | Stops anonymous visitors from discovering usernames. Details in the next section. |
platform-public-plane.php | On the public half of a split-plane site, refuses to authenticate any user who can edit_posts, disables XML-RPC, and hides the admin bar. |
platform-admin-plane.php | On the admin half, moves post preview links from the public host back onto the admin host so editors can actually preview. |
platform-environment.php | Drops listed plugins from active_plugins at load time, without touching the database. Used for production-only plugins in a dev shell, and for plane-specific plugins. |
platform-nonce.php | Raises the nonce_life filter to 60 days. |
platform-cloudflare-page-cache.php | Signals cacheability to the edge cache and purges it on content changes. See the page cache section. |
platform-gitium.php | Activates the platform's own copy of gitium by filter, so the site repository never records it as active. |
Warning
platform-nonce.php stretches WordPress nonces far beyond their default lifetime of one day. Nonces are a defense against cross-site request forgery, so a longer life widens the window in which a captured one stays valid. Know that you are carrying this trade before you rely on it, and change the platform file if your threat model needs the default back.
Page cache and the two planes
A site can run as two instances on one shared database: a public plane on the real hostname that never serves an administration surface, and a private admin plane behind an identity proxy. The page cache sits in front of the public plane.
The edge cache
The platform's Cloudflare Worker caches anonymous HTML pages, so a hit never reaches PHP or the database. The Worker only stores a response when all of these hold:
The request is a
GET(orHEADfor lookup) with no WordPress login, post-password, comment-author or WooCommerce cart cookie.The path is not
/wp-admin,/wp-login.php,/wp-cron.php,/wp-json,/xmlrpc.phpor another bypassed endpoint, and the query has none ofrest_route,preview,customize_changeset_uuid,unapprovedorreplytocom.The origin answered
200withtext/html, set no cookie, did not sendX-WP-Cacheable: 0, and did not sendCache-Control: no-storeorprivate.
Stored pages live for 300 seconds. Tracking parameters (utm_*, fbclid, gclid and a few more) are stripped from the cache key and the remaining query is sorted, so equivalent URLs share one entry. The client sees a cf-cache-status header of HIT, MISS or BYPASS.
platform-cloudflare-page-cache.php is WordPress's half of the arrangement. On every front-end response it sends X-WP-Cacheable: 1 with Cache-Control: public, max-age=0, s-maxage=300 for cacheable pages, and X-WP-Cacheable: 0 for logged-in users, previews, searches, 404s and non-GET requests.
Purging
The purge is global. The plugin posts to /__cache/purge on the site's home URL whenever content or configuration changes: post save, delete, trash and untrash, comment posts and edits, theme switch, customizer save, plugin activation and deactivation, and menu updates. The Worker authenticates the call with the shared CACHE_PURGE_SECRET and bumps a version number stored in KV. Because the version is part of every cache key, every cached page misses at once, and the old entries expire on their own.
Three things to check when purges do not seem to work:
CACHE_PURGE_SECRETmust be set in the environment WordPress runs in, andWP_HOMEmust be defined. If either is missing, the plugin silently does nothing.The Worker needs its
CACHE_KVbinding. Without it the purge endpoint answers501and the version stays at0.Each Worker isolate remembers the version for 10 seconds, so allow that long for a purge to be seen everywhere.
What the public plane refuses
On the NixOS module, setting the plane role to public makes Caddy redirect /wp-admin (except admin-ajax.php and admin-post.php) to the admin URL, redirect /wp-login.php unless plane.allowLogin is set, return 403 for /xmlrpc.php, return 404 for wp-cron.php, wp-signup.php, wp-activate.php and wp-links-opml.php, and return 404 for any PHP file under wp-content or wp-includes. The edge Worker applies the same kind of guard before the cache runs.
The design choice worth understanding is that the public plane is not read-only. Checkout, donations, comments and form posts are all writes that visitors are supposed to make. What it enforces instead is a session rule: nobody privileged can hold a session there. Each plane declares a different site URL, so the auth cookie names differ and an admin cookie is not read on the public host, and platform-public-plane.php refuses to authenticate staff even with a correct password. With no privileged session possible, WordPress's own capability checks deny every administrative route.
User enumeration protection
platform-user-enumeration.php closes four ways of learning usernames, with no settings and no database writes. It replaces the stop-user-enumeration plugin, which rewrites an option on every page load and so costs a round trip per view on a remote database.
| Leak | What the platform does |
|---|---|
?author=<n> probes, which redirect to /author/<login>/ | Anonymous requests where author contains a digit, or is an array, get a 403. Named values such as ?author=jane are left alone. |
The users REST routes under /wp/v2/users | The routes' permission callbacks require a logged-in user, then run the original check. |
| The users sitemap | The provider is removed, so authors' URLs are not listed. |
| oEmbed responses | The author_url field is removed. |
Logged-in users are unaffected, because the block editor's author picker needs the users endpoint. The REST check is applied on each route's permission callback rather than through rest_pre_dispatch, because another plugin returning null from that filter would quietly undo a short-circuit there. The edge Worker adds its own 401 for the same routes when the request carries no auth cookie or Authorization header.
Audit a dump before you restore it
A MySQL dump from a host you do not control is an input, not a trusted file. It can carry triggers, stored routines, views and DEFINER clauses that execute on the new server the moment they are restored. A trigger on wp_comments that creates an administrator whenever a comment matches a phrase is a persistence backdoor, and restoring the dump faithfully would migrate the compromise along with the content.
audit-mysql-dump is a Python script with no dependencies, packaged as a flake output. Run it before any other migration step:
nix run github:Avunu/wordpress-nix#audit-mysql-dump -- dump.sqlIt reports:
Every trigger, procedure, function and view, by name, plus a count of
DEFINERclauses.Any trigger or routine that inserts into, updates or replaces rows in the users tables, flagged as a privilege escalation.
Every account whose
wp_capabilitiescontainsadministrator, with login, email and registration date.A fingerprint for accounts whose capability was written as the string
"1"instead of WordPress's ownb:1form, which means something issued SQL directly.
Abridged output from the repository's test fixture looks like this:
EXECUTABLE OBJECTS FOUND. These run on the new server once restored.
trigger after_insert_comment
routine housekeeping
view wp_a_view
definer 1 DEFINER clause(s)
!! PRIVILEGE ESCALATION: these write to the users tables, which is
!! what a persistence backdoor does. Treat the source host as compromised.
!! after_insert_commentThe exit code is the gate: it is 1 when any trigger, routine or view is found, and 0 otherwise, so a migration script can stop on it. With --strip it exits 0 because it has already written a sanitized copy. The administrator list never changes the exit code, so read it yourself.
Strip the executable objects
When the dump is otherwise good, write a sanitized copy:
nix run github:Avunu/wordpress-nix#audit-mysql-dump -- dump.sql --strip -o clean.sql--strip removes triggers, procedures, functions, views and DEFINER clauses, and leaves content and schema as they were. The result audits clean. Other options are --json for machine-readable findings, - as the filename to read from stdin, and --allow-executable to exit 0 even when objects were found, which you should not use in a migration.
The tool does not delete accounts, on purpose. Editing INSERT statements risks corrupting content, and rows are easier to remove once they are in a database. The report prints the DELETE statements to use after restoring, and three SELECT COUNT(*) queries against information_schema to confirm nothing executable survived.
Warning
A finding in the escalation section means the source host should be treated as compromised, not just the dump. Removing the trigger does not explain how it got there. Rotate credentials and keys on the source before you trust anything else from it.
Limits to know about
The administrator check looks for the literal
wp_capabilitiesmeta key. On a site with a custom table prefix it can report none. If that surprises you, check the prefix.The audit is not wired into
wp-importor the other conversion tools. Run it yourself first, then continue withrestore-core-keys,mysql-to-sqliteandsqlite-to-turso.mysql-to-sqlitedoes not migrate triggers, procedures, functions or events, and lists each one it skips. Read that list too. The audit is the earlier and stricter gate.
Related documents
WordPress on FrankenPHP with wordpress-nix, for deploying and developing the sites these plugins run on.
Database Optimization Queries, for measuring and cleaning up meta bloat on a MySQL-backed site.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.