diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 388f4788b59..9da9e0970ff 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -790,6 +790,7 @@
./services/web-apps/mattermost.nix
./services/web-apps/mediawiki.nix
./services/web-apps/miniflux.nix
+ ./services/web-apps/moodle.nix
./services/web-apps/nextcloud.nix
./services/web-apps/nexus.nix
./services/web-apps/pgpkeyserver-lite.nix
diff --git a/nixos/modules/services/web-apps/moodle.nix b/nixos/modules/services/web-apps/moodle.nix
new file mode 100644
index 00000000000..f2516c67c6b
--- /dev/null
+++ b/nixos/modules/services/web-apps/moodle.nix
@@ -0,0 +1,300 @@
+{ config, lib, pkgs, ... }:
+
+let
+ inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types;
+ inherit (lib) concatStringsSep literalExample mapAttrsToList optional optionalString;
+
+ cfg = config.services.moodle;
+ fpm = config.services.phpfpm.pools.moodle;
+
+ user = "moodle";
+ group = config.services.httpd.group;
+ stateDir = "/var/lib/moodle";
+
+ moodleConfig = pkgs.writeText "config.php" ''
+ dbtype = '${ { "mysql" = "mariadb"; "pgsql" = "pgsql"; }.${cfg.database.type} }';
+ $CFG->dblibrary = 'native';
+ $CFG->dbhost = '${cfg.database.host}';
+ $CFG->dbname = '${cfg.database.name}';
+ $CFG->dbuser = '${cfg.database.user}';
+ ${optionalString (cfg.database.passwordFile != null) "$CFG->dbpass = file_get_contents('${cfg.database.passwordFile}');"}
+ $CFG->prefix = 'mdl_';
+ $CFG->dboptions = array (
+ 'dbpersist' => 0,
+ 'dbport' => '${toString cfg.database.port}',
+ ${optionalString (cfg.database.socket != null) "'dbsocket' => '${cfg.database.socket}',"}
+ 'dbcollation' => 'utf8mb4_unicode_ci',
+ );
+
+ $CFG->wwwroot = '${if cfg.virtualHost.enableSSL then "https" else "http"}://${cfg.virtualHost.hostName}';
+ $CFG->dataroot = '${stateDir}';
+ $CFG->admin = 'admin';
+
+ $CFG->directorypermissions = 02777;
+ $CFG->disableupdateautodeploy = true;
+
+ $CFG->pathtogs = '${pkgs.ghostscript}/bin/gs';
+ $CFG->pathtophp = '${pkgs.php}/bin/php';
+ $CFG->pathtodu = '${pkgs.coreutils}/bin/du';
+ $CFG->aspellpath = '${pkgs.aspell}/bin/aspell';
+ $CFG->pathtodot = '${pkgs.graphviz}/bin/dot';
+
+ require_once('${cfg.package}/share/moodle/lib/setup.php');
+
+ // There is no php closing tag in this file,
+ // it is intentional because it prevents trailing whitespace problems!
+ '';
+
+ mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql";
+ pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql";
+in
+{
+ # interface
+ options.services.moodle = {
+ enable = mkEnableOption "Moodle web application";
+
+ package = mkOption {
+ type = types.package;
+ default = pkgs.moodle;
+ defaultText = "pkgs.moodle";
+ description = "The Moodle package to use.";
+ };
+
+ initialPassword = mkOption {
+ type = types.str;
+ example = "correcthorsebatterystaple";
+ description = ''
+ Specifies the initial password for the admin, i.e. the password assigned if the user does not already exist.
+ The password specified here is world-readable in the Nix store, so it should be changed promptly.
+ '';
+ };
+
+ database = {
+ type = mkOption {
+ type = types.enum [ "mysql" "pgsql" ];
+ default = "mysql";
+ description = ''Database engine to use.'';
+ };
+
+ host = mkOption {
+ type = types.str;
+ default = "localhost";
+ description = "Database host address.";
+ };
+
+ port = mkOption {
+ type = types.int;
+ description = "Database host port.";
+ default = {
+ "mysql" = 3306;
+ "pgsql" = 5432;
+ }.${cfg.database.type};
+ defaultText = "3306";
+ };
+
+ name = mkOption {
+ type = types.str;
+ default = "moodle";
+ description = "Database name.";
+ };
+
+ user = mkOption {
+ type = types.str;
+ default = "moodle";
+ description = "Database user.";
+ };
+
+ passwordFile = mkOption {
+ type = types.nullOr types.path;
+ default = null;
+ example = "/run/keys/moodle-dbpassword";
+ description = ''
+ A file containing the password corresponding to
+ .
+ '';
+ };
+
+ socket = mkOption {
+ type = types.nullOr types.path;
+ default =
+ if mysqlLocal then "/run/mysqld/mysqld.sock"
+ else if pgsqlLocal then "/run/postgresql"
+ else null;
+ defaultText = "/run/mysqld/mysqld.sock";
+ description = "Path to the unix socket file to use for authentication.";
+ };
+
+ createLocally = mkOption {
+ type = types.bool;
+ default = true;
+ description = "Create the database and database user locally.";
+ };
+ };
+
+ virtualHost = mkOption {
+ type = types.submodule ({
+ options = import ../web-servers/apache-httpd/per-server-options.nix {
+ inherit lib;
+ forMainServer = false;
+ };
+ });
+ example = {
+ hostName = "moodle.example.org";
+ enableSSL = true;
+ adminAddr = "webmaster@example.org";
+ sslServerCert = "/var/lib/acme/moodle.example.org/full.pem";
+ sslServerKey = "/var/lib/acme/moodle.example.org/key.pem";
+ };
+ description = ''
+ Apache configuration can be done by adapting .
+ See for further information.
+ '';
+ };
+
+ poolConfig = mkOption {
+ type = with types; attrsOf (oneOf [ str int bool ]);
+ default = {
+ "pm" = "dynamic";
+ "pm.max_children" = 32;
+ "pm.start_servers" = 2;
+ "pm.min_spare_servers" = 2;
+ "pm.max_spare_servers" = 4;
+ "pm.max_requests" = 500;
+ };
+ description = ''
+ Options for the Moodle PHP pool. See the documentation on php-fpm.conf
+ for details on configuration directives.
+ '';
+ };
+ };
+
+ # implementation
+ config = mkIf cfg.enable {
+
+ assertions = [
+ { assertion = cfg.database.createLocally -> cfg.database.user == user;
+ message = "services.moodle.database.user must be set to ${user} if services.moodle.database.createLocally is set true";
+ }
+ { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
+ message = "a password cannot be specified if services.moodle.database.createLocally is set to true";
+ }
+ ];
+
+ services.mysql = mkIf mysqlLocal {
+ enable = true;
+ package = mkDefault pkgs.mariadb;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [
+ { name = cfg.database.user;
+ ensurePermissions = {
+ "${cfg.database.name}.*" = "SELECT, INSERT, UPDATE, DELETE, CREATE, CREATE TEMPORARY TABLES, DROP, INDEX, ALTER";
+ };
+ }
+ ];
+ };
+
+ services.postgresql = mkIf pgsqlLocal {
+ enable = true;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [
+ { name = cfg.database.user;
+ ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; };
+ }
+ ];
+ };
+
+ services.phpfpm.pools.moodle = {
+ inherit user group;
+ phpEnv.MOODLE_CONFIG = "${moodleConfig}";
+ phpOptions = ''
+ zend_extension = opcache.so
+ opcache.enable = 1
+ '';
+ settings = {
+ "listen.owner" = config.services.httpd.user;
+ "listen.group" = config.services.httpd.group;
+ } // cfg.poolConfig;
+ };
+
+ services.httpd = {
+ enable = true;
+ adminAddr = mkDefault cfg.virtualHost.adminAddr;
+ extraModules = [ "proxy_fcgi" ];
+ virtualHosts = [ (mkMerge [
+ cfg.virtualHost {
+ documentRoot = mkForce "${cfg.package}/share/moodle";
+ extraConfig = ''
+
+
+
+ SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
+
+
+ Options -Indexes
+ DirectoryIndex index.php
+
+ '';
+ }
+ ]) ];
+ };
+
+ systemd.tmpfiles.rules = [
+ "d '${stateDir}' 0750 ${user} ${group} - -"
+ ];
+
+ systemd.services.moodle-init = {
+ wantedBy = [ "multi-user.target" ];
+ before = [ "phpfpm-moodle.service" ];
+ after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
+ environment.MOODLE_CONFIG = moodleConfig;
+ script = ''
+ ${pkgs.php}/bin/php ${cfg.package}/share/moodle/admin/cli/check_database_schema.php && rc=$? || rc=$?
+
+ [ "$rc" == 1 ] && ${pkgs.php}/bin/php ${cfg.package}/share/moodle/admin/cli/upgrade.php \
+ --non-interactive \
+ --allow-unstable
+
+ [ "$rc" == 2 ] && ${pkgs.php}/bin/php ${cfg.package}/share/moodle/admin/cli/install_database.php \
+ --agree-license \
+ --adminpass=${cfg.initialPassword}
+
+ true
+ '';
+ serviceConfig = {
+ User = user;
+ Group = group;
+ Type = "oneshot";
+ };
+ };
+
+ systemd.services.moodle-cron = {
+ description = "Moodle cron service";
+ after = [ "moodle-init.service" ];
+ environment.MOODLE_CONFIG = moodleConfig;
+ serviceConfig = {
+ User = user;
+ Group = group;
+ ExecStart = "${pkgs.php}/bin/php ${cfg.package}/share/moodle/admin/cli/cron.php";
+ };
+ };
+
+ systemd.timers.moodle-cron = {
+ description = "Moodle cron timer";
+ wantedBy = [ "timers.target" ];
+ timerConfig = {
+ OnCalendar = "minutely";
+ };
+ };
+
+ systemd.services.httpd.after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
+
+ users.users."${user}".group = group;
+
+ };
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index 36a053e8e6b..e3e4ddab72c 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -161,6 +161,7 @@ in
minio = handleTest ./minio.nix {};
misc = handleTest ./misc.nix {};
mongodb = handleTest ./mongodb.nix {};
+ moodle = handleTest ./moodle.nix {};
morty = handleTest ./morty.nix {};
mosquitto = handleTest ./mosquitto.nix {};
mpd = handleTest ./mpd.nix {};
diff --git a/nixos/tests/moodle.nix b/nixos/tests/moodle.nix
new file mode 100644
index 00000000000..565a6b63694
--- /dev/null
+++ b/nixos/tests/moodle.nix
@@ -0,0 +1,22 @@
+import ./make-test.nix ({ pkgs, lib, ... }: {
+ name = "moodle";
+ meta.maintainers = [ lib.maintainers.aanderse ];
+
+ machine =
+ { ... }:
+ { services.moodle.enable = true;
+ services.moodle.virtualHost.hostName = "localhost";
+ services.moodle.virtualHost.adminAddr = "root@example.com";
+ services.moodle.initialPassword = "correcthorsebatterystaple";
+
+ # Ensure the virtual machine has enough memory to avoid errors like:
+ # Fatal error: Out of memory (allocated 152047616) (tried to allocate 33554440 bytes)
+ virtualisation.memorySize = 2000;
+ };
+
+ testScript = ''
+ startAll;
+ $machine->waitForUnit('phpfpm-moodle.service');
+ $machine->succeed('curl http://localhost/') =~ /You are not logged in/ or die;
+ '';
+})