Run WordPress on FrankenPHP with wordpress-nix
Build WordPress on FrankenPHP as an OCI image or a NixOS service, choose state or git mode, and develop locally with devenv using the wordpress-nix flake.
On this page
wordpress-nix is a Nix flake that runs WordPress on FrankenPHP two ways: as an OCI container image, or as a NixOS service (services.wordpress-nix). Use this document when you want to deploy a WordPress site on a NixOS host, build an image for a container platform, or run the same stack on a laptop with devenv.
How the pieces fit
Both deployment paths share one PHP build and one FrankenPHP build, so a site behaves the same in a container, on NixOS and in the dev shell.
| Path | Entry point | Use it when |
|---|---|---|
| NixOS module | nixosModules.default, option set services.wordpress-nix | You run WordPress directly on a NixOS host |
| OCI image | packages.<system>.wordpress-php83 and friends, or lib.mkSiteImage | You deploy to a container platform |
| Dev shell | flakeModules.default, option set perSystem.wordpress-nix | You develop a site repo locally |
The PHP build
FrankenPHP embeds PHP, and the embed requires a thread-safe (ZTS) build. lib/php.nix wraps the nixpkgs PHP you choose with ZTS enabled and a WordPress-ready extension set: mysqli, curl, dom, exif, gd, intl, mbstring, openssl, sodium, zip, apcu and the other extensions WordPress recommends.
Versions. The flake builds images for PHP 8.2, 8.3, 8.4 and 8.5. The NixOS module defaults to
pkgs.php83.Optimization. The build uses clang with
-O3and LTO, plus a CPU baseline flag (-march=x86-64-v3on x86_64,-mcpu=neoverse-n1on aarch64). SetphpOptimize = falseto skip that pass for a faster build.JIT. nixpkgs disables opcache JIT in ZTS builds before PHP 8.5, which would make
opcache.jitinert. The flake turns it back on for 8.2 to 8.4. From 8.5, opcache is part of the interpreter and no override is needed.Defaults.
conf/php.inisetsmemory_limit = 1G,max_execution_time = 300, 100 MB upload and POST limits, tracing JIT with a 64 MB buffer, and a 128 MB APCu segment.
Note
The database backends that run on SQLite (Cloudflare D1 and Turso, through the WordPress SQLite Anywhere plugin) require PHP 8.5. The module asserts this at evaluation time. Plain MySQL or MariaDB sites can stay on 8.2 to 8.4.
Deploy on NixOS
Add the flake as an input and import its module:
{
inputs.wordpress-nix.url = "github:Avunu/wordpress-nix";
}Then, in your host configuration:
{
imports = [ inputs.wordpress-nix.nixosModules.default ];
}Pass inputs into your modules the way you normally do (for example through specialArgs). Import the module through the flake's nixosModules, not by file path: only the flake wiring injects the pieces the SQLite backends and gitium need.
The module has three source modes. You pick one with services.wordpress-nix.source.type.
| Mode | Core and wp-content | Best for |
|---|---|---|
state (default) | Real, writable files under the state directory | Server-specific sites that an admin manages from wp-admin |
git | A read-only store path you provide | Sites whose code lives in a repository |
managed | Pinned core from the store, writable wp-content seeded from a site repo | The admin half of a split-plane site (not covered further here) |
State mode: a mutable site
In state mode the core lives in /var/lib/wordpress/www and the module downloads it on first boot with wp core download. An administrator can then update core, plugins and themes from wp-admin as on any ordinary host.
{
imports = [ inputs.wordpress-nix.nixosModules.default ];
services.wordpress-nix = {
enable = true;
php = pkgs.php84;
domain = "blog.example.com";
acmeEmail = "admin@example.com";
source.type = "state";
database.createLocally = true;
};
}source.versionselects the core release to download. It defaults tolatest.database.createLocally = trueenables a local MariaDB with passwordlessunix_socketauthentication. It is off by default, so without it you must pointdatabase.hostat an external server.The module does not run the WordPress installer. Finish the install in a browser or with wp-cli.
Git mode: a source-managed site
In git mode the document root is a flake input, a full WordPress webroot (or a Bedrock or Composer layout). The module mounts it read-only and keeps only wp-content/uploads, cache and upgrade writable in state.
{
# flake input: mysite.url = "git+ssh://git@git.example.com/example/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;
# manageWpConfig = false; # set this if the repository ships its own wp-config.php
};
database = {
createLocally = false;
host = "db.example.com";
name = "shop";
user = "shop";
passwordFile = config.age.secrets.wp-db.path; # for example an agenix secret
};
};
}What changes compared with state mode:
The module sets
DISALLOW_FILE_MODS, so wp-admin cannot install plugins or themes. You change code in the repository and redeploy.The module generates
wp-config.phpinto a store copy of your tree, because PHP resolves the symlinks andABSPATHlands in the store. SetmanageWpConfig = falseonly if your repository carries its own.The module copies the platform mu-plugins only in state and managed modes. A git-mode tree has to arrive complete. If you start from a plain checkout, build the tree with
lib.mkSiteDocroot { inherit pkgs; wpContent = "${siteRepo}/wp-content"; }, which composes the pinned core, yourwp-contentand the platform mu-plugins.To graft extra plugins or themes onto a base tree, use
lib.mkWordPressSite.
What the module sets up
When you enable the service, you get:
A system user and group named
wordpress, with the state directory/var/lib/wordpress(change it withstateDir).wordpress-init, a oneshot unit that prepares the document root, installs the platform mu-plugins (state and managed modes) and the database drop-in (remote SQLite backends), and writes the secrets file.wordpress, the FrankenPHP service. It runs Caddy with thephp_serverdirective, compresses withbr,zstdandgzip, and restarts automatically if it exits.wordpress-cronand a timer that runswp cron event run --due-nowevery minute. WordPress's own cron is disabled withDISABLE_WP_CRON. Setcron.enable = falseto turn the timer off.A local MariaDB with the database and user created, when
database.createLocallyis on.A
wpcommand on the system PATH, pointed at the right document root.Firewall rules for port 80, and for 443 over TCP and UDP when
domainis set. SetopenFirewall = falseto manage this yourself.
A few behaviors worth knowing:
Leave
domainempty and Caddy binds only:80, which suits a setup where something else terminates TLS. SettingdomainrequiresacmeEmailand turns on automatic HTTPS.Set
socketPath(inside its own directory such as/run/wordpress/) to serve over a unix socket with no network listener. It cannot be combined withdomain.Secrets never enter the Nix store. The database password and the authentication salts are written to
/var/lib/wordpress/wp-secrets.phpwith mode0600at activation. Salts are generated once, or read fromsaltsFileif you provide one. An external database password comes fromdatabase.passwordFilethrough systemdLoadCredential.A local database uses
unix_socketauthentication, sodatabase.usermust equaluser(bothwordpressby default).The services run with
ProtectSystem=strict,PrivateTmpandNoNewPrivileges. The module deliberately does not setMemoryDenyWriteExecute, because the opcache JIT would crash FrankenPHP at startup.
Operating the site
Run wp-cli as the service user:
sudo -u wordpress wp plugin listEvery unit writes to journald with APP_SITE and APP_SERVICE fields (web, init, cron, db), so you can filter one site:
journalctl APP_SITE=blog.example.com APP_SERVICE=web -o jsonlogging.accessLog (on by default) adds one JSON line per request. Set logging.site to change the APP_SITE value, which defaults to domain or wordpress.
Common options
| Option | Default | Purpose |
|---|---|---|
php | pkgs.php83 | Base PHP interpreter, wrapped with the ZTS build |
phpOptimize | true | Clang, LTO and CPU-baseline optimization pass |
phpIniExtra | empty | Extra php.ini lines, applied after conf/php.ini |
domain, acmeEmail | empty | Public hostname and ACME contact |
source.type | state | state, git or managed |
source.path | null | Document root for git mode |
database.type | mysql | mysql, d1 or turso |
database.createLocally | false | Create a local MariaDB |
tablePrefix | wp_ | WordPress table prefix |
configExtra | empty | Extra PHP appended to the generated wp-config.php |
debug | false | Turns on WP_DEBUG and PHP display_errors |
muPlugins | the flake's mu-plugins | Mu-plugin directories copied in state and managed modes |
The generated wp-config.php also sets WP_MEMORY_LIMIT to 1G, five post revisions, a seven-day trash, FS_METHOD to direct and DISALLOW_FILE_EDIT. Use configExtra for anything site-specific, and keep secrets out of it.
Build the OCI image
Build an image for a PHP version and load it into Docker:
nix build .#wordpress-php83
docker load < resultThe flake also provides wordpress-php82, wordpress-php84, wordpress-php85 and wordpress-d1-php85. The D1 variant bundles the SQLite Anywhere plugin and its native extensions. Each image loads as <imageName>:latest.
On pushes to main, the repository's workflow publishes these to ghcr.io/avunu/wordpress-nix with tags such as php83, php85 and d1-php85, and aliases latest (PHP 8.3) and d1.
Core is baked into the image at build time. On first start the entrypoint copies it into /var/www/html (it does not download anything) and exits with an error if the image has no baked core. Some older text in the repository mentions WORDPRESS_SOURCE_URL, but the entrypoint no longer uses it.
For a site, build from your own flake with lib.mkSiteImage (or nix build .#image when you use the devenv module). It takes wpContent, optional plugins and themes attribute sets, and defaults to PHP 8.5 with the D1 driver stack included.
Configure the container
The image listens on port 80, serves /var/www/html and reads its configuration from environment variables:
| Variable | Purpose |
|---|---|
WORDPRESS_HOME, WORDPRESS_SITE_URL | Site URLs |
WORDPRESS_DB_HOST, WORDPRESS_DB_USER, WORDPRESS_DB_PASSWORD, WORDPRESS_DB_NAME | Database connection |
WORDPRESS_TABLE_PREFIX | Table prefix |
WORDPRESS_SALTS | Eight define(...) lines. Generated locally if unset, which is fine for development only |
WORDPRESS_DEBUG | Any non-empty value turns on WP_DEBUG, including the string false. Leave it unset to keep debug off |
WORDPRESS_CONFIG_EXTRA | PHP evaluated inside wp-config.php |
WPCONF_<NAME> | Defines the constant <NAME>. The values true and false become booleans |
WORDPRESS_OBJECT_CACHE | Set to none to skip the APCu object cache drop-in |
PROC_TYPE | Set to worker to run a wp-cron loop every 60 seconds instead of serving web traffic |
Warning
The default database password in the image's wp-config.php is wordpress. Always set WORDPRESS_DB_PASSWORD explicitly, and set WORDPRESS_SALTS in production.
A minimal Compose file for local testing, adapted from scripts/docker-compose.yml:
services:
wordpress:
image: wordpress-php83:latest
ports:
- "8080:80"
volumes:
- ./wordpress:/var/www/html
environment:
WORDPRESS_HOME: http://localhost:8080
WORDPRESS_SITE_URL: http://localhost:8080
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: <DB_PASSWORD>
WORDPRESS_DB_NAME: wordpress
depends_on:
- db
worker:
image: wordpress-php83:latest
volumes:
- ./wordpress:/var/www/html
environment:
PROC_TYPE: worker
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: <DB_PASSWORD>
WORDPRESS_DB_NAME: wordpress
depends_on:
- db
db:
image: mysql:latest
environment:
MYSQL_ROOT_PASSWORD: <ROOT_PASSWORD>
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: <DB_PASSWORD>Note
The worker loop calls wp cron event run --all --due-now. The NixOS module's source records that wp-cli rejects --all and --due-now together, so we did not verify the container worker mode. Check its logs on first use, or run cron from outside the container.
The repository's scripts/test.sh runs nix build, loads the image and starts its own Compose file, which you can then open at http://localhost:8080.
Develop locally with devenv
A site repository imports flakeModules.default, which provides a devenv shell that runs the site the way production does. A site flake looks like this:
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";
siteRoot = ./.;
database.type = "sqlite";
configExtra = ''
define('WP_MEMORY_LIMIT', '512M');
'';
};
});lib.mkFlake merges wordpress-nix's own inputs under yours, so the site flake only declares wordpress-nix (plus a nixpkgs input that follows it).
Start the site
Enter the shell and start the processes:
direnv allow
devenv upWithout direnv, use nix develop --impure instead of direnv allow. The shell gives you PHP, FrankenPHP, wp-cli, Mailpit, the migration tools and these commands:
| Command | What it does |
|---|---|
devenv up | Starts FrankenPHP (plus Mailpit, and tursodb or MariaDB when selected). devenv up -D runs it detached |
wp-stop | Stops what devenv up -D started |
wp | Runs wp-cli against the dev site |
wp-import <FILE> | Loads a .sql, .sql.gz or SQLite file into the dev database (sqlite and turso types) |
wp-admin-user [NAME] | Creates or resets a local administrator (default name dev) and prints a random password |
wp-reset | Deletes the dev database and replica, and keeps wp-content |
wp-core-reset | Re-seeds core from the platform's pinned version |
The site listens on 127.0.0.1 on a port from 8100 to 8999, hashed from siteName, so every clone of a site gets the same port. Set port to pick one yourself. Mail goes to Mailpit instead of out to the internet.
How the dev shell is laid out
Core is seeded once as real, writable files under
.devenv/state/wordpress/www. After that, wp-admin and wp-cli can update it, andwp-core-resetre-pins it.wp-contentis your checkout's own directory, symlinked into that docroot. The shell copies the platform mu-plugins and thedb.phpdrop-in into it, and your site repository should gitignore both.The generated
wp-config.phpsetsWP_ENVIRONMENT_TYPEtodevelopment, turns onWP_DEBUGwith logging to a file under the state directory, and disables WordPress cron and core auto-updates.Plugins that misbehave on a development machine (security scanners, anti-spam, CAPTCHA gates) stay inactive without touching the database. The
productionOnlyPluginsoption lists them.Secrets never go in
configExtra. Constants named inenvironmentConstantsare defined from environment variables of the same name, which you can keep in a gitignored.env.
Choose the dev database
database.type | What runs |
|---|---|
sqlite (default) | A SQLite file under .devenv/state/, no server |
turso | A local tursodb, read through an embedded replica as in production |
mysql | devenv's MariaDB |
With mysql, wp-import does not load the dump for you. Restore it with the mysql client against the dev socket instead. The dev shell defaults to PHP 8.5, as the SQLite-based engines require, and we did not verify it on older PHP versions. Other site options include mailpit.enable, muPlugins, extraDevPackages and extraScripts. The same options build packages.image, static-assets and worker for the site.
Bootstrap a site repository
You do not need to write the site flake by hand. Run the bootstrapper in a directory:
nix run github:Avunu/wordpress-nix -- --dry-runIt inspects the directory and acts accordingly:
Empty directory. It scaffolds a new site from
templates/site.Existing WordPress install. It adopts it in place:
wp-content/becomes the payload, and core,wp-config.php, uploads and SQL dumps are gitignored. It does not move or delete anything.Existing wordpress-nix site. It reconciles it, adding what is missing without rewriting files you own.
Drop --dry-run to apply the plan. Useful flags are --name <SITE_NAME>, --database sqlite|turso|mysql, --commit (otherwise changes are only staged), --skip-lock and --force. The bare template is also available with nix flake init -t github:Avunu/wordpress-nix#site.
Note
The scaffolded flake and its CI callers include Cloudflare identity placeholders marked CHANGEME. You do not need to fill them in to develop locally.
Related documents
WordPress performance and hardening on Nix, for the platform mu-plugins and auditing a dump before you restore it.
WordPress Cron and wp-config Reference, for background on the cron and configuration constants the module sets for you.
WordPress, for administration topics that apply regardless of how the site is hosted.
Why Nix for Business Systems, for when this approach is and is not a good fit.
Reference Architecture for Self-Hosting Business Systems, for a hosting pattern built around per-site containers and tunnels.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.