WordPress on FrankenPHP, built with Nix: reproducible OCI images and a NixOS module from one codebase.
This project provides two ways to deploy an optimized FrankenPHP WordPress stack from a single, shared Nix codebase:
- OCI containers —
nix build .#wordpress-php83produces an image for ghcr.io etc. - A NixOS module —
services.wordpress-nixruns WordPress directly on a NixOS host.
Both paths share the same optimized ZTS PHP build (lib/php.nix) and FrankenPHP
(lib/frankenphp.nix).
- PHP 8.2 / 8.3 / 8.4, selectable per deployment (
services.wordpress-nix.php). - FrankenPHP as the server; standalone Caddy ACME TLS on NixOS.
- Optimized builds with CPU-specific flags (auto-gated per architecture; opt-out via
phpOptimize). - Two site-source modes on NixOS:
- state — mutable WordPress core in local state storage (flexible, server-specific; admin manages core/plugins/themes via the UI).
- git — a read-only document root pulled from a flake input (source-managed).
- Automated container builds and pushes to ghcr.io.
- Performance keys for the SQLite-backed engines (
mu-plugins/platform-performance-keys.php): the composite keys index-wp-mysql-for-speed added on MySQL —(meta_key, object_id)on the four meta tables,(post_parent, post_type, post_status)and(post_author, post_type, post_status, post_date)on posts — added once through the driver (ALTER TABLE … ADD KEY, so the driver's schema knows them), on the admin plane, and only on a driver that creates indexes in place (wordpress-sqlite-anywhere ≥ 1.2). A front-pagemeta_queryon the first migrated site went from 600 ms to 77 ms. - Platform mu-plugins (
mu-plugins/platform-*.php, refreshed on every deploy): edge page-cache signalling, a 60-day nonce lifetime, and user-enumeration hardening (403 on?author=<id>probes, the users REST routes require authentication, no users sitemap, no oEmbed author URL) — so sites need no plugin for it, and none of that plugin's per-request writes.
flake.nix # outputs: nixosModules.default, lib, packages, checks
lib/{php,frankenphp,wordpress}.nix # shared builders
lib/php-extensions.nix # the native wp_mysql_parser + wp_d1_client extensions (from the plugin flake)
modules/nixos.nix # services.wordpress-nix
modules/containers.nix # OCI image build (reuses lib/)
lib/init.nix, lib/sh/ # wordpress-init: the `nix run` bootstrapper
templates/site/ # what it lays down (also `nix flake init -t …#site`)
conf/{php.ini,Caddyfile,wp-config.php}
tests/module.nix # NixOS VM test
tests/logging.nix # logging contract check (no VM)
tests/init.nix # bootstrapper check (no VM, no network)
Add this flake as an input and import nixosModules.default.
{
imports = [ inputs.wordpress-nix.nixosModules.default ];
services.wordpress-nix = {
enable = true;
php = pkgs.php84; # per-deployment PHP version
domain = "blog.example.com"; # Caddy gets a cert via ACME
acmeEmail = "admin@example.com";
source.type = "state"; # core lives in /var/lib/wordpress/www, mutable
database.createLocally = true; # local MariaDB, passwordless unix_socket auth
};
}The WordPress document root (a full webroot, or a Bedrock/Composer layout) is a flake
input; it is mounted read-only, with wp-content/uploads kept writable in state.
{
# flake inputs: mysite.url = "git+ssh://git@host/mysite";
imports = [ inputs.wordpress-nix.nixosModules.default ];
services.wordpress-nix = {
enable = true;
php = pkgs.php83;
domain = "shop.example.com";
acmeEmail = "admin@example.com";
source = {
type = "git";
path = inputs.mysite; # read-only document root
# manageWpConfig = false; # set if the repo ships its own wp-config.php
};
database = {
createLocally = false; # external DB
host = "db.internal";
name = "shop";
user = "shop";
passwordFile = config.age.secrets.wp-db.path; # injected via systemd LoadCredential
};
};
}Notes:
- Leaving
domain = ""binds:80only (put your own TLS in front). - An external DB connects over TCP using
passwordFile; a local DB uses passwordlessunix_socketauth, sodatabase.usermust equaluser. - Secrets (DB password + salts) are written to
/var/lib/wordpress/wp-secrets.php(0600) at activation and never enter the Nix store. - Run wp-cli as the service user:
sudo -u wordpress wp .... - In git mode UI-driven plugin/theme installs are disabled (
DISALLOW_FILE_MODS) — manage them in the source.
services.wordpress-nix.database.type selects where the data lives.
mysql |
MariaDB/MySQL, local or external. The default. |
d1 |
Cloudflare D1 through the site Worker's authenticated /__d1 proxy. |
turso |
A Turso database over SQL-over-HTTP, optionally reading from a locally published snapshot. |
Both remote backends run the MySQL-on-SQLite driver in place of MySQL, through
the WordPress SQLite Anywhere
plugin (a flake input); the module installs its wp-content/db.php drop-in and
sets DB_ENGINE for you. The plugin requires PHP 8.5, so these modes need
php = pkgs.php85 (an assertion says so).
Three shapes. database.turso.embedded = true is the one to reach for: the
plugin's wp_turso extension holds an embedded replica open inside FrankenPHP,
pulls the primary's changes into it every pullIntervalMs (and again at the
end of any request that wrote), and serves every read from it at local-SQLite
speed — wp-admin included, which on a WAN primary goes from ~1.5 s a page to
~40 ms. Writes go to the primary. No publisher process, no snapshot copy.
services.wordpress-nix = {
enable = true;
php = pkgs.php85;
database.type = "turso";
database.turso = {
url = "libsql://site-org.turso.io";
tokenFile = "/run/agenix/site-turso-token";
embedded = true;
};
};The other two shapes are chosen by whether database.turso.snapshotPath is set.
Without a snapshot, every statement goes to the primary. This is what the
control plane wants — wp-admin and cron must read their own writes immediately.
Co-locate the primary: per-statement latency is what a query-heavy admin page
multiplies, and a local tursodb --sync-server answers in ~156 µs where a WAN
round trip would not.
services.wordpress-nix = {
enable = true;
php = pkgs.php85;
database.type = "turso";
database.turso.url = "http://127.0.0.1:8080";
};With a snapshot, reads come from a local SQLite file and writes go to the primary; the first write latches the rest of the request to the primary so it reads its own writes. This is the public front end, where rendering a page never touches the network — measured at 22 ms per page and ~41 requests/second per vCPU, indistinguishable from reading the database file directly.
services.wordpress-nix = {
enable = true;
php = pkgs.php85;
database.type = "turso";
database.turso = {
url = "libsql://site-org.turso.io";
tokenFile = "/run/agenix/site-turso-token";
snapshotPath = "/var/lib/wordpress/database/snapshot.db";
publishIntervalSeconds = 10;
};
};Setting snapshotPath starts wordpress-turso-publisher, which keeps the
snapshot current and publishes the first one before WordPress starts. Front-end
reads are behind the primary by up to publishIntervalSeconds — a real
semantic change worth documenting per site, though it composes with page caching,
which already means the public site lags the database by a bounded amount.
The snapshot exists because a live Turso embedded replica cannot be read by
pdo_sqlite at all: Turso holds an exclusive lock on it for the life of its
connection and coordinates its WAL through a file SQLite knows nothing about. The
publisher owns the replica and hands PHP a plain file instead. Nothing else may
touch database.turso.replicaPath.
Three tools, all flake packages, take a mysqldump to a populated Turso (or
D1) database:
nix run github:Avunu/wordpress#restore-core-keys -- dump.sql dump-fixed.sql # only if a plugin rewrote core keys
nix run github:Avunu/wordpress#mysql-to-sqlite -- dump-fixed.sql site.sqlite
TURSO_AUTH_TOKEN=... nix run github:Avunu/wordpress#sqlite-to-turso -- site.sqlite libsql://site-org.turso.iorestore-core-keys matters when the site ran a plugin such as
index-wp-mysql-for-speed, which rewrites the core tables' keys (wp_options
gets PRIMARY KEY (option_name); the meta tables get composite primary keys).
The driver keeps the AUTO_INCREMENT column as SQLite's primary key and has
no place for a second one, so option_name would lose its uniqueness and
INSERT … ON DUPLICATE KEY UPDATE its safety. The tool replaces the key lines
of every core table that differs from wp_get_db_schema() — taken from the
platform's pinned core — and reports what it changed; columns are kept as
dumped, and a table lacking a column the standard keys need is left alone
with a warning. Run it on every dump: it is a no-op for a standard schema.
mysql-to-sqlite replays the dump through the MySQL-on-SQLite driver itself
(with the same native parser the site runs on — a 130 MB dump takes about a
minute), so the SQLite file carries the exact schema — and the driver's
emulated INFORMATION_SCHEMA, with MySQL column types intact — that the site
will use at runtime. It replays under the SQL mode mysqldump sets, so
0000-00-00 dates and the rest of what was valid on the source load as they
were. Triggers, procedures, functions and events are not migrated and are
reported one by one: WordPress creates none, so read every one the report
lists — a trigger on wp_comments that inserts an administrator is a
well-known backdoor. sqlite-to-turso copies that file into the Turso primary over its
SQL-over-HTTP pipeline: tables, rows (as typed arguments, never SQL text),
indexes, triggers, views and AUTOINCREMENT counters, then verifies every
table's row count. It refuses a target that already has tables unless you
pass --replace (start over) or --resume (finish an interrupted load:
complete tables are skipped, partial ones reloaded). Gateway errors are
retried; every request is one transaction, so a retry never duplicates rows.
The token comes from TURSO_AUTH_TOKEN (or TURSO_AUTH_TOKEN_FILE); it is
never taken from the command line.
The tables are created without AUTOINCREMENT unless you pass
--keep-autoincrement. Turso's engine appends a row to a backing sequence
table for every AUTOINCREMENT row and compacts only at commit, which makes
a multi-row insert quadratic: 2,000 rows took 23 s against 0.4 s for the same
table with a plain INTEGER PRIMARY KEY, and a real site's load went from
hours to minutes. Single-row inserts — what WordPress does at runtime — cost
the same either way. The only semantic difference is that the id of a
deleted highest row may be reused, which WordPress does not depend on.
Everything goes to journald, so one collector can ship it. Every unit the module
defines — and the local mysql it enables — carries two extra journald fields,
shared with frappe-nix and odoo-nix:
| Unit | SYSLOG_IDENTIFIER |
APP_SERVICE |
|---|---|---|
wordpress (FrankenPHP) |
wordpress |
web |
wordpress-init |
wordpress-init |
init |
wordpress-cron |
wordpress-cron |
cron |
wordpress-turso-publisher |
wordpress-turso-publisher |
publisher |
mysql (local DB only) |
mysql |
db |
APP_SITE is logging.site: the domain, or wordpress when none is set.
journalctl APP_SITE=blog.example.com APP_SERVICE=web -o json- Caddy and FrankenPHP log JSON lines to stderr, each with a
level. logging.accessLog(on by default) adds one JSON line per request, from Caddy'shttp.log.accesslogger. Caddy redactsCookieandAuthorizationin it. Responses with a 5xx status are logged aterrorlevel.- PHP errors and
error_log()calls go through FrankenPHP into the same JSON logger, at a level mapped from their severity. The module unsetserror_log, whichconf/php.inipoints at/dev/stderrfor the OCI image;phpIniExtracan still set it. wp-cli, underwordpress-cronandwordpress-init, writes its errors to stderr as plain text. WP_DEBUG_LOGstays unset, so WordPress writes nodebug.log.
nix run github:Avunu/wordpress-nix (the default app, lib/init.nix) turns the current
directory into a wordpress-nix site repo. Run it where the site lives; it detects what it
is looking at:
| In… | It… |
|---|---|
| an empty directory | scaffolds a new site from templates/site |
| an existing WordPress install | adopts it in place: wp-content/ becomes the payload; core, wp-config.php, uploads, caches and SQL dumps are gitignored (the platform pins core and generates its own wp-config.php). Nothing on disk is moved or deleted |
| a wordpress-nix site | reconciles it: installs what is missing, refreshes the .gitignore block, never rewrites a file you own |
cd ~/sites/example && nix run github:Avunu/wordpress-nix # or pass a directory
nix run github:Avunu/wordpress-nix -- --dry-run # the plan, writing nothing
nix run github:Avunu/wordpress-nix -- --name example --database turso --commitIt writes flake.nix (site name filled in, --database sqlite|turso|mysql), .envrc,
wrangler.jsonc, the deploy/publish CI callers and a README; splices a managed block
into .gitignore; runs git init if the directory is not in a repository; stages
everything (it commits only with --commit); and pins the platform with nix flake lock
(--skip-lock to skip). An adopted install's $table_prefix is carried into the flake's
siteConfig when it is not wp_, and any .sql/.sqlite file found is offered to
wp-import in the next-steps list. Existing files are never overwritten — beside a
flake.nix that is not wordpress-nix's, --force writes flake.nix.wordpress-nix to
merge by hand. It reports what is still CHANGEME (Cloudflare IDs, URLs, the site repo);
none of that is needed for local development.
The publish caller reads the repository that holds your cluster configuration from a
CLUSTER_REPO repository (or organisation) variable; the reusable site-publish workflow
has no default for it.
Then:
git diff --cached --stat # review what was staged
direnv allow # or: nix develop --no-pure-eval
devenv up # the site, on the platform stack
wp-import export.sql # in another shell: load the database
wp-admin-userflakeModules.default is a flake-parts + devenv
module, in the shape frappe-nix and odoo-nix use. A site flake imports it through
lib.mkFlake (which merges wordpress-nix's own inputs — nixpkgs, devenv, the plugin
flake — under the site's, so the site declares only wordpress-nix) and sets
perSystem.wordpress-nix:
outputs = { self, wordpress-nix, ... }@inputs:
wordpress-nix.lib.mkFlake { inherit inputs; } ({ ... }: {
imports = [ wordpress-nix.flakeModules.default ];
systems = [ "x86_64-linux" "aarch64-linux" ];
perSystem.wordpress-nix = {
enable = true;
siteName = "example"; # ports and the image name derive from it
siteRoot = ./.; # wp-content/ lives here
database.type = "sqlite"; # | "turso" | "mysql"
configExtra = siteConfig; # the site's non-secret wp-config constants
};
});nix develop --impure (or direnv with use flake . --no-pure-eval) gives a shell where
devenv up runs the site as the platform does: the pinned core symlinked over the
checkout's wp-content/ (the managed-mode layout), the platform PHP 8.5 with
wp_mysql_parser, wp_d1_client and wp_turso, FrankenPHP, the SQLite Anywhere db.php
drop-in and the platform mu-plugins — with wp-cli, the migration tools (wp-import
runs restore-core-keys → mysql-to-sqlite on a dump), wp-admin-user, wp-reset, and
Mailpit catching all mail. The same options build packages.image, static-assets and
worker. Database shapes:
database.type |
dev shell |
|---|---|
sqlite (default) |
a file under .devenv/state/, through the same driver the remote engines use — no server |
turso |
a local tursodb --sync-server (or database.turso.url, token from WP_TURSO_TOKEN), read through the embedded replica as in production; wp talks to the primary |
mysql |
devenv's MariaDB |
Security, anti-spam and remote-management plugins have no environment switch of their
own (Patchstack's firewall is gated on options and its ban check runs regardless;
CleanTalk's APBCT_IS_LOCALHOST only tweaks spam scans), so productionOnlyPlugins
(default: patchstack, cleantalk-spam-protect, wordfence, sucuri-scanner, mainwp-child, and
the CAPTCHA gates advanced-google-recaptcha and hcaptcha-for-forms-and-more — a widget
bound to the production domain can never validate on 127.0.0.1, which locks the login
form) are filtered out of active_plugins by a platform mu-plugin whenever
WP_ENVIRONMENT_TYPE is not production — the database is untouched, wp-admin says which
are held back. Plugins with a development posture of their own get it instead: the
generated wp-config gives Simple Cloudflare Turnstile Cloudflare's always-pass testing
keys (CF_TURNSTILE_*), so the widget stays and every challenge passes, and sets
CleanTalk's APBCT_IS_LOCALHOST.
Secrets never sit in configExtra. environmentConstants (default: the house plugins'
JWT_AUTH_CLIENT_SECRET, CLOUDFLARE_EMAIL_ACCOUNT_ID/_API_TOKEN, S3_KEY/S3_SECRET)
are defined into wp-config from environment variables of the same name when set — from a
gitignored .env the template's .envrc loads. They are optional: mail is caught by
Mailpit and media renders read-only from S3_PUBLIC_URL without them.
nix run github:Avunu/wordpress-nix bootstraps one (see above); nix flake init -t github:Avunu/wordpress-nix#site lays down the bare template.
The container path is unchanged: WordPress is downloaded at container start
(WORDPRESS_SOURCE_URL, default wordpress.org/latest.zip) and configured from
environment variables (see .env.example and conf/wp-config.php).
- Nix with flakes enabled
- Docker (for local testing and pushing)
- GitHub account (for pushing to ghcr.io)
To build images locally:
# Build all images
nix build
# Build a specific PHP version
nix build .#wordpress-php83There’s a script you can use to test locally:
./scripts/test.shVisit http://localhost:8080 in your browser to test.
- Create a
.envfile in the project root:
GITHUB_USERNAME=your_github_username
GITHUB_TOKEN=your_personal_access_token
2. Run the build and push script:
./scripts/build-and-push.shThe included GitHub Actions workflow automatically builds and pushes images to ghcr.io on pushes to the main branch.
Contributions are welcome! Please submit pull requests with any improvements or bug fixes.
MIT. See LICENSE.