Skip to content
Skip to the article
In NixOS: 13 articles
NixOS

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.

Updated
Applies to
  • wordpress-nix (nixos-unstable)
  • FrankenPHP
  • PHP 8.2 to 8.5 (ZTS)
  • NixOS
Tags
  • nixos
  • wordpress
  • frankenphp
  • oci
Reading time
13 min

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.

PathEntry pointUse it when
NixOS modulenixosModules.default, option set services.wordpress-nixYou run WordPress directly on a NixOS host
OCI imagepackages.<system>.wordpress-php83 and friends, or lib.mkSiteImageYou deploy to a container platform
Dev shellflakeModules.default, option set perSystem.wordpress-nixYou 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 -O3 and LTO, plus a CPU baseline flag (-march=x86-64-v3 on x86_64, -mcpu=neoverse-n1 on aarch64). Set phpOptimize = false to skip that pass for a faster build.

  • JIT. nixpkgs disables opcache JIT in ZTS builds before PHP 8.5, which would make opcache.jit inert. 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.ini sets memory_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.

ModeCore and wp-contentBest for
state (default)Real, writable files under the state directoryServer-specific sites that an admin manages from wp-admin
gitA read-only store path you provideSites whose code lives in a repository
managedPinned core from the store, writable wp-content seeded from a site repoThe 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.version selects the core release to download. It defaults to latest.

  • database.createLocally = true enables a local MariaDB with passwordless unix_socket authentication. It is off by default, so without it you must point database.host at 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.php into a store copy of your tree, because PHP resolves the symlinks and ABSPATH lands in the store. Set manageWpConfig = false only 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, your wp-content and 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 with stateDir).

  • 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 the php_server directive, compresses with br, zstd and gzip, and restarts automatically if it exits.

  • wordpress-cron and a timer that runs wp cron event run --due-now every minute. WordPress's own cron is disabled with DISABLE_WP_CRON. Set cron.enable = false to turn the timer off.

  • A local MariaDB with the database and user created, when database.createLocally is on.

  • A wp command on the system PATH, pointed at the right document root.

  • Firewall rules for port 80, and for 443 over TCP and UDP when domain is set. Set openFirewall = false to manage this yourself.

A few behaviors worth knowing:

  • Leave domain empty and Caddy binds only :80, which suits a setup where something else terminates TLS. Setting domain requires acmeEmail and 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 with domain.

  • Secrets never enter the Nix store. The database password and the authentication salts are written to /var/lib/wordpress/wp-secrets.php with mode 0600 at activation. Salts are generated once, or read from saltsFile if you provide one. An external database password comes from database.passwordFile through systemd LoadCredential.

  • A local database uses unix_socket authentication, so database.user must equal user (both wordpress by default).

  • The services run with ProtectSystem=strict, PrivateTmp and NoNewPrivileges. The module deliberately does not set MemoryDenyWriteExecute, because the opcache JIT would crash FrankenPHP at startup.

Operating the site

Run wp-cli as the service user:

sudo -u wordpress wp plugin list

Every 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 json

logging.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

OptionDefaultPurpose
phppkgs.php83Base PHP interpreter, wrapped with the ZTS build
phpOptimizetrueClang, LTO and CPU-baseline optimization pass
phpIniExtraemptyExtra php.ini lines, applied after conf/php.ini
domain, acmeEmailemptyPublic hostname and ACME contact
source.typestatestate, git or managed
source.pathnullDocument root for git mode
database.typemysqlmysql, d1 or turso
database.createLocallyfalseCreate a local MariaDB
tablePrefixwp_WordPress table prefix
configExtraemptyExtra PHP appended to the generated wp-config.php
debugfalseTurns on WP_DEBUG and PHP display_errors
muPluginsthe flake's mu-pluginsMu-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 < result

The 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:

VariablePurpose
WORDPRESS_HOME, WORDPRESS_SITE_URLSite URLs
WORDPRESS_DB_HOST, WORDPRESS_DB_USER, WORDPRESS_DB_PASSWORD, WORDPRESS_DB_NAMEDatabase connection
WORDPRESS_TABLE_PREFIXTable prefix
WORDPRESS_SALTSEight define(...) lines. Generated locally if unset, which is fine for development only
WORDPRESS_DEBUGAny non-empty value turns on WP_DEBUG, including the string false. Leave it unset to keep debug off
WORDPRESS_CONFIG_EXTRAPHP evaluated inside wp-config.php
WPCONF_<NAME>Defines the constant <NAME>. The values true and false become booleans
WORDPRESS_OBJECT_CACHESet to none to skip the APCu object cache drop-in
PROC_TYPESet 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 up

Without direnv, use nix develop --impure instead of direnv allow. The shell gives you PHP, FrankenPHP, wp-cli, Mailpit, the migration tools and these commands:

CommandWhat it does
devenv upStarts FrankenPHP (plus Mailpit, and tursodb or MariaDB when selected). devenv up -D runs it detached
wp-stopStops what devenv up -D started
wpRuns 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-resetDeletes the dev database and replica, and keeps wp-content
wp-core-resetRe-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, and wp-core-reset re-pins it.

  • wp-content is your checkout's own directory, symlinked into that docroot. The shell copies the platform mu-plugins and the db.php drop-in into it, and your site repository should gitignore both.

  • The generated wp-config.php sets WP_ENVIRONMENT_TYPE to development, turns on WP_DEBUG with 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 productionOnlyPlugins option lists them.

  • Secrets never go in configExtra. Constants named in environmentConstants are defined from environment variables of the same name, which you can keep in a gitignored .env.

Choose the dev database

database.typeWhat runs
sqlite (default)A SQLite file under .devenv/state/, no server
tursoA local tursodb, read through an embedded replica as in production
mysqldevenv'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-run

It 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.

Sources

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.