1 { config, pkgs, lib, ... }:
6 cfg = config.services.wordpress;
9 webserver = config.services.${cfg.webserver};
10 stateDir = hostName: "/var/lib/wordpress/${hostName}";
12 pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
13 pname = "wordpress-${hostName}";
14 version = src.version;
21 # symlink the wordpress config
22 ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
23 # symlink uploads directory
24 ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads
25 ln -s ${cfg.fontsDir} $out/share/wordpress/wp-content/fonts
27 # https://github.com/NixOS/nixpkgs/pull/53399
29 # Symlinking works for most plugins and themes, but Avada, for instance, fails to
30 # understand the symlink, causing its file path stripping to fail. This results in
31 # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js
32 # Since hard linking directories is not allowed, copying is the next best thing.
34 # copy additional plugin(s), theme(s) and language(s)
35 ${concatStringsSep "\n" (mapAttrsToList (name: theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${name}") cfg.themes)}
36 ${concatStringsSep "\n" (mapAttrsToList (name: plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${name}") cfg.plugins)}
37 ${concatMapStringsSep "\n" (language: "cp -r ${language} $out/share/wordpress/wp-content/languages/") cfg.languages}
42 # wordpress is installed onto a read-only file system
43 DISALLOW_FILE_EDIT = true;
44 AUTOMATIC_UPDATER_DISABLED = true;
45 DB_NAME = cfg.database.name;
46 DB_HOST = "${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}";
47 DB_USER = cfg.database.user;
49 # Always set DB_PASSWORD even when passwordFile is not set. This is the
50 # default Wordpress behaviour.
51 DB_PASSWORD = if (cfg.database.passwordFile != null) then { _file = cfg.database.passwordFile; } else "";
54 wpConfig = hostName: cfg: let
55 conf_gen = c: mapAttrsToList (k: v: "define('${k}', ${mkPhpValue v});") cfg.mergedConfig;
56 in pkgs.writeTextFile {
57 name = "wp-config-${hostName}.php";
60 $table_prefix = '${cfg.database.tablePrefix}';
62 require_once('${stateDir hostName}/secret-keys.php');
65 ${concatStringsSep "\n" (conf_gen cfg.mergedConfig)}
67 if ( !defined('ABSPATH') )
68 define('ABSPATH', dirname(__FILE__) . '/');
70 require_once(ABSPATH . 'wp-settings.php');
73 checkPhase = "${pkgs.php}/bin/php --syntax-check $target";
77 isHasAttr = s: isAttrs v && hasAttr s v;
78 # "you're escaped" -> "'you\'re escaped'"
79 # https://www.php.net/manual/en/language.types.string.php#language.types.string.syntax.single
80 toPhpString = s: "'${escape [ "'" "\\" ] s}'";
82 if isString v then toPhpString v
83 # NOTE: If any value contains a , (comma) this will not get escaped
84 else if isList v && any lib.strings.isCoercibleToString v then toPhpString (concatMapStringsSep "," toString v)
85 else if isInt v then toString v
86 else if isBool v then boolToString v
87 else if isHasAttr "_file" then "trim(file_get_contents(${toPhpString v._file}))"
88 else if isHasAttr "_raw" then v._raw
89 else abort "The Wordpress config value ${lib.generators.toPretty {} v} can not be encoded."
92 secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ];
93 secretsScript = hostStateDir: ''
94 # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839
95 grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php"
96 if ! test -e "${hostStateDir}/secret-keys.php"; then
98 echo "<?php" >> "${hostStateDir}/secret-keys.php"
99 ${concatMapStringsSep "\n" (var: ''
100 echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php"
102 echo "?>" >> "${hostStateDir}/secret-keys.php"
103 chmod 440 "${hostStateDir}/secret-keys.php"
107 siteOpts = { lib, name, config, ... }:
110 package = mkPackageOption pkgs "wordpress" { };
112 uploadsDir = mkOption {
114 default = "/var/lib/wordpress/${name}/uploads";
116 This directory is used for uploads of pictures. The directory passed here is automatically
117 created and permissions adjusted as required.
121 fontsDir = mkOption {
123 default = "/var/lib/wordpress/${name}/fonts";
125 This directory is used to download fonts from a remote location, e.g.
126 to host google fonts locally.
131 type = with types; coercedTo
133 (l: warn "setting this option with a list is deprecated"
134 listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l))
138 Path(s) to respective plugin(s) which are copied from the 'plugins' directory.
141 These plugins need to be packaged before use, see example.
144 example = literalExpression ''
146 inherit (pkgs.wordpressPackages.plugins) embed-pdf-viewer-plugin;
152 type = with types; coercedTo
154 (l: warn "setting this option with a list is deprecated"
155 listToAttrs (map (p: nameValuePair (p.name or (throw "${p} does not have a name")) p) l))
157 default = { inherit (pkgs.wordpressPackages.themes) twentytwentythree; };
158 defaultText = literalExpression "{ inherit (pkgs.wordpressPackages.themes) twentytwentythree; }";
160 Path(s) to respective theme(s) which are copied from the 'theme' directory.
163 These themes need to be packaged before use, see example.
166 example = literalExpression ''
168 inherit (pkgs.wordpressPackages.themes) responsive-theme;
173 languages = mkOption {
174 type = types.listOf types.path;
177 List of path(s) to respective language(s) which are copied from the 'languages' directory.
179 example = literalExpression ''
181 # Let's package the German language.
182 # For other languages try to replace language and country code in the download URL with your desired one.
183 # Reference https://translate.wordpress.org for available translations and
185 (pkgs.stdenv.mkDerivation {
186 name = "language-de";
187 src = pkgs.fetchurl {
188 url = "https://de.wordpress.org/wordpress-''${pkgs.wordpress.version}-de_DE.tar.gz";
189 # Name is required to invalidate the hash when wordpress is updated
190 name = "wordpress-''${pkgs.wordpress.version}-language-de";
191 sha256 = "sha256-dlas0rXTSV4JAl8f/UyMbig57yURRYRhTMtJwF9g8h0=";
193 installPhase = "mkdir -p $out; cp -r ./wp-content/languages/* $out/";
202 default = "localhost";
203 description = "Database host address.";
209 description = "Database host port.";
214 default = "wordpress";
215 description = "Database name.";
220 default = "wordpress";
221 description = "Database user.";
224 passwordFile = mkOption {
225 type = types.nullOr types.path;
227 example = "/run/keys/wordpress-dbpassword";
229 A file containing the password corresponding to
230 {option}`database.user`.
234 tablePrefix = mkOption {
238 The $table_prefix is the value placed in the front of your database tables.
239 Change the value if you want to use something other than wp_ for your database
240 prefix. Typically this is changed if you are installing multiple WordPress blogs
241 in the same database.
243 See <https://codex.wordpress.org/Editing_wp-config.php#table_prefix>.
248 type = types.nullOr types.path;
250 defaultText = literalExpression "/run/mysqld/mysqld.sock";
251 description = "Path to the unix socket file to use for authentication.";
254 createLocally = mkOption {
257 description = "Create the database and database user locally.";
261 virtualHost = mkOption {
262 type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
263 example = literalExpression ''
265 adminAddr = "webmaster@example.org";
271 Apache configuration can be done by adapting {option}`services.httpd.virtualHosts`.
275 poolConfig = mkOption {
276 type = with types; attrsOf (oneOf [ str int bool ]);
279 "pm.max_children" = 32;
280 "pm.start_servers" = 2;
281 "pm.min_spare_servers" = 2;
282 "pm.max_spare_servers" = 4;
283 "pm.max_requests" = 500;
286 Options for the WordPress PHP pool. See the documentation on `php-fpm.conf`
287 for details on configuration directives.
291 settings = mkOption {
292 type = types.attrsOf types.anything;
295 Structural Wordpress configuration.
296 Refer to <https://developer.wordpress.org/apis/wp-config-php>
297 for details and supported values.
299 example = literalExpression ''
301 WP_DEFAULT_THEME = "twentytwentytwo";
302 WP_SITEURL = "https://example.org";
303 WP_HOME = "https://example.org";
305 WP_DEBUG_DISPLAY = true;
307 FORCE_SSL_ADMIN = true;
308 AUTOMATIC_UPDATER_DISABLED = true;
313 mergedConfig = mkOption {
315 default = mergeConfig config;
316 defaultText = literalExpression ''
318 DISALLOW_FILE_EDIT = true;
319 AUTOMATIC_UPDATER_DISABLED = true;
323 Read only representation of the final configuration.
327 extraConfig = mkOption {
331 Any additional text to be appended to the wp-config.php
332 configuration file. This is a PHP script. For configuration
333 settings, see <https://codex.wordpress.org/Editing_wp-config.php>.
335 **Note**: Please pass structured settings via
336 `services.wordpress.sites.${name}.settings` instead.
339 @ini_set( 'log_errors', 'Off' );
340 @ini_set( 'display_errors', 'On' );
346 config.virtualHost.hostName = mkDefault name;
352 services.wordpress = {
355 type = types.attrsOf (types.submodule siteOpts);
357 description = "Specification of one or more WordPress sites to serve";
360 webserver = mkOption {
361 type = types.enum [ "httpd" "nginx" "caddy" ];
364 Whether to use apache2 or nginx for virtual host management.
366 Further nginx configuration can be done by adapting `services.nginx.virtualHosts.<name>`.
367 See [](#opt-services.nginx.virtualHosts) for further information.
369 Further apache2 configuration can be done by adapting `services.httpd.virtualHosts.<name>`.
370 See [](#opt-services.httpd.virtualHosts) for further information.
378 config = mkIf (eachSite != {}) (mkMerge [{
381 (mapAttrsToList (hostName: cfg:
382 { assertion = cfg.database.createLocally -> cfg.database.user == user;
383 message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
385 (mapAttrsToList (hostName: cfg:
386 { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
387 message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.'';
391 services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
393 package = mkDefault pkgs.mariadb;
394 ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
395 ensureUsers = mapAttrsToList (hostName: cfg:
396 { name = cfg.database.user;
397 ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
402 services.phpfpm.pools = mapAttrs' (hostName: cfg: (
403 nameValuePair "wordpress-${hostName}" {
405 group = webserver.group;
407 "listen.owner" = webserver.user;
408 "listen.group" = webserver.group;
415 (mkIf (cfg.webserver == "httpd") {
418 extraModules = [ "proxy_fcgi" ];
419 virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost {
420 documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
422 <Directory "${pkg hostName cfg}/share/wordpress">
423 <FilesMatch "\.php$">
424 <If "-f %{REQUEST_FILENAME}">
425 SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/"
429 # standard wordpress .htaccess contents
430 <IfModule mod_rewrite.c>
432 RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
434 RewriteRule ^index\.php$ - [L]
435 RewriteCond %{REQUEST_FILENAME} !-f
436 RewriteCond %{REQUEST_FILENAME} !-d
437 RewriteRule . /index.php [L]
440 DirectoryIndex index.php
442 Options +FollowSymLinks -Indexes
445 # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
446 <Files wp-config.php>
455 systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
456 "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -"
457 "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
458 "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
459 "d '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
460 "Z '${cfg.fontsDir}' 0750 ${user} ${webserver.group} - -"
463 systemd.services = mkMerge [
464 (mapAttrs' (hostName: cfg: (
465 nameValuePair "wordpress-init-${hostName}" {
466 wantedBy = [ "multi-user.target" ];
467 before = [ "phpfpm-wordpress-${hostName}.service" ];
468 after = optional cfg.database.createLocally "mysql.service";
469 script = secretsScript (stateDir hostName);
474 Group = webserver.group;
478 (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
479 httpd.after = [ "mysql.service" ];
483 users.users.${user} = {
484 group = webserver.group;
489 (mkIf (cfg.webserver == "nginx") {
492 virtualHosts = mapAttrs (hostName: cfg: {
493 serverName = mkDefault hostName;
494 root = "${pkg hostName cfg}/share/wordpress";
502 try_files $uri $uri/ /index.php$is_args$args;
508 fastcgi_split_path_info ^(.+\.php)(/.+)$;
509 fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket};
510 fastcgi_index index.php;
511 include "${config.services.nginx.package}/conf/fastcgi.conf";
512 fastcgi_param PATH_INFO $fastcgi_path_info;
513 fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
514 # Mitigate https://httpoxy.org/ vulnerabilities
515 fastcgi_param HTTP_PROXY "";
516 fastcgi_intercept_errors off;
517 fastcgi_buffer_size 16k;
518 fastcgi_buffers 4 16k;
519 fastcgi_connect_timeout 300;
520 fastcgi_send_timeout 300;
521 fastcgi_read_timeout 300;
526 extraConfig = "deny all;";
528 "~* /(?:uploads|files)/.*\\.php$" = {
530 extraConfig = "deny all;";
532 "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = {
544 (mkIf (cfg.webserver == "caddy") {
547 virtualHosts = mapAttrs' (hostName: cfg: (
548 nameValuePair "http://${hostName}" {
550 root * /${pkg hostName cfg}/share/wordpress
553 php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket}
556 path_regexp path /uploads\/(.*)\.php
561 path not ^\/wp-admin/*
563 rewrite @wp-admin {path}/index.php?{query}