summary refs log tree commit diff
path: root/nixos/modules/services/web-apps
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules/services/web-apps')
-rw-r--r--nixos/modules/services/web-apps/atlassian/confluence.nix197
-rw-r--r--nixos/modules/services/web-apps/atlassian/crowd.nix164
-rw-r--r--nixos/modules/services/web-apps/atlassian/jira.nix204
-rw-r--r--nixos/modules/services/web-apps/baget.nix170
-rw-r--r--nixos/modules/services/web-apps/bookstack.nix449
-rw-r--r--nixos/modules/services/web-apps/calibre-web.nix165
-rw-r--r--nixos/modules/services/web-apps/code-server.nix139
-rw-r--r--nixos/modules/services/web-apps/convos.nix72
-rw-r--r--nixos/modules/services/web-apps/cryptpad.nix54
-rw-r--r--nixos/modules/services/web-apps/dex.nix118
-rw-r--r--nixos/modules/services/web-apps/discourse.nix1087
-rw-r--r--nixos/modules/services/web-apps/discourse.xml355
-rw-r--r--nixos/modules/services/web-apps/documize.nix150
-rw-r--r--nixos/modules/services/web-apps/dokuwiki.nix439
-rw-r--r--nixos/modules/services/web-apps/engelsystem.nix186
-rw-r--r--nixos/modules/services/web-apps/ethercalc.nix62
-rw-r--r--nixos/modules/services/web-apps/fluidd.nix66
-rw-r--r--nixos/modules/services/web-apps/galene.nix185
-rw-r--r--nixos/modules/services/web-apps/gerrit.nix242
-rw-r--r--nixos/modules/services/web-apps/gotify-server.nix49
-rw-r--r--nixos/modules/services/web-apps/grocy.nix172
-rw-r--r--nixos/modules/services/web-apps/grocy.xml77
-rw-r--r--nixos/modules/services/web-apps/hedgedoc.nix1038
-rw-r--r--nixos/modules/services/web-apps/hledger-web.nix142
-rw-r--r--nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix262
-rw-r--r--nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix157
-rw-r--r--nixos/modules/services/web-apps/ihatemoney/default.nix153
-rw-r--r--nixos/modules/services/web-apps/invidious.nix264
-rw-r--r--nixos/modules/services/web-apps/invoiceplane.nix305
-rw-r--r--nixos/modules/services/web-apps/isso.nix69
-rw-r--r--nixos/modules/services/web-apps/jirafeau.nix173
-rw-r--r--nixos/modules/services/web-apps/jitsi-meet.nix452
-rw-r--r--nixos/modules/services/web-apps/jitsi-meet.xml55
-rw-r--r--nixos/modules/services/web-apps/keycloak.nix819
-rw-r--r--nixos/modules/services/web-apps/keycloak.xml222
-rw-r--r--nixos/modules/services/web-apps/lemmy.md34
-rw-r--r--nixos/modules/services/web-apps/lemmy.nix236
-rw-r--r--nixos/modules/services/web-apps/lemmy.xml56
-rw-r--r--nixos/modules/services/web-apps/limesurvey.nix280
-rw-r--r--nixos/modules/services/web-apps/mastodon.nix636
-rw-r--r--nixos/modules/services/web-apps/matomo-doc.xml107
-rw-r--r--nixos/modules/services/web-apps/matomo.nix335
-rw-r--r--nixos/modules/services/web-apps/mattermost.nix344
-rw-r--r--nixos/modules/services/web-apps/mediawiki.nix475
-rw-r--r--nixos/modules/services/web-apps/miniflux.nix127
-rw-r--r--nixos/modules/services/web-apps/moodle.nix315
-rw-r--r--nixos/modules/services/web-apps/nextcloud.nix933
-rw-r--r--nixos/modules/services/web-apps/nextcloud.xml291
-rw-r--r--nixos/modules/services/web-apps/nexus.nix156
-rw-r--r--nixos/modules/services/web-apps/node-red.nix149
-rw-r--r--nixos/modules/services/web-apps/openwebrx.nix34
-rw-r--r--nixos/modules/services/web-apps/peertube.nix475
-rw-r--r--nixos/modules/services/web-apps/pgpkeyserver-lite.nix78
-rw-r--r--nixos/modules/services/web-apps/pict-rs.md88
-rw-r--r--nixos/modules/services/web-apps/pict-rs.nix50
-rw-r--r--nixos/modules/services/web-apps/pict-rs.xml162
-rw-r--r--nixos/modules/services/web-apps/plantuml-server.nix140
-rw-r--r--nixos/modules/services/web-apps/plausible.nix292
-rw-r--r--nixos/modules/services/web-apps/plausible.xml51
-rw-r--r--nixos/modules/services/web-apps/powerdns-admin.nix152
-rw-r--r--nixos/modules/services/web-apps/prosody-filer.nix86
-rw-r--r--nixos/modules/services/web-apps/restya-board.nix380
-rw-r--r--nixos/modules/services/web-apps/rss-bridge.nix125
-rw-r--r--nixos/modules/services/web-apps/selfoss.nix164
-rw-r--r--nixos/modules/services/web-apps/shiori.nix96
-rw-r--r--nixos/modules/services/web-apps/sogo.nix271
-rw-r--r--nixos/modules/services/web-apps/timetagger.nix80
-rw-r--r--nixos/modules/services/web-apps/trilium.nix146
-rw-r--r--nixos/modules/services/web-apps/tt-rss.nix686
-rw-r--r--nixos/modules/services/web-apps/vikunja.nix145
-rw-r--r--nixos/modules/services/web-apps/virtlyst.nix73
-rw-r--r--nixos/modules/services/web-apps/whitebophir.nix52
-rw-r--r--nixos/modules/services/web-apps/wiki-js.nix139
-rw-r--r--nixos/modules/services/web-apps/wordpress.nix480
-rw-r--r--nixos/modules/services/web-apps/youtrack.nix181
-rw-r--r--nixos/modules/services/web-apps/zabbix.nix238
76 files changed, 18251 insertions, 0 deletions
diff --git a/nixos/modules/services/web-apps/atlassian/confluence.nix b/nixos/modules/services/web-apps/atlassian/confluence.nix
new file mode 100644
index 00000000000..2d809c17ff0
--- /dev/null
+++ b/nixos/modules/services/web-apps/atlassian/confluence.nix
@@ -0,0 +1,197 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.confluence;
+
+  pkg = cfg.package.override (optionalAttrs cfg.sso.enable {
+    enableSSO = cfg.sso.enable;
+    crowdProperties = ''
+      application.name                        ${cfg.sso.applicationName}
+      application.password                    ${cfg.sso.applicationPassword}
+      application.login.url                   ${cfg.sso.crowd}/console/
+
+      crowd.server.url                        ${cfg.sso.crowd}/services/
+      crowd.base.url                          ${cfg.sso.crowd}/
+
+      session.isauthenticated                 session.isauthenticated
+      session.tokenkey                        session.tokenkey
+      session.validationinterval              ${toString cfg.sso.validationInterval}
+      session.lastvalidation                  session.lastvalidation
+    '';
+  });
+
+in
+
+{
+  options = {
+    services.confluence = {
+      enable = mkEnableOption "Atlassian Confluence service";
+
+      user = mkOption {
+        type = types.str;
+        default = "confluence";
+        description = "User which runs confluence.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "confluence";
+        description = "Group which runs confluence.";
+      };
+
+      home = mkOption {
+        type = types.str;
+        default = "/var/lib/confluence";
+        description = "Home directory of the confluence instance.";
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Address to listen on.";
+      };
+
+      listenPort = mkOption {
+        type = types.int;
+        default = 8090;
+        description = "Port to listen on.";
+      };
+
+      catalinaOptions = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "-Xms1024m" "-Xmx2048m" "-Dconfluence.disable.peopledirectory.all=true" ];
+        description = "Java options to pass to catalina/tomcat.";
+      };
+
+      proxy = {
+        enable = mkEnableOption "proxy support";
+
+        name = mkOption {
+          type = types.str;
+          example = "confluence.example.com";
+          description = "Virtual hostname at the proxy";
+        };
+
+        port = mkOption {
+          type = types.int;
+          default = 443;
+          example = 80;
+          description = "Port used at the proxy";
+        };
+
+        scheme = mkOption {
+          type = types.str;
+          default = "https";
+          example = "http";
+          description = "Protocol used at the proxy.";
+        };
+      };
+
+      sso = {
+        enable = mkEnableOption "SSO with Atlassian Crowd";
+
+        crowd = mkOption {
+          type = types.str;
+          example = "http://localhost:8095/crowd";
+          description = "Crowd Base URL without trailing slash";
+        };
+
+        applicationName = mkOption {
+          type = types.str;
+          example = "jira";
+          description = "Exact name of this Confluence instance in Crowd";
+        };
+
+        applicationPassword = mkOption {
+          type = types.str;
+          description = "Application password of this Confluence instance in Crowd";
+        };
+
+        validationInterval = mkOption {
+          type = types.int;
+          default = 2;
+          example = 0;
+          description = ''
+            Set to 0, if you want authentication checks to occur on each
+            request. Otherwise set to the number of minutes between request
+            to validate if the user is logged in or out of the Crowd SSO
+            server. Setting this value to 1 or higher will increase the
+            performance of Crowd's integration.
+          '';
+        };
+      };
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.atlassian-confluence;
+        defaultText = literalExpression "pkgs.atlassian-confluence";
+        description = "Atlassian Confluence package to use.";
+      };
+
+      jrePackage = mkOption {
+        type = types.package;
+        default = pkgs.oraclejre8;
+        defaultText = literalExpression "pkgs.oraclejre8";
+        description = "Note that Atlassian only support the Oracle JRE (JRASERVER-46152).";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+    };
+
+    users.groups.${cfg.group} = {};
+
+    systemd.tmpfiles.rules = [
+      "d '${cfg.home}' - ${cfg.user} - - -"
+      "d /run/confluence - - - - -"
+
+      "L+ /run/confluence/home - - - - ${cfg.home}"
+      "L+ /run/confluence/logs - - - - ${cfg.home}/logs"
+      "L+ /run/confluence/temp - - - - ${cfg.home}/temp"
+      "L+ /run/confluence/work - - - - ${cfg.home}/work"
+      "L+ /run/confluence/server.xml - - - - ${cfg.home}/server.xml"
+    ];
+
+    systemd.services.confluence = {
+      description = "Atlassian Confluence";
+
+      wantedBy = [ "multi-user.target" ];
+      requires = [ "postgresql.service" ];
+      after = [ "postgresql.service" ];
+
+      path = [ cfg.jrePackage pkgs.bash ];
+
+      environment = {
+        CONF_USER = cfg.user;
+        JAVA_HOME = "${cfg.jrePackage}";
+        CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions;
+      };
+
+      preStart = ''
+        mkdir -p ${cfg.home}/{logs,work,temp,deploy}
+
+        sed -e 's,port="8090",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \
+        '' + (lib.optionalString cfg.proxy.enable ''
+          -e 's,protocol="org.apache.coyote.http11.Http11NioProtocol",protocol="org.apache.coyote.http11.Http11NioProtocol" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}",' \
+        '') + ''
+          ${pkg}/conf/server.xml.dist > ${cfg.home}/server.xml
+      '';
+
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        PrivateTmp = true;
+        ExecStart = "${pkg}/bin/start-confluence.sh -fg";
+        ExecStop = "${pkg}/bin/stop-confluence.sh";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/atlassian/crowd.nix b/nixos/modules/services/web-apps/atlassian/crowd.nix
new file mode 100644
index 00000000000..a8b2482d5a9
--- /dev/null
+++ b/nixos/modules/services/web-apps/atlassian/crowd.nix
@@ -0,0 +1,164 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.crowd;
+
+  pkg = cfg.package.override {
+    home = cfg.home;
+    port = cfg.listenPort;
+    openidPassword = cfg.openidPassword;
+  } // (optionalAttrs cfg.proxy.enable {
+    proxyUrl = "${cfg.proxy.scheme}://${cfg.proxy.name}:${toString cfg.proxy.port}";
+  });
+
+in
+
+{
+  options = {
+    services.crowd = {
+      enable = mkEnableOption "Atlassian Crowd service";
+
+      user = mkOption {
+        type = types.str;
+        default = "crowd";
+        description = "User which runs Crowd.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "crowd";
+        description = "Group which runs Crowd.";
+      };
+
+      home = mkOption {
+        type = types.str;
+        default = "/var/lib/crowd";
+        description = "Home directory of the Crowd instance.";
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Address to listen on.";
+      };
+
+      listenPort = mkOption {
+        type = types.int;
+        default = 8092;
+        description = "Port to listen on.";
+      };
+
+      openidPassword = mkOption {
+        type = types.str;
+        description = "Application password for OpenID server.";
+      };
+
+      catalinaOptions = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "-Xms1024m" "-Xmx2048m" ];
+        description = "Java options to pass to catalina/tomcat.";
+      };
+
+      proxy = {
+        enable = mkEnableOption "reverse proxy support";
+
+        name = mkOption {
+          type = types.str;
+          example = "crowd.example.com";
+          description = "Virtual hostname at the proxy";
+        };
+
+        port = mkOption {
+          type = types.int;
+          default = 443;
+          example = 80;
+          description = "Port used at the proxy";
+        };
+
+        scheme = mkOption {
+          type = types.str;
+          default = "https";
+          example = "http";
+          description = "Protocol used at the proxy.";
+        };
+
+        secure = mkOption {
+          type = types.bool;
+          default = true;
+          description = "Whether the connections to the proxy should be considered secure.";
+        };
+      };
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.atlassian-crowd;
+        defaultText = literalExpression "pkgs.atlassian-crowd";
+        description = "Atlassian Crowd package to use.";
+      };
+
+      jrePackage = mkOption {
+        type = types.package;
+        default = pkgs.oraclejre8;
+        defaultText = literalExpression "pkgs.oraclejre8";
+        description = "Note that Atlassian only support the Oracle JRE (JRASERVER-46152).";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+    };
+
+    users.groups.${cfg.group} = {};
+
+    systemd.tmpfiles.rules = [
+      "d '${cfg.home}' - ${cfg.user} ${cfg.group} - -"
+      "d /run/atlassian-crowd - - - - -"
+
+      "L+ /run/atlassian-crowd/database - - - - ${cfg.home}/database"
+      "L+ /run/atlassian-crowd/logs - - - - ${cfg.home}/logs"
+      "L+ /run/atlassian-crowd/work - - - - ${cfg.home}/work"
+      "L+ /run/atlassian-crowd/server.xml - - - - ${cfg.home}/server.xml"
+    ];
+
+    systemd.services.atlassian-crowd = {
+      description = "Atlassian Crowd";
+
+      wantedBy = [ "multi-user.target" ];
+      requires = [ "postgresql.service" ];
+      after = [ "postgresql.service" ];
+
+      path = [ cfg.jrePackage ];
+
+      environment = {
+        JAVA_HOME = "${cfg.jrePackage}";
+        CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions;
+        CATALINA_TMPDIR = "/tmp";
+      };
+
+      preStart = ''
+        rm -rf ${cfg.home}/work
+        mkdir -p ${cfg.home}/{logs,database,work}
+
+        sed -e 's,port="8095",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \
+        '' + (lib.optionalString cfg.proxy.enable ''
+          -e 's,compression="on",compression="off" protocol="HTTP/1.1" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}" secure="${boolToString cfg.proxy.secure}",' \
+        '') + ''
+          ${pkg}/apache-tomcat/conf/server.xml.dist > ${cfg.home}/server.xml
+      '';
+
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        PrivateTmp = true;
+        ExecStart = "${pkg}/start_crowd.sh -fg";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/atlassian/jira.nix b/nixos/modules/services/web-apps/atlassian/jira.nix
new file mode 100644
index 00000000000..d7a26838d6f
--- /dev/null
+++ b/nixos/modules/services/web-apps/atlassian/jira.nix
@@ -0,0 +1,204 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.jira;
+
+  pkg = cfg.package.override (optionalAttrs cfg.sso.enable {
+    enableSSO = cfg.sso.enable;
+    crowdProperties = ''
+      application.name                        ${cfg.sso.applicationName}
+      application.password                    ${cfg.sso.applicationPassword}
+      application.login.url                   ${cfg.sso.crowd}/console/
+
+      crowd.server.url                        ${cfg.sso.crowd}/services/
+      crowd.base.url                          ${cfg.sso.crowd}/
+
+      session.isauthenticated                 session.isauthenticated
+      session.tokenkey                        session.tokenkey
+      session.validationinterval              ${toString cfg.sso.validationInterval}
+      session.lastvalidation                  session.lastvalidation
+    '';
+  });
+
+in
+
+{
+  options = {
+    services.jira = {
+      enable = mkEnableOption "Atlassian JIRA service";
+
+      user = mkOption {
+        type = types.str;
+        default = "jira";
+        description = "User which runs JIRA.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "jira";
+        description = "Group which runs JIRA.";
+      };
+
+      home = mkOption {
+        type = types.str;
+        default = "/var/lib/jira";
+        description = "Home directory of the JIRA instance.";
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Address to listen on.";
+      };
+
+      listenPort = mkOption {
+        type = types.int;
+        default = 8091;
+        description = "Port to listen on.";
+      };
+
+      catalinaOptions = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "-Xms1024m" "-Xmx2048m" ];
+        description = "Java options to pass to catalina/tomcat.";
+      };
+
+      proxy = {
+        enable = mkEnableOption "reverse proxy support";
+
+        name = mkOption {
+          type = types.str;
+          example = "jira.example.com";
+          description = "Virtual hostname at the proxy";
+        };
+
+        port = mkOption {
+          type = types.int;
+          default = 443;
+          example = 80;
+          description = "Port used at the proxy";
+        };
+
+        scheme = mkOption {
+          type = types.str;
+          default = "https";
+          example = "http";
+          description = "Protocol used at the proxy.";
+        };
+
+        secure = mkOption {
+          type = types.bool;
+          default = true;
+          description = "Whether the connections to the proxy should be considered secure.";
+        };
+      };
+
+      sso = {
+        enable = mkEnableOption "SSO with Atlassian Crowd";
+
+        crowd = mkOption {
+          type = types.str;
+          example = "http://localhost:8095/crowd";
+          description = "Crowd Base URL without trailing slash";
+        };
+
+        applicationName = mkOption {
+          type = types.str;
+          example = "jira";
+          description = "Exact name of this JIRA instance in Crowd";
+        };
+
+        applicationPassword = mkOption {
+          type = types.str;
+          description = "Application password of this JIRA instance in Crowd";
+        };
+
+        validationInterval = mkOption {
+          type = types.int;
+          default = 2;
+          example = 0;
+          description = ''
+            Set to 0, if you want authentication checks to occur on each
+            request. Otherwise set to the number of minutes between request
+            to validate if the user is logged in or out of the Crowd SSO
+            server. Setting this value to 1 or higher will increase the
+            performance of Crowd's integration.
+          '';
+        };
+      };
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.atlassian-jira;
+        defaultText = literalExpression "pkgs.atlassian-jira";
+        description = "Atlassian JIRA package to use.";
+      };
+
+      jrePackage = mkOption {
+        type = types.package;
+        default = pkgs.oraclejre8;
+        defaultText = literalExpression "pkgs.oraclejre8";
+        description = "Note that Atlassian only support the Oracle JRE (JRASERVER-46152).";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+    };
+
+    users.groups.${cfg.group} = {};
+
+    systemd.tmpfiles.rules = [
+      "d '${cfg.home}' - ${cfg.user} - - -"
+      "d /run/atlassian-jira - - - - -"
+
+      "L+ /run/atlassian-jira/home - - - - ${cfg.home}"
+      "L+ /run/atlassian-jira/logs - - - - ${cfg.home}/logs"
+      "L+ /run/atlassian-jira/work - - - - ${cfg.home}/work"
+      "L+ /run/atlassian-jira/temp - - - - ${cfg.home}/temp"
+      "L+ /run/atlassian-jira/server.xml - - - - ${cfg.home}/server.xml"
+    ];
+
+    systemd.services.atlassian-jira = {
+      description = "Atlassian JIRA";
+
+      wantedBy = [ "multi-user.target" ];
+      requires = [ "postgresql.service" ];
+      after = [ "postgresql.service" ];
+
+      path = [ cfg.jrePackage pkgs.bash ];
+
+      environment = {
+        JIRA_USER = cfg.user;
+        JIRA_HOME = cfg.home;
+        JAVA_HOME = "${cfg.jrePackage}";
+        CATALINA_OPTS = concatStringsSep " " cfg.catalinaOptions;
+      };
+
+      preStart = ''
+        mkdir -p ${cfg.home}/{logs,work,temp,deploy}
+
+        sed -e 's,port="8080",port="${toString cfg.listenPort}" address="${cfg.listenAddress}",' \
+        '' + (lib.optionalString cfg.proxy.enable ''
+          -e 's,protocol="HTTP/1.1",protocol="HTTP/1.1" proxyName="${cfg.proxy.name}" proxyPort="${toString cfg.proxy.port}" scheme="${cfg.proxy.scheme}" secure="${toString cfg.proxy.secure}",' \
+        '') + ''
+          ${pkg}/conf/server.xml.dist > ${cfg.home}/server.xml
+      '';
+
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        PrivateTmp = true;
+        ExecStart = "${pkg}/bin/start-jira.sh -fg";
+        ExecStop = "${pkg}/bin/stop-jira.sh";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/baget.nix b/nixos/modules/services/web-apps/baget.nix
new file mode 100644
index 00000000000..3007dd4fbb2
--- /dev/null
+++ b/nixos/modules/services/web-apps/baget.nix
@@ -0,0 +1,170 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.baget;
+
+  defaultConfig = {
+    "PackageDeletionBehavior" = "Unlist";
+    "AllowPackageOverwrites" = false;
+
+    "Database" = {
+      "Type" = "Sqlite";
+      "ConnectionString" = "Data Source=baget.db";
+    };
+
+    "Storage" = {
+      "Type" = "FileSystem";
+      "Path" = "";
+    };
+
+    "Search" = {
+      "Type" = "Database";
+    };
+
+    "Mirror" = {
+      "Enabled" = false;
+      "PackageSource" = "https://api.nuget.org/v3/index.json";
+    };
+
+    "Logging" = {
+      "IncludeScopes" = false;
+      "Debug" = {
+        "LogLevel" = {
+          "Default" = "Warning";
+        };
+      };
+      "Console" = {
+        "LogLevel" = {
+          "Microsoft.Hosting.Lifetime" = "Information";
+          "Default" = "Warning";
+        };
+      };
+    };
+  };
+
+  configAttrs = recursiveUpdate defaultConfig cfg.extraConfig;
+
+  configFormat = pkgs.formats.json {};
+  configFile = configFormat.generate "appsettings.json" configAttrs;
+
+in
+{
+  options.services.baget = {
+    enable = mkEnableOption "BaGet NuGet-compatible server";
+
+    apiKeyFile = mkOption {
+      type = types.path;
+      example = "/root/baget.key";
+      description = ''
+        Private API key for BaGet.
+      '';
+    };
+
+    extraConfig = mkOption {
+      type = configFormat.type;
+      default = {};
+      example = {
+        "Database" = {
+          "Type" = "PostgreSql";
+          "ConnectionString" = "Server=/run/postgresql;Port=5432;";
+        };
+      };
+      defaultText = literalExpression ''
+        {
+          "PackageDeletionBehavior" = "Unlist";
+          "AllowPackageOverwrites" = false;
+
+          "Database" = {
+            "Type" = "Sqlite";
+            "ConnectionString" = "Data Source=baget.db";
+          };
+
+          "Storage" = {
+            "Type" = "FileSystem";
+            "Path" = "";
+          };
+
+          "Search" = {
+            "Type" = "Database";
+          };
+
+          "Mirror" = {
+            "Enabled" = false;
+            "PackageSource" = "https://api.nuget.org/v3/index.json";
+          };
+
+          "Logging" = {
+            "IncludeScopes" = false;
+            "Debug" = {
+              "LogLevel" = {
+                "Default" = "Warning";
+              };
+            };
+            "Console" = {
+              "LogLevel" = {
+                "Microsoft.Hosting.Lifetime" = "Information";
+                "Default" = "Warning";
+              };
+            };
+          };
+        }
+      '';
+      description = ''
+        Extra configuration options for BaGet. Refer to <link xlink:href="https://loic-sharma.github.io/BaGet/configuration/"/> for details.
+        Default value is merged with values from here.
+      '';
+    };
+  };
+
+  # implementation
+
+  config = mkIf cfg.enable {
+
+    systemd.services.baget = {
+      description = "BaGet server";
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ];
+      after = [ "network.target" "network-online.target" ];
+      path = [ pkgs.jq ];
+      serviceConfig = {
+        WorkingDirectory = "/var/lib/baget";
+        DynamicUser = true;
+        StateDirectory = "baget";
+        StateDirectoryMode = "0700";
+        LoadCredential = "api_key:${cfg.apiKeyFile}";
+
+        CapabilityBoundingSet = "";
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        PrivateTmp = true;
+        PrivateUsers = true;
+        PrivateMounts = true;
+        ProtectHome = true;
+        ProtectClock = true;
+        ProtectProc = "noaccess";
+        ProcSubset = "pid";
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectControlGroups = true;
+        ProtectHostname = true;
+        RestrictSUIDSGID = true;
+        RestrictRealtime = true;
+        RestrictNamespaces = true;
+        LockPersonality = true;
+        RemoveIPC = true;
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
+        SystemCallFilter = [ "@system-service" "~@privileged" ];
+      };
+      script = ''
+        jq --slurpfile apiKeys <(jq -R . "$CREDENTIALS_DIRECTORY/api_key") '.ApiKey = $apiKeys[0]' ${configFile} > appsettings.json
+        ln -snf ${pkgs.baget}/lib/BaGet/wwwroot wwwroot
+        exec ${pkgs.baget}/bin/BaGet
+      '';
+    };
+
+  };
+}
diff --git a/nixos/modules/services/web-apps/bookstack.nix b/nixos/modules/services/web-apps/bookstack.nix
new file mode 100644
index 00000000000..64a2767fab6
--- /dev/null
+++ b/nixos/modules/services/web-apps/bookstack.nix
@@ -0,0 +1,449 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.bookstack;
+  bookstack = pkgs.bookstack.override {
+    dataDir = cfg.dataDir;
+  };
+  db = cfg.database;
+  mail = cfg.mail;
+
+  user = cfg.user;
+  group = cfg.group;
+
+  # shell script for local administration
+  artisan = pkgs.writeScriptBin "bookstack" ''
+    #! ${pkgs.runtimeShell}
+    cd ${bookstack}
+    sudo=exec
+    if [[ "$USER" != ${user} ]]; then
+      sudo='exec /run/wrappers/bin/sudo -u ${user}'
+    fi
+    $sudo ${pkgs.php}/bin/php artisan $*
+  '';
+
+  tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
+
+in {
+  imports = [
+    (mkRemovedOptionModule [ "services" "bookstack" "extraConfig" ] "Use services.bookstack.config instead.")
+    (mkRemovedOptionModule [ "services" "bookstack" "cacheDir" ] "The cache directory is now handled automatically.")
+  ];
+
+  options.services.bookstack = {
+
+    enable = mkEnableOption "BookStack";
+
+    user = mkOption {
+      default = "bookstack";
+      description = "User bookstack runs as.";
+      type = types.str;
+    };
+
+    group = mkOption {
+      default = "bookstack";
+      description = "Group bookstack runs as.";
+      type = types.str;
+    };
+
+    appKeyFile = mkOption {
+      description = ''
+        A file containing the Laravel APP_KEY - a 32 character long,
+        base64 encoded key used for encryption where needed. Can be
+        generated with <code>head -c 32 /dev/urandom | base64</code>.
+      '';
+      example = "/run/keys/bookstack-appkey";
+      type = types.path;
+    };
+
+    hostname = lib.mkOption {
+      type = lib.types.str;
+      default = if config.networking.domain != null then
+                  config.networking.fqdn
+                else
+                  config.networking.hostName;
+      defaultText = lib.literalExpression "config.networking.fqdn";
+      example = "bookstack.example.com";
+      description = ''
+        The hostname to serve BookStack on.
+      '';
+    };
+
+    appURL = mkOption {
+      description = ''
+        The root URL that you want to host BookStack on. All URLs in BookStack will be generated using this value.
+        If you change this in the future you may need to run a command to update stored URLs in the database. Command example: <code>php artisan bookstack:update-url https://old.example.com https://new.example.com</code>
+      '';
+      default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
+      defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostname}'';
+      example = "https://example.com";
+      type = types.str;
+    };
+
+    dataDir = mkOption {
+      description = "BookStack data directory";
+      default = "/var/lib/bookstack";
+      type = types.path;
+    };
+
+    database = {
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = "Database host address.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 3306;
+        description = "Database host port.";
+      };
+      name = mkOption {
+        type = types.str;
+        default = "bookstack";
+        description = "Database name.";
+      };
+      user = mkOption {
+        type = types.str;
+        default = user;
+        defaultText = literalExpression "user";
+        description = "Database username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/bookstack-dbpassword";
+        description = ''
+          A file containing the password corresponding to
+          <option>database.user</option>.
+        '';
+      };
+      createLocally = mkOption {
+        type = types.bool;
+        default = false;
+        description = "Create the database and database user locally.";
+      };
+    };
+
+    mail = {
+      driver = mkOption {
+        type = types.enum [ "smtp" "sendmail" ];
+        default = "smtp";
+        description = "Mail driver to use.";
+      };
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = "Mail host address.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 1025;
+        description = "Mail host port.";
+      };
+      fromName = mkOption {
+        type = types.str;
+        default = "BookStack";
+        description = "Mail \"from\" name.";
+      };
+      from = mkOption {
+        type = types.str;
+        default = "mail@bookstackapp.com";
+        description = "Mail \"from\" email.";
+      };
+      user = mkOption {
+        type = with types; nullOr str;
+        default = null;
+        example = "bookstack";
+        description = "Mail username.";
+      };
+      passwordFile = mkOption {
+        type = with types; nullOr path;
+        default = null;
+        example = "/run/keys/bookstack-mailpassword";
+        description = ''
+          A file containing the password corresponding to
+          <option>mail.user</option>.
+        '';
+      };
+      encryption = mkOption {
+        type = with types; nullOr (enum [ "tls" ]);
+        default = null;
+        description = "SMTP encryption mechanism to use.";
+      };
+    };
+
+    maxUploadSize = mkOption {
+      type = types.str;
+      default = "18M";
+      example = "1G";
+      description = "The maximum size for uploads (e.g. images).";
+    };
+
+    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 bookstack PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+        for details on configuration directives.
+      '';
+    };
+
+    nginx = mkOption {
+      type = types.submodule (
+        recursiveUpdate
+          (import ../web-servers/nginx/vhost-options.nix { inherit config lib; }) {}
+      );
+      default = {};
+      example = literalExpression ''
+        {
+          serverAliases = [
+            "bookstack.''${config.networking.domain}"
+          ];
+          # To enable encryption and let let's encrypt take care of certificate
+          forceSSL = true;
+          enableACME = true;
+        }
+      '';
+      description = ''
+        With this option, you can customize the nginx virtualHost settings.
+      '';
+    };
+
+    config = mkOption {
+      type = with types;
+        attrsOf
+          (nullOr
+            (either
+              (oneOf [
+                bool
+                int
+                port
+                path
+                str
+              ])
+              (submodule {
+                options = {
+                  _secret = mkOption {
+                    type = nullOr str;
+                    description = ''
+                      The path to a file containing the value the
+                      option should be set to in the final
+                      configuration file.
+                    '';
+                  };
+                };
+              })));
+      default = {};
+      example = literalExpression ''
+        {
+          ALLOWED_IFRAME_HOSTS = "https://example.com";
+          WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf";
+          AUTH_METHOD = "oidc";
+          OIDC_NAME = "MyLogin";
+          OIDC_DISPLAY_NAME_CLAIMS = "name";
+          OIDC_CLIENT_ID = "bookstack";
+          OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
+          OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
+          OIDC_ISSUER_DISCOVER = true;
+        }
+      '';
+      description = ''
+        BookStack configuration options to set in the
+        <filename>.env</filename> file.
+
+        Refer to <link xlink:href="https://www.bookstackapp.com/docs/"/>
+        for details on supported values.
+
+        Settings containing secret data should be set to an attribute
+        set containing the attribute <literal>_secret</literal> - a
+        string pointing to a file containing the value the option
+        should be set to. See the example to get a better picture of
+        this: in the resulting <filename>.env</filename> file, the
+        <literal>OIDC_CLIENT_SECRET</literal> key will be set to the
+        contents of the <filename>/run/keys/oidc_secret</filename>
+        file.
+      '';
+    };
+
+  };
+
+  config = mkIf cfg.enable {
+
+    assertions = [
+      { assertion = db.createLocally -> db.user == user;
+        message = "services.bookstack.database.user must be set to ${user} if services.bookstack.database.createLocally is set true.";
+      }
+      { assertion = db.createLocally -> db.passwordFile == null;
+        message = "services.bookstack.database.passwordFile cannot be specified if services.bookstack.database.createLocally is set to true.";
+      }
+    ];
+
+    services.bookstack.config = {
+      APP_KEY._secret = cfg.appKeyFile;
+      APP_URL = cfg.appURL;
+      DB_HOST = db.host;
+      DB_PORT = db.port;
+      DB_DATABASE = db.name;
+      DB_USERNAME = db.user;
+      MAIL_DRIVER = mail.driver;
+      MAIL_FROM_NAME = mail.fromName;
+      MAIL_FROM = mail.from;
+      MAIL_HOST = mail.host;
+      MAIL_PORT = mail.port;
+      MAIL_USERNAME = mail.user;
+      MAIL_ENCRYPTION = mail.encryption;
+      DB_PASSWORD._secret = db.passwordFile;
+      MAIL_PASSWORD._secret = mail.passwordFile;
+      APP_SERVICES_CACHE = "/run/bookstack/cache/services.php";
+      APP_PACKAGES_CACHE = "/run/bookstack/cache/packages.php";
+      APP_CONFIG_CACHE = "/run/bookstack/cache/config.php";
+      APP_ROUTES_CACHE = "/run/bookstack/cache/routes-v7.php";
+      APP_EVENTS_CACHE = "/run/bookstack/cache/events.php";
+      SESSION_SECURE_COOKIE = tlsEnabled;
+    };
+
+    environment.systemPackages = [ artisan ];
+
+    services.mysql = mkIf db.createLocally {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = [ db.name ];
+      ensureUsers = [
+        { name = db.user;
+          ensurePermissions = { "${db.name}.*" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    services.phpfpm.pools.bookstack = {
+      inherit user;
+      inherit group;
+      phpOptions = ''
+        log_errors = on
+        post_max_size = ${cfg.maxUploadSize}
+        upload_max_filesize = ${cfg.maxUploadSize}
+      '';
+      settings = {
+        "listen.mode" = "0660";
+        "listen.owner" = user;
+        "listen.group" = group;
+      } // cfg.poolConfig;
+    };
+
+    services.nginx = {
+      enable = mkDefault true;
+      recommendedTlsSettings = true;
+      recommendedOptimisation = true;
+      recommendedGzipSettings = true;
+      virtualHosts.${cfg.hostname} = mkMerge [ cfg.nginx {
+        root = mkForce "${bookstack}/public";
+        locations = {
+          "/" = {
+            index = "index.php";
+            tryFiles = "$uri $uri/ /index.php?$query_string";
+          };
+          "~ \.php$".extraConfig = ''
+            fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket};
+          '';
+          "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = {
+            extraConfig = "expires 365d;";
+          };
+        };
+      }];
+    };
+
+    systemd.services.bookstack-setup = {
+      description = "Preperation tasks for BookStack";
+      before = [ "phpfpm-bookstack.service" ];
+      after = optional db.createLocally "mysql.service";
+      wantedBy = [ "multi-user.target" ];
+      serviceConfig = {
+        Type = "oneshot";
+        RemainAfterExit = true;
+        User = user;
+        WorkingDirectory = "${bookstack}";
+        RuntimeDirectory = "bookstack/cache";
+        RuntimeDirectoryMode = 0700;
+      };
+      path = [ pkgs.replace-secret ];
+      script =
+        let
+          isSecret = v: isAttrs v && v ? _secret && isString v._secret;
+          bookstackEnvVars = lib.generators.toKeyValue {
+            mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
+              mkValueString = v: with builtins;
+                if isInt         v then toString v
+                else if isString v then v
+                else if true  == v then "true"
+                else if false == v then "false"
+                else if isSecret v then hashString "sha256" v._secret
+                else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
+            };
+          };
+          secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
+          mkSecretReplacement = file: ''
+            replace-secret ${escapeShellArgs [ (builtins.hashString "sha256" file) file "${cfg.dataDir}/.env" ]}
+          '';
+          secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
+          filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ {} null ])) cfg.config;
+          bookstackEnv = pkgs.writeText "bookstack.env" (bookstackEnvVars filteredConfig);
+        in ''
+        # error handling
+        set -euo pipefail
+
+        # set permissions
+        umask 077
+
+        # create .env file
+        install -T -m 0600 -o ${user} ${bookstackEnv} "${cfg.dataDir}/.env"
+        ${secretReplacements}
+        if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
+            sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
+        fi
+
+        # migrate db
+        ${pkgs.php}/bin/php artisan migrate --force
+      '';
+    };
+
+    systemd.tmpfiles.rules = [
+      "d ${cfg.dataDir}                            0710 ${user} ${group} - -"
+      "d ${cfg.dataDir}/public                     0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/public/uploads             0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage                    0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/app                0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/fonts              0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework          0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework/cache    0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework/sessions 0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/framework/views    0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/logs               0700 ${user} ${group} - -"
+      "d ${cfg.dataDir}/storage/uploads            0700 ${user} ${group} - -"
+    ];
+
+    users = {
+      users = mkIf (user == "bookstack") {
+        bookstack = {
+          inherit group;
+          isSystemUser = true;
+        };
+        "${config.services.nginx.user}".extraGroups = [ group ];
+      };
+      groups = mkIf (group == "bookstack") {
+        bookstack = {};
+      };
+    };
+
+  };
+
+  meta.maintainers = with maintainers; [ ymarkus ];
+}
diff --git a/nixos/modules/services/web-apps/calibre-web.nix b/nixos/modules/services/web-apps/calibre-web.nix
new file mode 100644
index 00000000000..704cd2cfa8a
--- /dev/null
+++ b/nixos/modules/services/web-apps/calibre-web.nix
@@ -0,0 +1,165 @@
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.calibre-web;
+
+  inherit (lib) concatStringsSep mkEnableOption mkIf mkOption optional optionalString types;
+in
+{
+  options = {
+    services.calibre-web = {
+      enable = mkEnableOption "Calibre-Web";
+
+      listen = {
+        ip = mkOption {
+          type = types.str;
+          default = "::1";
+          description = ''
+            IP address that Calibre-Web should listen on.
+          '';
+        };
+
+        port = mkOption {
+          type = types.port;
+          default = 8083;
+          description = ''
+            Listen port for Calibre-Web.
+          '';
+        };
+      };
+
+      dataDir = mkOption {
+        type = types.str;
+        default = "calibre-web";
+        description = ''
+          The directory below <filename>/var/lib</filename> where Calibre-Web stores its data.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "calibre-web";
+        description = "User account under which Calibre-Web runs.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "calibre-web";
+        description = "Group account under which Calibre-Web runs.";
+      };
+
+      openFirewall = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Open ports in the firewall for the server.
+        '';
+      };
+
+      options = {
+        calibreLibrary = mkOption {
+          type = types.nullOr types.path;
+          default = null;
+          description = ''
+            Path to Calibre library.
+          '';
+        };
+
+        enableBookConversion = mkOption {
+          type = types.bool;
+          default = false;
+          description = ''
+            Configure path to the Calibre's ebook-convert in the DB.
+          '';
+        };
+
+        enableBookUploading = mkOption {
+          type = types.bool;
+          default = false;
+          description = ''
+            Allow books to be uploaded via Calibre-Web UI.
+          '';
+        };
+
+        reverseProxyAuth = {
+          enable = mkOption {
+            type = types.bool;
+            default = false;
+            description = ''
+              Enable authorization using auth proxy.
+            '';
+          };
+
+          header = mkOption {
+            type = types.str;
+            default = "";
+            description = ''
+              Auth proxy header name.
+            '';
+          };
+        };
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.calibre-web = let
+      appDb = "/var/lib/${cfg.dataDir}/app.db";
+      gdriveDb = "/var/lib/${cfg.dataDir}/gdrive.db";
+      calibreWebCmd = "${pkgs.calibre-web}/bin/calibre-web -p ${appDb} -g ${gdriveDb}";
+
+      settings = concatStringsSep ", " (
+        [
+          "config_port = ${toString cfg.listen.port}"
+          "config_uploading = ${if cfg.options.enableBookUploading then "1" else "0"}"
+          "config_allow_reverse_proxy_header_login = ${if cfg.options.reverseProxyAuth.enable then "1" else "0"}"
+          "config_reverse_proxy_login_header_name = '${cfg.options.reverseProxyAuth.header}'"
+        ]
+        ++ optional (cfg.options.calibreLibrary != null) "config_calibre_dir = '${cfg.options.calibreLibrary}'"
+        ++ optional cfg.options.enableBookConversion "config_converterpath = '${pkgs.calibre}/bin/ebook-convert'"
+      );
+    in
+      {
+        description = "Web app for browsing, reading and downloading eBooks stored in a Calibre database";
+        after = [ "network.target" ];
+        wantedBy = [ "multi-user.target" ];
+
+        serviceConfig = {
+          Type = "simple";
+          User = cfg.user;
+          Group = cfg.group;
+
+          StateDirectory = cfg.dataDir;
+          ExecStartPre = pkgs.writeShellScript "calibre-web-pre-start" (
+            ''
+              __RUN_MIGRATIONS_AND_EXIT=1 ${calibreWebCmd}
+
+              ${pkgs.sqlite}/bin/sqlite3 ${appDb} "update settings set ${settings}"
+            '' + optionalString (cfg.options.calibreLibrary != null) ''
+              test -f ${cfg.options.calibreLibrary}/metadata.db || { echo "Invalid Calibre library"; exit 1; }
+            ''
+          );
+
+          ExecStart = "${calibreWebCmd} -i ${cfg.listen.ip}";
+          Restart = "on-failure";
+        };
+      };
+
+    networking.firewall = mkIf cfg.openFirewall {
+      allowedTCPPorts = [ cfg.listen.port ];
+    };
+
+    users.users = mkIf (cfg.user == "calibre-web") {
+      calibre-web = {
+        isSystemUser = true;
+        group = cfg.group;
+      };
+    };
+
+    users.groups = mkIf (cfg.group == "calibre-web") {
+      calibre-web = {};
+    };
+  };
+
+  meta.maintainers = with lib.maintainers; [ pborzenkov ];
+}
diff --git a/nixos/modules/services/web-apps/code-server.nix b/nixos/modules/services/web-apps/code-server.nix
new file mode 100644
index 00000000000..474e9140ae8
--- /dev/null
+++ b/nixos/modules/services/web-apps/code-server.nix
@@ -0,0 +1,139 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+
+  cfg = config.services.code-server;
+  defaultUser = "code-server";
+  defaultGroup = defaultUser;
+
+in {
+  ###### interface
+  options = {
+    services.code-server = {
+      enable = mkEnableOption "code-server";
+
+      package = mkOption {
+        default = pkgs.code-server;
+        defaultText = "pkgs.code-server";
+        description = "Which code-server derivation to use.";
+        type = types.package;
+      };
+
+      extraPackages = mkOption {
+        default = [ ];
+        description = "Packages that are available in the PATH of code-server.";
+        example = "[ pkgs.go ]";
+        type = types.listOf types.package;
+      };
+
+      extraEnvironment = mkOption {
+        type = types.attrsOf types.str;
+        description =
+          "Additional environment variables to passed to code-server.";
+        default = { };
+        example = { PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig"; };
+      };
+
+      extraArguments = mkOption {
+        default = [ "--disable-telemetry" ];
+        description = "Additional arguments that passed to code-server";
+        example = ''[ "--verbose" ]'';
+        type = types.listOf types.str;
+      };
+
+      host = mkOption {
+        default = "127.0.0.1";
+        description = "The host-ip to bind to.";
+        type = types.str;
+      };
+
+      port = mkOption {
+        default = 4444;
+        description = "The port where code-server runs.";
+        type = types.port;
+      };
+
+      auth = mkOption {
+        default = "password";
+        description = "The type of authentication to use.";
+        type = types.enum [ "none" "password" ];
+      };
+
+      hashedPassword = mkOption {
+        default = "";
+        description =
+          "Create the password with: 'echo -n 'thisismypassword' | npx argon2-cli -e'.";
+        type = types.str;
+      };
+
+      user = mkOption {
+        default = defaultUser;
+        example = "yourUser";
+        description = ''
+          The user to run code-server as.
+          By default, a user named <literal>${defaultUser}</literal> will be created.
+        '';
+        type = types.str;
+      };
+
+      group = mkOption {
+        default = defaultGroup;
+        example = "yourGroup";
+        description = ''
+          The group to run code-server under.
+          By default, a group named <literal>${defaultGroup}</literal> will be created.
+        '';
+        type = types.str;
+      };
+
+      extraGroups = mkOption {
+        default = [ ];
+        description =
+          "An array of additional groups for the <literal>${defaultUser}</literal> user.";
+        example = [ "docker" ];
+        type = types.listOf types.str;
+      };
+
+    };
+  };
+
+  ###### implementation
+  config = mkIf cfg.enable {
+    systemd.services.code-server = {
+      description = "VSCode server";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network-online.target" ];
+      path = cfg.extraPackages;
+      environment = {
+        HASHED_PASSWORD = cfg.hashedPassword;
+      } // cfg.extraEnvironment;
+      serviceConfig = {
+        ExecStart = "${cfg.package}/bin/code-server --bind-addr ${cfg.host}:${toString cfg.port} --auth ${cfg.auth} " + builtins.concatStringsSep " " cfg.extraArguments;
+        ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
+        RuntimeDirectory = cfg.user;
+        User = cfg.user;
+        Group = cfg.group;
+        Restart = "on-failure";
+      };
+
+    };
+
+    users.users."${cfg.user}" = mkMerge [
+      (mkIf (cfg.user == defaultUser) {
+        isNormalUser = true;
+        description = "code-server user";
+        inherit (cfg) group;
+      })
+      {
+        packages = cfg.extraPackages;
+        inherit (cfg) extraGroups;
+      }
+    ];
+
+    users.groups."${defaultGroup}" = mkIf (cfg.group == defaultGroup) { };
+
+  };
+
+  meta.maintainers = with maintainers; [ stackshadow ];
+}
diff --git a/nixos/modules/services/web-apps/convos.nix b/nixos/modules/services/web-apps/convos.nix
new file mode 100644
index 00000000000..8be11eec9f3
--- /dev/null
+++ b/nixos/modules/services/web-apps/convos.nix
@@ -0,0 +1,72 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.convos;
+in
+{
+  options.services.convos = {
+    enable = mkEnableOption "Convos";
+    listenPort = mkOption {
+      type = types.port;
+      default = 3000;
+      example = 8080;
+      description = "Port the web interface should listen on";
+    };
+    listenAddress = mkOption {
+      type = types.str;
+      default = "*";
+      example = "127.0.0.1";
+      description = "Address or host the web interface should listen on";
+    };
+    reverseProxy = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Enables reverse proxy support. This will allow Convos to automatically
+        pick up the <literal>X-Forwarded-For</literal> and
+        <literal>X-Request-Base</literal> HTTP headers set in your reverse proxy
+        web server. Note that enabling this option without a reverse proxy in
+        front will be a security issue.
+      '';
+    };
+  };
+  config = mkIf cfg.enable {
+    systemd.services.convos = {
+      description = "Convos Service";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ];
+      environment = {
+        CONVOS_HOME = "%S/convos";
+        CONVOS_REVERSE_PROXY = if cfg.reverseProxy then "1" else "0";
+        MOJO_LISTEN = "http://${toString cfg.listenAddress}:${toString cfg.listenPort}";
+      };
+      serviceConfig = {
+        ExecStart = "${pkgs.convos}/bin/convos daemon";
+        Restart = "on-failure";
+        StateDirectory = "convos";
+        WorkingDirectory = "%S/convos";
+        DynamicUser = true;
+        MemoryDenyWriteExecute = true;
+        ProtectHome = true;
+        ProtectClock = true;
+        ProtectHostname = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectKernelLogs = true;
+        ProtectControlGroups = true;
+        PrivateDevices = true;
+        PrivateMounts = true;
+        PrivateUsers = true;
+        LockPersonality = true;
+        RestrictRealtime = true;
+        RestrictNamespaces = true;
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6"];
+        SystemCallFilter = "@system-service";
+        SystemCallArchitectures = "native";
+        CapabilityBoundingSet = "";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/cryptpad.nix b/nixos/modules/services/web-apps/cryptpad.nix
new file mode 100644
index 00000000000..e6772de768e
--- /dev/null
+++ b/nixos/modules/services/web-apps/cryptpad.nix
@@ -0,0 +1,54 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.cryptpad;
+in
+{
+  options.services.cryptpad = {
+    enable = mkEnableOption "the Cryptpad service";
+
+    package = mkOption {
+      default = pkgs.cryptpad;
+      defaultText = literalExpression "pkgs.cryptpad";
+      type = types.package;
+      description = "
+        Cryptpad package to use.
+      ";
+    };
+
+    configFile = mkOption {
+      type = types.path;
+      default = "${cfg.package}/lib/node_modules/cryptpad/config/config.example.js";
+      defaultText = literalExpression ''"''${package}/lib/node_modules/cryptpad/config/config.example.js"'';
+      description = ''
+        Path to the JavaScript configuration file.
+
+        See <link
+        xlink:href="https://github.com/xwiki-labs/cryptpad/blob/master/config/config.example.js"/>
+        for a configuration example.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.cryptpad = {
+      description = "Cryptpad Service";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ];
+      serviceConfig = {
+        DynamicUser = true;
+        Environment = [
+          "CRYPTPAD_CONFIG=${cfg.configFile}"
+          "HOME=%S/cryptpad"
+        ];
+        ExecStart = "${cfg.package}/bin/cryptpad";
+        PrivateTmp = true;
+        Restart = "always";
+        StateDirectory = "cryptpad";
+        WorkingDirectory = "%S/cryptpad";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/dex.nix b/nixos/modules/services/web-apps/dex.nix
new file mode 100644
index 00000000000..4d4689a4cf2
--- /dev/null
+++ b/nixos/modules/services/web-apps/dex.nix
@@ -0,0 +1,118 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.dex;
+  fixClient = client: if client ? secretFile then ((builtins.removeAttrs client [ "secretFile" ]) // { secret = client.secretFile; }) else client;
+  filteredSettings = mapAttrs (n: v: if n == "staticClients" then (builtins.map fixClient v) else v) cfg.settings;
+  secretFiles = flatten (builtins.map (c: if c ? secretFile then [ c.secretFile ] else []) (cfg.settings.staticClients or []));
+
+  settingsFormat = pkgs.formats.yaml {};
+  configFile = settingsFormat.generate "config.yaml" filteredSettings;
+
+  startPreScript = pkgs.writeShellScript "dex-start-pre" (''
+  '' + (concatStringsSep "\n" (builtins.map (file: ''
+    ${pkgs.replace-secret}/bin/replace-secret '${file}' '${file}' /run/dex/config.yaml
+  '') secretFiles)));
+in
+{
+  options.services.dex = {
+    enable = mkEnableOption "the OpenID Connect and OAuth2 identity provider";
+
+    settings = mkOption {
+      type = settingsFormat.type;
+      default = {};
+      example = literalExpression ''
+        {
+          # External url
+          issuer = "http://127.0.0.1:5556/dex";
+          storage = {
+            type = "postgres";
+            config.host = "/var/run/postgres";
+          };
+          web = {
+            http = "127.0.0.1:5556";
+          };
+          enablePasswordDB = true;
+          staticClients = [
+            {
+              id = "oidcclient";
+              name = "Client";
+              redirectURIs = [ "https://example.com/callback" ];
+              secretFile = "/etc/dex/oidcclient"; # The content of `secretFile` will be written into to the config as `secret`.
+            }
+          ];
+        }
+      '';
+      description = ''
+        The available options can be found in
+        <link xlink:href="https://github.com/dexidp/dex/blob/v${pkgs.dex.version}/config.yaml.dist">the example configuration</link>.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.dex = {
+      description = "dex identity provider";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ] ++ (optional (cfg.settings.storage.type == "postgres") "postgresql.service");
+
+      serviceConfig = {
+        ExecStart = "${pkgs.dex-oidc}/bin/dex serve /run/dex/config.yaml";
+        ExecStartPre = [
+          "${pkgs.coreutils}/bin/install -m 600 ${configFile} /run/dex/config.yaml"
+          "+${startPreScript}"
+        ];
+        RuntimeDirectory = "dex";
+
+        AmbientCapabilities = "CAP_NET_BIND_SERVICE";
+        BindReadOnlyPaths = [
+          "/nix/store"
+          "-/etc/resolv.conf"
+          "-/etc/nsswitch.conf"
+          "-/etc/hosts"
+          "-/etc/localtime"
+          "-/etc/dex"
+        ];
+        BindPaths = optional (cfg.settings.storage.type == "postgres") "/var/run/postgresql";
+        CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
+        # ProtectClock= adds DeviceAllow=char-rtc r
+        DeviceAllow = "";
+        DynamicUser = true;
+        LockPersonality = true;
+        MemoryDenyWriteExecute = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        PrivateMounts = true;
+        # Port needs to be exposed to the host network
+        #PrivateNetwork = true;
+        PrivateTmp = true;
+        PrivateUsers = true;
+        ProcSubset = "pid";
+        ProtectClock = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        # Would re-mount paths ignored by temporary root
+        #ProtectSystem = "strict";
+        ProtectControlGroups = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+        RestrictNamespaces = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [ "@system-service" "~@privileged @resources @setuid @keyring" ];
+        TemporaryFileSystem = "/:ro";
+        # Does not work well with the temporary root
+        #UMask = "0066";
+      };
+    };
+  };
+
+  # uses attributes of the linked package
+  meta.buildDocsInSandbox = false;
+}
diff --git a/nixos/modules/services/web-apps/discourse.nix b/nixos/modules/services/web-apps/discourse.nix
new file mode 100644
index 00000000000..2c2911aada3
--- /dev/null
+++ b/nixos/modules/services/web-apps/discourse.nix
@@ -0,0 +1,1087 @@
+{ config, options, lib, pkgs, utils, ... }:
+
+let
+  json = pkgs.formats.json {};
+
+  cfg = config.services.discourse;
+  opt = options.services.discourse;
+
+  # Keep in sync with https://github.com/discourse/discourse_docker/blob/master/image/base/Dockerfile#L5
+  upstreamPostgresqlVersion = lib.getVersion pkgs.postgresql_13;
+
+  postgresqlPackage = if config.services.postgresql.enable then
+                        config.services.postgresql.package
+                      else
+                        pkgs.postgresql;
+
+  postgresqlVersion = lib.getVersion postgresqlPackage;
+
+  # We only want to create a database if we're actually going to connect to it.
+  databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == null;
+
+  tlsEnabled = (cfg.enableACME
+                || cfg.sslCertificate != null
+                || cfg.sslCertificateKey != null);
+in
+{
+  options = {
+    services.discourse = {
+      enable = lib.mkEnableOption "Discourse, an open source discussion platform";
+
+      package = lib.mkOption {
+        type = lib.types.package;
+        default = pkgs.discourse;
+        apply = p: p.override {
+          plugins = lib.unique (p.enabledPlugins ++ cfg.plugins);
+        };
+        defaultText = lib.literalExpression "pkgs.discourse";
+        description = ''
+          The discourse package to use.
+        '';
+      };
+
+      hostname = lib.mkOption {
+        type = lib.types.str;
+        default = if config.networking.domain != null then
+                    config.networking.fqdn
+                  else
+                    config.networking.hostName;
+        defaultText = lib.literalExpression "config.networking.fqdn";
+        example = "discourse.example.com";
+        description = ''
+          The hostname to serve Discourse on.
+        '';
+      };
+
+      secretKeyBaseFile = lib.mkOption {
+        type = with lib.types; nullOr path;
+        default = null;
+        example = "/run/keys/secret_key_base";
+        description = ''
+          The path to a file containing the
+          <literal>secret_key_base</literal> secret.
+
+          Discourse uses <literal>secret_key_base</literal> to encrypt
+          the cookie store, which contains session data, and to digest
+          user auth tokens.
+
+          Needs to be a 64 byte long string of hexadecimal
+          characters. You can generate one by running
+
+          <screen>
+          <prompt>$ </prompt>openssl rand -hex 64 >/path/to/secret_key_base_file
+          </screen>
+
+          This should be a string, not a nix path, since nix paths are
+          copied into the world-readable nix store.
+        '';
+      };
+
+      sslCertificate = lib.mkOption {
+        type = with lib.types; nullOr path;
+        default = null;
+        example = "/run/keys/ssl.cert";
+        description = ''
+          The path to the server SSL certificate. Set this to enable
+          SSL.
+        '';
+      };
+
+      sslCertificateKey = lib.mkOption {
+        type = with lib.types; nullOr path;
+        default = null;
+        example = "/run/keys/ssl.key";
+        description = ''
+          The path to the server SSL certificate key. Set this to
+          enable SSL.
+        '';
+      };
+
+      enableACME = lib.mkOption {
+        type = lib.types.bool;
+        default = cfg.sslCertificate == null && cfg.sslCertificateKey == null;
+        defaultText = lib.literalDocBook ''
+          <literal>true</literal>, unless <option>services.discourse.sslCertificate</option>
+          and <option>services.discourse.sslCertificateKey</option> are set.
+        '';
+        description = ''
+          Whether an ACME certificate should be used to secure
+          connections to the server.
+        '';
+      };
+
+      backendSettings = lib.mkOption {
+        type = with lib.types; attrsOf (nullOr (oneOf [ str int bool float ]));
+        default = {};
+        example = lib.literalExpression ''
+          {
+            max_reqs_per_ip_per_minute = 300;
+            max_reqs_per_ip_per_10_seconds = 60;
+            max_asset_reqs_per_ip_per_10_seconds = 250;
+            max_reqs_per_ip_mode = "warn+block";
+          };
+        '';
+        description = ''
+          Additional settings to put in the
+          <filename>discourse.conf</filename> file.
+
+          Look in the
+          <link xlink:href="https://github.com/discourse/discourse/blob/master/config/discourse_defaults.conf">discourse_defaults.conf</link>
+          file in the upstream distribution to find available options.
+
+          Setting an option to <literal>null</literal> means
+          <quote>define variable, but leave right-hand side
+          empty</quote>.
+        '';
+      };
+
+      siteSettings = lib.mkOption {
+        type = json.type;
+        default = {};
+        example = lib.literalExpression ''
+          {
+            required = {
+              title = "My Cats";
+              site_description = "Discuss My Cats (and be nice plz)";
+            };
+            login = {
+              enable_github_logins = true;
+              github_client_id = "a2f6dfe838cb3206ce20";
+              github_client_secret._secret = /run/keys/discourse_github_client_secret;
+            };
+          };
+        '';
+        description = ''
+          Discourse site settings. These are the settings that can be
+          changed from the UI. This only defines their default values:
+          they can still be overridden from the UI.
+
+          Available settings can be found by looking in the
+          <link xlink:href="https://github.com/discourse/discourse/blob/master/config/site_settings.yml">site_settings.yml</link>
+          file of the upstream distribution. To find a setting's path,
+          you only need to care about the first two levels; i.e. its
+          category and name. See the example.
+
+          Settings containing secret data should be set to an
+          attribute set containing the attribute
+          <literal>_secret</literal> - a string pointing to a file
+          containing the value the option should be set to. See the
+          example to get a better picture of this: in the resulting
+          <filename>config/nixos_site_settings.json</filename> file,
+          the <literal>login.github_client_secret</literal> key will
+          be set to the contents of the
+          <filename>/run/keys/discourse_github_client_secret</filename>
+          file.
+        '';
+      };
+
+      admin = {
+        skipCreate = lib.mkOption {
+          type = lib.types.bool;
+          default = false;
+          description = ''
+            Do not create the admin account, instead rely on other
+            existing admin accounts.
+          '';
+        };
+
+        email = lib.mkOption {
+          type = lib.types.str;
+          example = "admin@example.com";
+          description = ''
+            The admin user email address.
+          '';
+        };
+
+        username = lib.mkOption {
+          type = lib.types.str;
+          example = "admin";
+          description = ''
+            The admin user username.
+          '';
+        };
+
+        fullName = lib.mkOption {
+          type = lib.types.str;
+          description = ''
+            The admin user's full name.
+          '';
+        };
+
+        passwordFile = lib.mkOption {
+          type = lib.types.path;
+          description = ''
+            A path to a file containing the admin user's password.
+
+            This should be a string, not a nix path, since nix paths are
+            copied into the world-readable nix store.
+          '';
+        };
+      };
+
+      nginx.enable = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        description = ''
+          Whether an <literal>nginx</literal> virtual host should be
+          set up to serve Discourse. Only disable if you're planning
+          to use a different web server, which is not recommended.
+        '';
+      };
+
+      database = {
+        pool = lib.mkOption {
+          type = lib.types.int;
+          default = 8;
+          description = ''
+            Database connection pool size.
+          '';
+        };
+
+        host = lib.mkOption {
+          type = with lib.types; nullOr str;
+          default = null;
+          description = ''
+            Discourse database hostname. <literal>null</literal> means <quote>prefer
+            local unix socket connection</quote>.
+          '';
+        };
+
+        passwordFile = lib.mkOption {
+          type = with lib.types; nullOr path;
+          default = null;
+          description = ''
+            File containing the Discourse database user password.
+
+            This should be a string, not a nix path, since nix paths are
+            copied into the world-readable nix store.
+          '';
+        };
+
+        createLocally = lib.mkOption {
+          type = lib.types.bool;
+          default = true;
+          description = ''
+            Whether a database should be automatically created on the
+            local host. Set this to <literal>false</literal> if you plan
+            on provisioning a local database yourself. This has no effect
+            if <option>services.discourse.database.host</option> is customized.
+          '';
+        };
+
+        name = lib.mkOption {
+          type = lib.types.str;
+          default = "discourse";
+          description = ''
+            Discourse database name.
+          '';
+        };
+
+        username = lib.mkOption {
+          type = lib.types.str;
+          default = "discourse";
+          description = ''
+            Discourse database user.
+          '';
+        };
+
+        ignorePostgresqlVersion = lib.mkOption {
+          type = lib.types.bool;
+          default = false;
+          description = ''
+            Whether to allow other versions of PostgreSQL than the
+            recommended one. Only effective when
+            <option>services.discourse.database.createLocally</option>
+            is enabled.
+          '';
+        };
+      };
+
+      redis = {
+        host = lib.mkOption {
+          type = lib.types.str;
+          default = "localhost";
+          description = ''
+            Redis server hostname.
+          '';
+        };
+
+        passwordFile = lib.mkOption {
+          type = with lib.types; nullOr path;
+          default = null;
+          description = ''
+            File containing the Redis password.
+
+            This should be a string, not a nix path, since nix paths are
+            copied into the world-readable nix store.
+          '';
+        };
+
+        dbNumber = lib.mkOption {
+          type = lib.types.int;
+          default = 0;
+          description = ''
+            Redis database number.
+          '';
+        };
+
+        useSSL = lib.mkOption {
+          type = lib.types.bool;
+          default = cfg.redis.host != "localhost";
+          defaultText = lib.literalExpression ''config.${opt.redis.host} != "localhost"'';
+          description = ''
+            Connect to Redis with SSL.
+          '';
+        };
+      };
+
+      mail = {
+        notificationEmailAddress = lib.mkOption {
+          type = lib.types.str;
+          default = "${if cfg.mail.incoming.enable then "notifications" else "noreply"}@${cfg.hostname}";
+          defaultText = lib.literalExpression ''
+            "''${if config.services.discourse.mail.incoming.enable then "notifications" else "noreply"}@''${config.services.discourse.hostname}"
+          '';
+          description = ''
+            The <literal>from:</literal> email address used when
+            sending all essential system emails. The domain specified
+            here must have SPF, DKIM and reverse PTR records set
+            correctly for email to arrive.
+          '';
+        };
+
+        contactEmailAddress = lib.mkOption {
+          type = lib.types.str;
+          default = "";
+          description = ''
+            Email address of key contact responsible for this
+            site. Used for critical notifications, as well as on the
+            <literal>/about</literal> contact form for urgent matters.
+          '';
+        };
+
+        outgoing = {
+          serverAddress = lib.mkOption {
+            type = lib.types.str;
+            default = "localhost";
+            description = ''
+              The address of the SMTP server Discourse should use to
+              send email.
+            '';
+          };
+
+          port = lib.mkOption {
+            type = lib.types.port;
+            default = 25;
+            description = ''
+              The port of the SMTP server Discourse should use to
+              send email.
+            '';
+          };
+
+          username = lib.mkOption {
+            type = with lib.types; nullOr str;
+            default = null;
+            description = ''
+              The username of the SMTP server.
+            '';
+          };
+
+          passwordFile = lib.mkOption {
+            type = lib.types.nullOr lib.types.path;
+            default = null;
+            description = ''
+              A file containing the password of the SMTP server account.
+
+              This should be a string, not a nix path, since nix paths
+              are copied into the world-readable nix store.
+            '';
+          };
+
+          domain = lib.mkOption {
+            type = lib.types.str;
+            default = cfg.hostname;
+            defaultText = lib.literalExpression "config.${opt.hostname}";
+            description = ''
+              HELO domain to use for outgoing mail.
+            '';
+          };
+
+          authentication = lib.mkOption {
+            type = with lib.types; nullOr (enum ["plain" "login" "cram_md5"]);
+            default = null;
+            description = ''
+              Authentication type to use, see http://api.rubyonrails.org/classes/ActionMailer/Base.html
+            '';
+          };
+
+          enableStartTLSAuto = lib.mkOption {
+            type = lib.types.bool;
+            default = true;
+            description = ''
+              Whether to try to use StartTLS.
+            '';
+          };
+
+          opensslVerifyMode = lib.mkOption {
+            type = lib.types.str;
+            default = "peer";
+            description = ''
+              How OpenSSL checks the certificate, see http://api.rubyonrails.org/classes/ActionMailer/Base.html
+            '';
+          };
+
+          forceTLS = lib.mkOption {
+            type = lib.types.bool;
+            default = false;
+            description = ''
+              Force implicit TLS as per RFC 8314 3.3.
+            '';
+          };
+        };
+
+        incoming = {
+          enable = lib.mkOption {
+            type = lib.types.bool;
+            default = false;
+            description = ''
+              Whether to set up Postfix to receive incoming mail.
+            '';
+          };
+
+          replyEmailAddress = lib.mkOption {
+            type = lib.types.str;
+            default = "%{reply_key}@${cfg.hostname}";
+            defaultText = lib.literalExpression ''"%{reply_key}@''${config.services.discourse.hostname}"'';
+            description = ''
+              Template for reply by email incoming email address, for
+              example: %{reply_key}@reply.example.com or
+              replies+%{reply_key}@example.com
+            '';
+          };
+
+          mailReceiverPackage = lib.mkOption {
+            type = lib.types.package;
+            default = pkgs.discourse-mail-receiver;
+            defaultText = lib.literalExpression "pkgs.discourse-mail-receiver";
+            description = ''
+              The discourse-mail-receiver package to use.
+            '';
+          };
+
+          apiKeyFile = lib.mkOption {
+            type = lib.types.nullOr lib.types.path;
+            default = null;
+            description = ''
+              A file containing the Discourse API key used to add
+              posts and messages from mail. If left at its default
+              value <literal>null</literal>, one will be automatically
+              generated.
+
+              This should be a string, not a nix path, since nix paths
+              are copied into the world-readable nix store.
+            '';
+          };
+        };
+      };
+
+      plugins = lib.mkOption {
+        type = lib.types.listOf lib.types.package;
+        default = [];
+        example = lib.literalExpression ''
+          with config.services.discourse.package.plugins; [
+            discourse-canned-replies
+            discourse-github
+          ];
+        '';
+        description = ''
+          Plugins to install as part of
+          <productname>Discourse</productname>, expressed as a list of
+          derivations.
+        '';
+      };
+
+      sidekiqProcesses = lib.mkOption {
+        type = lib.types.int;
+        default = 1;
+        description = ''
+          How many Sidekiq processes should be spawned.
+        '';
+      };
+
+      unicornTimeout = lib.mkOption {
+        type = lib.types.int;
+        default = 30;
+        description = ''
+          Time in seconds before a request to Unicorn times out.
+
+          This can be raised if the system Discourse is running on is
+          too slow to handle many requests within 30 seconds.
+        '';
+      };
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = (cfg.database.host != null) -> (cfg.database.passwordFile != null);
+        message = "When services.gitlab.database.host is customized, services.discourse.database.passwordFile must be set!";
+      }
+      {
+        assertion = cfg.hostname != "";
+        message = "Could not automatically determine hostname, set service.discourse.hostname manually.";
+      }
+      {
+        assertion = cfg.database.ignorePostgresqlVersion || (databaseActuallyCreateLocally -> upstreamPostgresqlVersion == postgresqlVersion);
+        message = "The PostgreSQL version recommended for use with Discourse is ${upstreamPostgresqlVersion}, you're using ${postgresqlVersion}. "
+                  + "Either update your PostgreSQL package to the correct version or set services.discourse.database.ignorePostgresqlVersion. "
+                  + "See https://nixos.org/manual/nixos/stable/index.html#module-postgresql for details on how to upgrade PostgreSQL.";
+      }
+    ];
+
+
+    # Default config values are from `config/discourse_defaults.conf`
+    # upstream.
+    services.discourse.backendSettings = lib.mapAttrs (_: lib.mkDefault) {
+      db_pool = cfg.database.pool;
+      db_timeout = 5000;
+      db_connect_timeout = 5;
+      db_socket = null;
+      db_host = cfg.database.host;
+      db_backup_host = null;
+      db_port = null;
+      db_backup_port = 5432;
+      db_name = cfg.database.name;
+      db_username = if databaseActuallyCreateLocally then "discourse" else cfg.database.username;
+      db_password = cfg.database.passwordFile;
+      db_prepared_statements = false;
+      db_replica_host = null;
+      db_replica_port = null;
+      db_advisory_locks = true;
+
+      inherit (cfg) hostname;
+      backup_hostname = null;
+
+      smtp_address = cfg.mail.outgoing.serverAddress;
+      smtp_port = cfg.mail.outgoing.port;
+      smtp_domain = cfg.mail.outgoing.domain;
+      smtp_user_name = cfg.mail.outgoing.username;
+      smtp_password = cfg.mail.outgoing.passwordFile;
+      smtp_authentication = cfg.mail.outgoing.authentication;
+      smtp_enable_start_tls = cfg.mail.outgoing.enableStartTLSAuto;
+      smtp_openssl_verify_mode = cfg.mail.outgoing.opensslVerifyMode;
+      smtp_force_tls = cfg.mail.outgoing.forceTLS;
+
+      load_mini_profiler = true;
+      mini_profiler_snapshots_period = 0;
+      mini_profiler_snapshots_transport_url = null;
+      mini_profiler_snapshots_transport_auth_key = null;
+
+      cdn_url = null;
+      cdn_origin_hostname = null;
+      developer_emails = null;
+
+      redis_host = cfg.redis.host;
+      redis_port = 6379;
+      redis_replica_host = null;
+      redis_replica_port = 6379;
+      redis_db = cfg.redis.dbNumber;
+      redis_password = cfg.redis.passwordFile;
+      redis_skip_client_commands = false;
+      redis_use_ssl = cfg.redis.useSSL;
+
+      message_bus_redis_enabled = false;
+      message_bus_redis_host = "localhost";
+      message_bus_redis_port = 6379;
+      message_bus_redis_replica_host = null;
+      message_bus_redis_replica_port = 6379;
+      message_bus_redis_db = 0;
+      message_bus_redis_password = null;
+      message_bus_redis_skip_client_commands = false;
+
+      enable_cors = false;
+      cors_origin = "";
+      serve_static_assets = false;
+      sidekiq_workers = 5;
+      rtl_css = false;
+      connection_reaper_age = 30;
+      connection_reaper_interval = 30;
+      relative_url_root = null;
+      message_bus_max_backlog_size = 100;
+      secret_key_base = cfg.secretKeyBaseFile;
+      fallback_assets_path = null;
+
+      s3_bucket = null;
+      s3_region = null;
+      s3_access_key_id = null;
+      s3_secret_access_key = null;
+      s3_use_iam_profile = null;
+      s3_cdn_url = null;
+      s3_endpoint = null;
+      s3_http_continue_timeout = null;
+      s3_install_cors_rule = null;
+
+      max_user_api_reqs_per_minute = 20;
+      max_user_api_reqs_per_day = 2880;
+      max_admin_api_reqs_per_minute = 60;
+      max_reqs_per_ip_per_minute = 200;
+      max_reqs_per_ip_per_10_seconds = 50;
+      max_asset_reqs_per_ip_per_10_seconds = 200;
+      max_reqs_per_ip_mode = "block";
+      max_reqs_rate_limit_on_private = false;
+      skip_per_ip_rate_limit_trust_level = 1;
+      force_anonymous_min_queue_seconds = 1;
+      force_anonymous_min_per_10_seconds = 3;
+      background_requests_max_queue_length = 0.5;
+      reject_message_bus_queue_seconds = 0.1;
+      disable_search_queue_threshold = 1;
+      max_old_rebakes_per_15_minutes = 300;
+      max_logster_logs = 1000;
+      refresh_maxmind_db_during_precompile_days = 2;
+      maxmind_backup_path = null;
+      maxmind_license_key = null;
+      enable_performance_http_headers = false;
+      enable_js_error_reporting = true;
+      mini_scheduler_workers = 5;
+      compress_anon_cache = false;
+      anon_cache_store_threshold = 2;
+      allowed_theme_repos = null;
+      enable_email_sync_demon = false;
+      max_digests_enqueued_per_30_mins_per_site = 10000;
+      cluster_name = null;
+      multisite_config_path = "config/multisite.yml";
+      enable_long_polling = null;
+      long_polling_interval = null;
+    };
+
+    services.redis.enable = lib.mkDefault (cfg.redis.host == "localhost");
+
+    services.postgresql = lib.mkIf databaseActuallyCreateLocally {
+      enable = true;
+      ensureUsers = [{ name = "discourse"; }];
+    };
+
+    # The postgresql module doesn't currently support concepts like
+    # objects owners and extensions; for now we tack on what's needed
+    # here.
+    systemd.services.discourse-postgresql =
+      let
+        pgsql = config.services.postgresql;
+      in
+        lib.mkIf databaseActuallyCreateLocally {
+          after = [ "postgresql.service" ];
+          bindsTo = [ "postgresql.service" ];
+          wantedBy = [ "discourse.service" ];
+          partOf = [ "discourse.service" ];
+          path = [
+            pgsql.package
+          ];
+          script = ''
+            set -o errexit -o pipefail -o nounset -o errtrace
+            shopt -s inherit_errexit
+
+            psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'discourse'" | grep -q 1 || psql -tAc 'CREATE DATABASE "discourse" OWNER "discourse"'
+            psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS pg_trgm"
+            psql '${cfg.database.name}' -tAc "CREATE EXTENSION IF NOT EXISTS hstore"
+          '';
+
+          serviceConfig = {
+            User = pgsql.superUser;
+            Type = "oneshot";
+            RemainAfterExit = true;
+          };
+        };
+
+    systemd.services.discourse = {
+      wantedBy = [ "multi-user.target" ];
+      after = [
+        "redis.service"
+        "postgresql.service"
+        "discourse-postgresql.service"
+      ];
+      bindsTo = [
+        "redis.service"
+      ] ++ lib.optionals (cfg.database.host == null) [
+        "postgresql.service"
+        "discourse-postgresql.service"
+      ];
+      path = cfg.package.runtimeDeps ++ [
+        postgresqlPackage
+        pkgs.replace-secret
+        cfg.package.rake
+      ];
+      environment = cfg.package.runtimeEnv // {
+        UNICORN_TIMEOUT = builtins.toString cfg.unicornTimeout;
+        UNICORN_SIDEKIQS = builtins.toString cfg.sidekiqProcesses;
+        MALLOC_ARENA_MAX = "2";
+      };
+
+      preStart =
+        let
+          discourseKeyValue = lib.generators.toKeyValue {
+            mkKeyValue = lib.flip lib.generators.mkKeyValueDefault " = " {
+              mkValueString = v: with builtins;
+                if isInt           v then toString v
+                else if isString   v then ''"${v}"''
+                else if true  ==   v then "true"
+                else if false ==   v then "false"
+                else if null  ==   v then ""
+                else if isFloat    v then lib.strings.floatToString v
+                else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
+            };
+          };
+
+          discourseConf = pkgs.writeText "discourse.conf" (discourseKeyValue cfg.backendSettings);
+
+          mkSecretReplacement = file:
+            lib.optionalString (file != null) ''
+              replace-secret '${file}' '${file}' /run/discourse/config/discourse.conf
+            '';
+
+          mkAdmin = ''
+            export ADMIN_EMAIL="${cfg.admin.email}"
+            export ADMIN_NAME="${cfg.admin.fullName}"
+            export ADMIN_USERNAME="${cfg.admin.username}"
+            ADMIN_PASSWORD="$(<${cfg.admin.passwordFile})"
+            export ADMIN_PASSWORD
+            discourse-rake admin:create_noninteractively
+          '';
+
+        in ''
+          set -o errexit -o pipefail -o nounset -o errtrace
+          shopt -s inherit_errexit
+
+          umask u=rwx,g=rx,o=
+
+          rm -rf /var/lib/discourse/tmp/*
+
+          cp -r ${cfg.package}/share/discourse/config.dist/* /run/discourse/config/
+          cp -r ${cfg.package}/share/discourse/public.dist/* /run/discourse/public/
+          ln -sf /var/lib/discourse/uploads /run/discourse/public/uploads
+          ln -sf /var/lib/discourse/backups /run/discourse/public/backups
+
+          (
+              umask u=rwx,g=,o=
+
+              ${utils.genJqSecretsReplacementSnippet
+                  cfg.siteSettings
+                  "/run/discourse/config/nixos_site_settings.json"
+              }
+              install -T -m 0600 -o discourse ${discourseConf} /run/discourse/config/discourse.conf
+              ${mkSecretReplacement cfg.database.passwordFile}
+              ${mkSecretReplacement cfg.mail.outgoing.passwordFile}
+              ${mkSecretReplacement cfg.redis.passwordFile}
+              ${mkSecretReplacement cfg.secretKeyBaseFile}
+              chmod 0400 /run/discourse/config/discourse.conf
+          )
+
+          discourse-rake db:migrate >>/var/log/discourse/db_migration.log
+          chmod -R u+w /var/lib/discourse/tmp/
+
+          ${lib.optionalString (!cfg.admin.skipCreate) mkAdmin}
+
+          discourse-rake themes:update
+          discourse-rake uploads:regenerate_missing_optimized
+        '';
+
+      serviceConfig = {
+        Type = "simple";
+        User = "discourse";
+        Group = "discourse";
+        RuntimeDirectory = map (p: "discourse/" + p) [
+          "config"
+          "home"
+          "assets/javascripts/plugins"
+          "public"
+          "sockets"
+        ];
+        RuntimeDirectoryMode = 0750;
+        StateDirectory = map (p: "discourse/" + p) [
+          "uploads"
+          "backups"
+          "tmp"
+        ];
+        StateDirectoryMode = 0750;
+        LogsDirectory = "discourse";
+        TimeoutSec = "infinity";
+        Restart = "on-failure";
+        WorkingDirectory = "${cfg.package}/share/discourse";
+
+        RemoveIPC = true;
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+        RestrictSUIDSGID = true;
+        ProtectSystem = "strict";
+        ProtectHome = "read-only";
+
+        ExecStart = "${cfg.package.rubyEnv}/bin/bundle exec config/unicorn_launcher -E production -c config/unicorn.conf.rb";
+      };
+    };
+
+    services.nginx = lib.mkIf cfg.nginx.enable {
+      enable = true;
+      additionalModules = [ pkgs.nginxModules.brotli ];
+
+      recommendedTlsSettings = true;
+      recommendedOptimisation = true;
+      recommendedGzipSettings = true;
+      recommendedProxySettings = true;
+
+      upstreams.discourse.servers."unix:/run/discourse/sockets/unicorn.sock" = {};
+
+      appendHttpConfig = ''
+        # inactive means we keep stuff around for 1440m minutes regardless of last access (1 week)
+        # levels means it is a 2 deep hierarchy cause we can have lots of files
+        # max_size limits the size of the cache
+        proxy_cache_path /var/cache/nginx inactive=1440m levels=1:2 keys_zone=discourse:10m max_size=600m;
+
+        # see: https://meta.discourse.org/t/x/74060
+        proxy_buffer_size 8k;
+      '';
+
+      virtualHosts.${cfg.hostname} = {
+        inherit (cfg) sslCertificate sslCertificateKey enableACME;
+        forceSSL = lib.mkDefault tlsEnabled;
+
+        root = "${cfg.package}/share/discourse/public";
+
+        locations =
+          let
+            proxy = { extraConfig ? "" }: {
+              proxyPass = "http://discourse";
+              extraConfig = extraConfig + ''
+                proxy_set_header X-Request-Start "t=''${msec}";
+              '';
+            };
+            cache = time: ''
+              expires ${time};
+              add_header Cache-Control public,immutable;
+            '';
+            cache_1y = cache "1y";
+            cache_1d = cache "1d";
+          in
+            {
+              "/".tryFiles = "$uri @discourse";
+              "@discourse" = proxy {};
+              "^~ /backups/".extraConfig = ''
+                internal;
+              '';
+              "/favicon.ico" = {
+                return = "204";
+                extraConfig = ''
+                  access_log off;
+                  log_not_found off;
+                '';
+              };
+              "~ ^/uploads/short-url/" = proxy {};
+              "~ ^/secure-media-uploads/" = proxy {};
+              "~* (fonts|assets|plugins|uploads)/.*\.(eot|ttf|woff|woff2|ico|otf)$".extraConfig = cache_1y + ''
+                add_header Access-Control-Allow-Origin *;
+              '';
+              "/srv/status" = proxy {
+                extraConfig = ''
+                  access_log off;
+                  log_not_found off;
+                '';
+              };
+              "~ ^/javascripts/".extraConfig = cache_1d;
+              "~ ^/assets/(?<asset_path>.+)$".extraConfig = cache_1y + ''
+                # asset pipeline enables this
+                brotli_static on;
+                gzip_static on;
+              '';
+              "~ ^/plugins/".extraConfig = cache_1y;
+              "~ /images/emoji/".extraConfig = cache_1y;
+              "~ ^/uploads/" = proxy {
+                extraConfig = cache_1y + ''
+                  proxy_set_header X-Sendfile-Type X-Accel-Redirect;
+                  proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
+
+                  # custom CSS
+                  location ~ /stylesheet-cache/ {
+                      try_files $uri =404;
+                  }
+                  # this allows us to bypass rails
+                  location ~* \.(gif|png|jpg|jpeg|bmp|tif|tiff|ico|webp)$ {
+                      try_files $uri =404;
+                  }
+                  # SVG needs an extra header attached
+                  location ~* \.(svg)$ {
+                  }
+                  # thumbnails & optimized images
+                  location ~ /_?optimized/ {
+                      try_files $uri =404;
+                  }
+                '';
+              };
+              "~ ^/admin/backups/" = proxy {
+                extraConfig = ''
+                  proxy_set_header X-Sendfile-Type X-Accel-Redirect;
+                  proxy_set_header X-Accel-Mapping ${cfg.package}/share/discourse/public/=/downloads/;
+                '';
+              };
+              "~ ^/(svg-sprite/|letter_avatar/|letter_avatar_proxy/|user_avatar|highlight-js|stylesheets|theme-javascripts|favicon/proxied|service-worker)" = proxy {
+                extraConfig = ''
+                  # if Set-Cookie is in the response nothing gets cached
+                  # this is double bad cause we are not passing last modified in
+                  proxy_ignore_headers "Set-Cookie";
+                  proxy_hide_header "Set-Cookie";
+                  proxy_hide_header "X-Discourse-Username";
+                  proxy_hide_header "X-Runtime";
+
+                  # note x-accel-redirect can not be used with proxy_cache
+                  proxy_cache discourse;
+                  proxy_cache_key "$scheme,$host,$request_uri";
+                  proxy_cache_valid 200 301 302 7d;
+                  proxy_cache_valid any 1m;
+                '';
+              };
+              "/message-bus/" = proxy {
+                extraConfig = ''
+                  proxy_http_version 1.1;
+                  proxy_buffering off;
+                '';
+              };
+              "/downloads/".extraConfig = ''
+                internal;
+                alias ${cfg.package}/share/discourse/public/;
+              '';
+            };
+      };
+    };
+
+    systemd.services.discourse-mail-receiver-setup = lib.mkIf cfg.mail.incoming.enable (
+      let
+        mail-receiver-environment = {
+          MAIL_DOMAIN = cfg.hostname;
+          DISCOURSE_BASE_URL = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
+          DISCOURSE_API_KEY = "@api-key@";
+          DISCOURSE_API_USERNAME = "system";
+        };
+        mail-receiver-json = json.generate "mail-receiver.json" mail-receiver-environment;
+      in
+        {
+          before = [ "postfix.service" ];
+          after = [ "discourse.service" ];
+          wantedBy = [ "discourse.service" ];
+          partOf = [ "discourse.service" ];
+          path = [
+            cfg.package.rake
+            pkgs.jq
+          ];
+          preStart = lib.optionalString (cfg.mail.incoming.apiKeyFile == null) ''
+            set -o errexit -o pipefail -o nounset -o errtrace
+            shopt -s inherit_errexit
+
+            if [[ ! -e /var/lib/discourse-mail-receiver/api_key ]]; then
+                discourse-rake api_key:create_master[email-receiver] >/var/lib/discourse-mail-receiver/api_key
+            fi
+          '';
+          script =
+            let
+              apiKeyPath =
+                if cfg.mail.incoming.apiKeyFile == null then
+                  "/var/lib/discourse-mail-receiver/api_key"
+                else
+                  cfg.mail.incoming.apiKeyFile;
+            in ''
+              set -o errexit -o pipefail -o nounset -o errtrace
+              shopt -s inherit_errexit
+
+              api_key=$(<'${apiKeyPath}')
+              export api_key
+
+              jq <${mail-receiver-json} \
+                 '.DISCOURSE_API_KEY = $ENV.api_key' \
+                 >'/run/discourse-mail-receiver/mail-receiver-environment.json'
+            '';
+
+          serviceConfig = {
+            Type = "oneshot";
+            RemainAfterExit = true;
+            RuntimeDirectory = "discourse-mail-receiver";
+            RuntimeDirectoryMode = "0700";
+            StateDirectory = "discourse-mail-receiver";
+            User = "discourse";
+            Group = "discourse";
+          };
+        });
+
+    services.discourse.siteSettings = {
+      required = {
+        notification_email = cfg.mail.notificationEmailAddress;
+        contact_email = cfg.mail.contactEmailAddress;
+      };
+      email = {
+        manual_polling_enabled = cfg.mail.incoming.enable;
+        reply_by_email_enabled = cfg.mail.incoming.enable;
+        reply_by_email_address = cfg.mail.incoming.replyEmailAddress;
+      };
+    };
+
+    services.postfix = lib.mkIf cfg.mail.incoming.enable {
+      enable = true;
+      sslCert = if cfg.sslCertificate != null then cfg.sslCertificate else "";
+      sslKey = if cfg.sslCertificateKey != null then cfg.sslCertificateKey else "";
+
+      origin = cfg.hostname;
+      relayDomains = [ cfg.hostname ];
+      config = {
+        smtpd_recipient_restrictions = "check_policy_service unix:private/discourse-policy";
+        append_dot_mydomain = lib.mkDefault false;
+        compatibility_level = "2";
+        smtputf8_enable = false;
+        smtpd_banner = lib.mkDefault "ESMTP server";
+        myhostname = lib.mkDefault cfg.hostname;
+        mydestination = lib.mkDefault "localhost";
+      };
+      transport = ''
+        ${cfg.hostname} discourse-mail-receiver:
+      '';
+      masterConfig = {
+        "discourse-mail-receiver" = {
+          type = "unix";
+          privileged = true;
+          chroot = false;
+          command = "pipe";
+          args = [
+            "user=discourse"
+            "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/receive-mail"
+            "\${recipient}"
+          ];
+        };
+        "discourse-policy" = {
+          type = "unix";
+          privileged = true;
+          chroot = false;
+          command = "spawn";
+          args = [
+            "user=discourse"
+            "argv=${cfg.mail.incoming.mailReceiverPackage}/bin/discourse-smtp-fast-rejection"
+          ];
+        };
+      };
+    };
+
+    users.users = {
+      discourse = {
+        group = "discourse";
+        isSystemUser = true;
+      };
+    } // (lib.optionalAttrs cfg.nginx.enable {
+      ${config.services.nginx.user}.extraGroups = [ "discourse" ];
+    });
+
+    users.groups = {
+      discourse = {};
+    };
+
+    environment.systemPackages = [
+      cfg.package.rake
+    ];
+  };
+
+  meta.doc = ./discourse.xml;
+  meta.maintainers = [ lib.maintainers.talyz ];
+}
diff --git a/nixos/modules/services/web-apps/discourse.xml b/nixos/modules/services/web-apps/discourse.xml
new file mode 100644
index 00000000000..ad9b65abf51
--- /dev/null
+++ b/nixos/modules/services/web-apps/discourse.xml
@@ -0,0 +1,355 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-discourse">
+ <title>Discourse</title>
+ <para>
+   <link xlink:href="https://www.discourse.org/">Discourse</link> is a
+   modern and open source discussion platform.
+ </para>
+
+ <section xml:id="module-services-discourse-basic-usage">
+   <title>Basic usage</title>
+   <para>
+     A minimal configuration using Let's Encrypt for TLS certificates looks like this:
+<programlisting>
+services.discourse = {
+  <link linkend="opt-services.discourse.enable">enable</link> = true;
+  <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com";
+  admin = {
+    <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com";
+    <link linkend="opt-services.discourse.admin.username">username</link> = "admin";
+    <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator";
+    <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file";
+  };
+  <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file";
+};
+<link linkend="opt-security.acme.defaults.email">security.acme.email</link> = "me@example.com";
+<link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link> = true;
+</programlisting>
+   </para>
+
+   <para>
+     Provided a proper DNS setup, you'll be able to connect to the
+     instance at <literal>discourse.example.com</literal> and log in
+     using the credentials provided in
+     <literal>services.discourse.admin</literal>.
+   </para>
+ </section>
+
+ <section xml:id="module-services-discourse-tls">
+   <title>Using a regular TLS certificate</title>
+   <para>
+     To set up TLS using a regular certificate and key on file, use
+     the <xref linkend="opt-services.discourse.sslCertificate" />
+     and <xref linkend="opt-services.discourse.sslCertificateKey" />
+     options:
+
+<programlisting>
+services.discourse = {
+  <link linkend="opt-services.discourse.enable">enable</link> = true;
+  <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com";
+  <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate";
+  <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key";
+  admin = {
+    <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com";
+    <link linkend="opt-services.discourse.admin.username">username</link> = "admin";
+    <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator";
+    <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file";
+  };
+  <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file";
+};
+</programlisting>
+
+   </para>
+ </section>
+
+ <section xml:id="module-services-discourse-database">
+   <title>Database access</title>
+   <para>
+     <productname>Discourse</productname> uses
+     <productname>PostgreSQL</productname> to store most of its
+     data. A database will automatically be enabled and a database
+     and role created unless <xref
+     linkend="opt-services.discourse.database.host" /> is changed from
+     its default of <literal>null</literal> or <xref
+     linkend="opt-services.discourse.database.createLocally" /> is set
+     to <literal>false</literal>.
+   </para>
+
+   <para>
+     External database access can also be configured by setting
+     <xref linkend="opt-services.discourse.database.host" />, <xref
+     linkend="opt-services.discourse.database.username" /> and <xref
+     linkend="opt-services.discourse.database.passwordFile" /> as
+     appropriate. Note that you need to manually create a database
+     called <literal>discourse</literal> (or the name you chose in
+     <xref linkend="opt-services.discourse.database.name" />) and
+     allow the configured database user full access to it.
+   </para>
+ </section>
+
+ <section xml:id="module-services-discourse-mail">
+   <title>Email</title>
+   <para>
+     In addition to the basic setup, you'll want to configure an SMTP
+     server <productname>Discourse</productname> can use to send user
+     registration and password reset emails, among others. You can
+     also optionally let <productname>Discourse</productname> receive
+     email, which enables people to reply to threads and conversations
+     via email.
+   </para>
+
+   <para>
+     A basic setup which assumes you want to use your configured <link
+     linkend="opt-services.discourse.hostname">hostname</link> as
+     email domain can be done like this:
+
+<programlisting>
+services.discourse = {
+  <link linkend="opt-services.discourse.enable">enable</link> = true;
+  <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com";
+  <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate";
+  <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key";
+  admin = {
+    <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com";
+    <link linkend="opt-services.discourse.admin.username">username</link> = "admin";
+    <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator";
+    <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file";
+  };
+  mail.outgoing = {
+    <link linkend="opt-services.discourse.mail.outgoing.serverAddress">serverAddress</link> = "smtp.emailprovider.com";
+    <link linkend="opt-services.discourse.mail.outgoing.port">port</link> = 587;
+    <link linkend="opt-services.discourse.mail.outgoing.username">username</link> = "user@emailprovider.com";
+    <link linkend="opt-services.discourse.mail.outgoing.passwordFile">passwordFile</link> = "/path/to/smtp_password_file";
+  };
+  <link linkend="opt-services.discourse.mail.incoming.enable">mail.incoming.enable</link> = true;
+  <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file";
+};
+</programlisting>
+
+     This assumes you have set up an MX record for the address you've
+     set in <link linkend="opt-services.discourse.hostname">hostname</link> and
+     requires proper SPF, DKIM and DMARC configuration to be done for
+     the domain you're sending from, in order for email to be reliably delivered.
+   </para>
+
+   <para>
+     If you want to use a different domain for your outgoing email
+     (for example <literal>example.com</literal> instead of
+     <literal>discourse.example.com</literal>) you should set
+     <xref linkend="opt-services.discourse.mail.notificationEmailAddress" /> and
+     <xref linkend="opt-services.discourse.mail.contactEmailAddress" /> manually.
+   </para>
+
+   <note>
+     <para>
+       Setup of TLS for incoming email is currently only configured
+       automatically when a regular TLS certificate is used, i.e. when
+       <xref linkend="opt-services.discourse.sslCertificate" /> and
+       <xref linkend="opt-services.discourse.sslCertificateKey" /> are
+       set.
+     </para>
+   </note>
+
+ </section>
+
+ <section xml:id="module-services-discourse-settings">
+   <title>Additional settings</title>
+   <para>
+     Additional site settings and backend settings, for which no
+     explicit <productname>NixOS</productname> options are provided,
+     can be set in <xref linkend="opt-services.discourse.siteSettings" /> and
+     <xref linkend="opt-services.discourse.backendSettings" /> respectively.
+   </para>
+
+   <section xml:id="module-services-discourse-site-settings">
+     <title>Site settings</title>
+     <para>
+       <quote>Site settings</quote> are the settings that can be
+       changed through the <productname>Discourse</productname>
+       UI. Their <emphasis>default</emphasis> values can be set using
+       <xref linkend="opt-services.discourse.siteSettings" />.
+     </para>
+
+     <para>
+       Settings are expressed as a Nix attribute set which matches the
+       structure of the configuration in
+       <link xlink:href="https://github.com/discourse/discourse/blob/master/config/site_settings.yml">config/site_settings.yml</link>.
+       To find a setting's path, you only need to care about the first
+       two levels; i.e. its category (e.g. <literal>login</literal>)
+       and name (e.g. <literal>invite_only</literal>).
+     </para>
+
+     <para>
+       Settings containing secret data should be set to an attribute
+       set containing the attribute <literal>_secret</literal> - a
+       string pointing to a file containing the value the option
+       should be set to. See the example.
+     </para>
+   </section>
+
+   <section xml:id="module-services-discourse-backend-settings">
+     <title>Backend settings</title>
+     <para>
+       Settings are expressed as a Nix attribute set which matches the
+       structure of the configuration in
+       <link xlink:href="https://github.com/discourse/discourse/blob/stable/config/discourse_defaults.conf">config/discourse.conf</link>.
+       Empty parameters can be defined by setting them to
+       <literal>null</literal>.
+     </para>
+   </section>
+
+   <section xml:id="module-services-discourse-settings-example">
+     <title>Example</title>
+     <para>
+       The following example sets the title and description of the
+       <productname>Discourse</productname> instance and enables
+       <productname>GitHub</productname> login in the site settings,
+       and changes a few request limits in the backend settings:
+<programlisting>
+services.discourse = {
+  <link linkend="opt-services.discourse.enable">enable</link> = true;
+  <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com";
+  <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate";
+  <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key";
+  admin = {
+    <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com";
+    <link linkend="opt-services.discourse.admin.username">username</link> = "admin";
+    <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator";
+    <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file";
+  };
+  mail.outgoing = {
+    <link linkend="opt-services.discourse.mail.outgoing.serverAddress">serverAddress</link> = "smtp.emailprovider.com";
+    <link linkend="opt-services.discourse.mail.outgoing.port">port</link> = 587;
+    <link linkend="opt-services.discourse.mail.outgoing.username">username</link> = "user@emailprovider.com";
+    <link linkend="opt-services.discourse.mail.outgoing.passwordFile">passwordFile</link> = "/path/to/smtp_password_file";
+  };
+  <link linkend="opt-services.discourse.mail.incoming.enable">mail.incoming.enable</link> = true;
+  <link linkend="opt-services.discourse.siteSettings">siteSettings</link> = {
+    required = {
+      title = "My Cats";
+      site_description = "Discuss My Cats (and be nice plz)";
+    };
+    login = {
+      enable_github_logins = true;
+      github_client_id = "a2f6dfe838cb3206ce20";
+      github_client_secret._secret = /run/keys/discourse_github_client_secret;
+    };
+  };
+  <link linkend="opt-services.discourse.backendSettings">backendSettings</link> = {
+    max_reqs_per_ip_per_minute = 300;
+    max_reqs_per_ip_per_10_seconds = 60;
+    max_asset_reqs_per_ip_per_10_seconds = 250;
+    max_reqs_per_ip_mode = "warn+block";
+  };
+  <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file";
+};
+</programlisting>
+     </para>
+     <para>
+       In the resulting site settings file, the
+       <literal>login.github_client_secret</literal> key will be set
+       to the contents of the
+       <filename>/run/keys/discourse_github_client_secret</filename>
+       file.
+     </para>
+   </section>
+ </section>
+  <section xml:id="module-services-discourse-plugins">
+    <title>Plugins</title>
+    <para>
+      You can install <productname>Discourse</productname> plugins
+      using the <xref linkend="opt-services.discourse.plugins" />
+      option. Pre-packaged plugins are provided in
+      <literal>&lt;your_discourse_package_here&gt;.plugins</literal>. If
+      you want the full suite of plugins provided through
+      <literal>nixpkgs</literal>, you can also set the <xref
+      linkend="opt-services.discourse.package" /> option to
+      <literal>pkgs.discourseAllPlugins</literal>.
+    </para>
+
+    <para>
+      Plugins can be built with the
+      <literal>&lt;your_discourse_package_here&gt;.mkDiscoursePlugin</literal>
+      function. Normally, it should suffice to provide a
+      <literal>name</literal> and <literal>src</literal> attribute. If
+      the plugin has Ruby dependencies, however, they need to be
+      packaged in accordance with the <link
+      xlink:href="https://nixos.org/manual/nixpkgs/stable/#developing-with-ruby">Developing
+      with Ruby</link> section of the Nixpkgs manual and the
+      appropriate gem options set in <literal>bundlerEnvArgs</literal>
+      (normally <literal>gemdir</literal> is sufficient). A plugin's
+      Ruby dependencies are listed in its
+      <filename>plugin.rb</filename> file as function calls to
+      <literal>gem</literal>. To construct the corresponding
+      <filename>Gemfile</filename> manually, run <command>bundle
+      init</command>, then add the <literal>gem</literal> lines to it
+      verbatim.
+    </para>
+
+    <para>
+      Much of the packaging can be done automatically by the
+      <filename>nixpkgs/pkgs/servers/web-apps/discourse/update.py</filename>
+      script - just add the plugin to the <literal>plugins</literal>
+      list in the <function>update_plugins</function> function and run
+      the script:
+      <programlisting language="bash">
+./update.py update-plugins
+</programlisting>
+    </para>
+
+    <para>
+      Some plugins provide <link
+      linkend="module-services-discourse-site-settings">site
+      settings</link>. Their defaults can be configured using <xref
+      linkend="opt-services.discourse.siteSettings" />, just like
+      regular site settings. To find the names of these settings, look
+      in the <literal>config/settings.yml</literal> file of the plugin
+      repo.
+    </para>
+
+    <para>
+      For example, to add the <link
+      xlink:href="https://github.com/discourse/discourse-spoiler-alert">discourse-spoiler-alert</link>
+      and <link
+      xlink:href="https://github.com/discourse/discourse-solved">discourse-solved</link>
+      plugins, and disable <literal>discourse-spoiler-alert</literal>
+      by default:
+
+<programlisting>
+services.discourse = {
+  <link linkend="opt-services.discourse.enable">enable</link> = true;
+  <link linkend="opt-services.discourse.hostname">hostname</link> = "discourse.example.com";
+  <link linkend="opt-services.discourse.sslCertificate">sslCertificate</link> = "/path/to/ssl_certificate";
+  <link linkend="opt-services.discourse.sslCertificateKey">sslCertificateKey</link> = "/path/to/ssl_certificate_key";
+  admin = {
+    <link linkend="opt-services.discourse.admin.email">email</link> = "admin@example.com";
+    <link linkend="opt-services.discourse.admin.username">username</link> = "admin";
+    <link linkend="opt-services.discourse.admin.fullName">fullName</link> = "Administrator";
+    <link linkend="opt-services.discourse.admin.passwordFile">passwordFile</link> = "/path/to/password_file";
+  };
+  mail.outgoing = {
+    <link linkend="opt-services.discourse.mail.outgoing.serverAddress">serverAddress</link> = "smtp.emailprovider.com";
+    <link linkend="opt-services.discourse.mail.outgoing.port">port</link> = 587;
+    <link linkend="opt-services.discourse.mail.outgoing.username">username</link> = "user@emailprovider.com";
+    <link linkend="opt-services.discourse.mail.outgoing.passwordFile">passwordFile</link> = "/path/to/smtp_password_file";
+  };
+  <link linkend="opt-services.discourse.mail.incoming.enable">mail.incoming.enable</link> = true;
+  <link linkend="opt-services.discourse.mail.incoming.enable">plugins</link> = with config.services.discourse.package.plugins; [
+    discourse-spoiler-alert
+    discourse-solved
+  ];
+  <link linkend="opt-services.discourse.siteSettings">siteSettings</link> = {
+    plugins = {
+      spoiler_enabled = false;
+    };
+  };
+  <link linkend="opt-services.discourse.secretKeyBaseFile">secretKeyBaseFile</link> = "/path/to/secret_key_base_file";
+};
+</programlisting>
+
+    </para>
+  </section>
+</chapter>
diff --git a/nixos/modules/services/web-apps/documize.nix b/nixos/modules/services/web-apps/documize.nix
new file mode 100644
index 00000000000..7f2ed82ee33
--- /dev/null
+++ b/nixos/modules/services/web-apps/documize.nix
@@ -0,0 +1,150 @@
+{ pkgs, lib, config, ... }:
+
+with lib;
+
+let
+  cfg = config.services.documize;
+
+  mkParams = optional: concatMapStrings (name: let
+    predicate = optional -> cfg.${name} != null;
+    template = " -${name} '${toString cfg.${name}}'";
+  in optionalString predicate template);
+
+in {
+  options.services.documize = {
+    enable = mkEnableOption "Documize Wiki";
+
+    stateDirectoryName = mkOption {
+      type = types.str;
+      default = "documize";
+      description = ''
+        The name of the directory below <filename>/var/lib/private</filename>
+        where documize runs in and stores, for example, backups.
+      '';
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.documize-community;
+      defaultText = literalExpression "pkgs.documize-community";
+      description = ''
+        Which package to use for documize.
+      '';
+    };
+
+    salt = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      example = "3edIYV6c8B28b19fh";
+      description = ''
+        The salt string used to encode JWT tokens, if not set a random value will be generated.
+      '';
+    };
+
+    cert = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        The <filename>cert.pem</filename> file used for https.
+      '';
+    };
+
+    key = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        The <filename>key.pem</filename> file used for https.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5001;
+      description = ''
+        The http/https port number.
+      '';
+    };
+
+    forcesslport = mkOption {
+      type = types.nullOr types.port;
+      default = null;
+      description = ''
+        Redirect given http port number to TLS.
+      '';
+    };
+
+    offline = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Set <literal>true</literal> for offline mode.
+      '';
+      apply = v: if true == v then 1 else 0;
+    };
+
+    dbtype = mkOption {
+      type = types.enum [ "mysql" "percona" "mariadb" "postgresql" "sqlserver" ];
+      default = "postgresql";
+      description = ''
+        Specify the database provider:
+        <simplelist type='inline'>
+          <member><literal>mysql</literal></member>
+          <member><literal>percona</literal></member>
+          <member><literal>mariadb</literal></member>
+          <member><literal>postgresql</literal></member>
+          <member><literal>sqlserver</literal></member>
+        </simplelist>
+      '';
+    };
+
+    db = mkOption {
+      type = types.str;
+      description = ''
+        Database specific connection string for example:
+        <itemizedlist>
+        <listitem><para>MySQL/Percona/MariaDB:
+          <literal>user:password@tcp(host:3306)/documize</literal>
+        </para></listitem>
+        <listitem><para>MySQLv8+:
+          <literal>user:password@tcp(host:3306)/documize?allowNativePasswords=true</literal>
+        </para></listitem>
+        <listitem><para>PostgreSQL:
+          <literal>host=localhost port=5432 dbname=documize user=admin password=secret sslmode=disable</literal>
+        </para></listitem>
+        <listitem><para>MSSQL:
+          <literal>sqlserver://username:password@localhost:1433?database=Documize</literal> or
+          <literal>sqlserver://sa@localhost/SQLExpress?database=Documize</literal>
+        </para></listitem>
+        </itemizedlist>
+      '';
+    };
+
+    location = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        reserved
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.documize-server = {
+      description = "Documize Wiki";
+      documentation = [ "https://documize.com/" ];
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        ExecStart = concatStringsSep " " [
+          "${cfg.package}/bin/documize"
+          (mkParams false [ "db" "dbtype" "port" ])
+          (mkParams true [ "offline" "location" "forcesslport" "key" "cert" "salt" ])
+        ];
+        Restart = "always";
+        DynamicUser = "yes";
+        StateDirectory = cfg.stateDirectoryName;
+        WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/dokuwiki.nix b/nixos/modules/services/web-apps/dokuwiki.nix
new file mode 100644
index 00000000000..1f8ca742db9
--- /dev/null
+++ b/nixos/modules/services/web-apps/dokuwiki.nix
@@ -0,0 +1,439 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg = config.services.dokuwiki;
+  eachSite = cfg.sites;
+  user = "dokuwiki";
+  webserver = config.services.${cfg.webserver};
+  stateDir = hostName: "/var/lib/dokuwiki/${hostName}/data";
+
+  dokuwikiAclAuthConfig = hostName: cfg: pkgs.writeText "acl.auth-${hostName}.php" ''
+    # acl.auth.php
+    # <?php exit()?>
+    #
+    # Access Control Lists
+    #
+    ${toString cfg.acl}
+  '';
+
+  dokuwikiLocalConfig = hostName: cfg: pkgs.writeText "local-${hostName}.php" ''
+    <?php
+    $conf['savedir'] = '${cfg.stateDir}';
+    $conf['superuser'] = '${toString cfg.superUser}';
+    $conf['useacl'] = '${toString cfg.aclUse}';
+    $conf['disableactions'] = '${cfg.disableActions}';
+    ${toString cfg.extraConfig}
+  '';
+
+  dokuwikiPluginsLocalConfig = hostName: cfg: pkgs.writeText "plugins.local-${hostName}.php" ''
+    <?php
+    ${cfg.pluginsConfig}
+  '';
+
+
+  pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
+    pname = "dokuwiki-${hostName}";
+    version = src.version;
+    src = cfg.package;
+
+    installPhase = ''
+      mkdir -p $out
+      cp -r * $out/
+
+      # symlink the dokuwiki config
+      ln -s ${dokuwikiLocalConfig hostName cfg} $out/share/dokuwiki/local.php
+
+      # symlink plugins config
+      ln -s ${dokuwikiPluginsLocalConfig hostName cfg} $out/share/dokuwiki/plugins.local.php
+
+      # symlink acl
+      ln -s ${dokuwikiAclAuthConfig hostName cfg} $out/share/dokuwiki/acl.auth.php
+
+      # symlink additional plugin(s) and templates(s)
+      ${concatMapStringsSep "\n" (template: "ln -s ${template} $out/share/dokuwiki/lib/tpl/${template.name}") cfg.templates}
+      ${concatMapStringsSep "\n" (plugin: "ln -s ${plugin} $out/share/dokuwiki/lib/plugins/${plugin.name}") cfg.plugins}
+    '';
+  };
+
+  siteOpts = { config, lib, name, ... }:
+    {
+      options = {
+        enable = mkEnableOption "DokuWiki web application.";
+
+        package = mkOption {
+          type = types.package;
+          default = pkgs.dokuwiki;
+          defaultText = literalExpression "pkgs.dokuwiki";
+          description = "Which DokuWiki package to use.";
+        };
+
+        stateDir = mkOption {
+          type = types.path;
+          default = "/var/lib/dokuwiki/${name}/data";
+          description = "Location of the DokuWiki state directory.";
+        };
+
+        acl = mkOption {
+          type = types.nullOr types.lines;
+          default = null;
+          example = "*               @ALL               8";
+          description = ''
+            Access Control Lists: see <link xlink:href="https://www.dokuwiki.org/acl"/>
+            Mutually exclusive with services.dokuwiki.aclFile
+            Set this to a value other than null to take precedence over aclFile option.
+
+            Warning: Consider using aclFile instead if you do not
+            want to store the ACL in the world-readable Nix store.
+          '';
+        };
+
+        aclFile = mkOption {
+          type = with types; nullOr str;
+          default = if (config.aclUse && config.acl == null) then "/var/lib/dokuwiki/${name}/acl.auth.php" else null;
+          description = ''
+            Location of the dokuwiki acl rules. Mutually exclusive with services.dokuwiki.acl
+            Mutually exclusive with services.dokuwiki.acl which is preferred.
+            Consult documentation <link xlink:href="https://www.dokuwiki.org/acl"/> for further instructions.
+            Example: <link xlink:href="https://github.com/splitbrain/dokuwiki/blob/master/conf/acl.auth.php.dist"/>
+          '';
+          example = "/var/lib/dokuwiki/${name}/acl.auth.php";
+        };
+
+        aclUse = mkOption {
+          type = types.bool;
+          default = true;
+          description = ''
+            Necessary for users to log in into the system.
+            Also limits anonymous users. When disabled,
+            everyone is able to create and edit content.
+          '';
+        };
+
+        pluginsConfig = mkOption {
+          type = types.lines;
+          default = ''
+            $plugins['authad'] = 0;
+            $plugins['authldap'] = 0;
+            $plugins['authmysql'] = 0;
+            $plugins['authpgsql'] = 0;
+          '';
+          description = ''
+            List of the dokuwiki (un)loaded plugins.
+          '';
+        };
+
+        superUser = mkOption {
+          type = types.nullOr types.str;
+          default = "@admin";
+          description = ''
+            You can set either a username, a list of usernames (“admin1,admin2”),
+            or the name of a group by prepending an @ char to the groupname
+            Consult documentation <link xlink:href="https://www.dokuwiki.org/config:superuser"/> for further instructions.
+          '';
+        };
+
+        usersFile = mkOption {
+          type = with types; nullOr str;
+          default = if config.aclUse then "/var/lib/dokuwiki/${name}/users.auth.php" else null;
+          description = ''
+            Location of the dokuwiki users file. List of users. Format:
+            login:passwordhash:Real Name:email:groups,comma,separated
+            Create passwordHash easily by using:$ mkpasswd -5 password `pwgen 8 1`
+            Example: <link xlink:href="https://github.com/splitbrain/dokuwiki/blob/master/conf/users.auth.php.dist"/>
+            '';
+          example = "/var/lib/dokuwiki/${name}/users.auth.php";
+        };
+
+        disableActions = mkOption {
+          type = types.nullOr types.str;
+          default = "";
+          example = "search,register";
+          description = ''
+            Disable individual action modes. Refer to
+            <link xlink:href="https://www.dokuwiki.org/config:action_modes"/>
+            for details on supported values.
+          '';
+        };
+
+        plugins = mkOption {
+          type = types.listOf types.path;
+          default = [];
+          description = ''
+                List of path(s) to respective plugin(s) which are copied from the 'plugin' directory.
+                <note><para>These plugins need to be packaged before use, see example.</para></note>
+          '';
+          example = literalExpression ''
+                let
+                  # Let's package the icalevents plugin
+                  plugin-icalevents = pkgs.stdenv.mkDerivation {
+                    name = "icalevents";
+                    # Download the plugin from the dokuwiki site
+                    src = pkgs.fetchurl {
+                      url = "https://github.com/real-or-random/dokuwiki-plugin-icalevents/releases/download/2017-06-16/dokuwiki-plugin-icalevents-2017-06-16.zip";
+                      sha256 = "e40ed7dd6bbe7fe3363bbbecb4de481d5e42385b5a0f62f6a6ce6bf3a1f9dfa8";
+                    };
+                    sourceRoot = ".";
+                    # We need unzip to build this package
+                    buildInputs = [ pkgs.unzip ];
+                    # Installing simply means copying all files to the output directory
+                    installPhase = "mkdir -p $out; cp -R * $out/";
+                  };
+                # And then pass this theme to the plugin list like this:
+                in [ plugin-icalevents ]
+          '';
+        };
+
+        templates = mkOption {
+          type = types.listOf types.path;
+          default = [];
+          description = ''
+                List of path(s) to respective template(s) which are copied from the 'tpl' directory.
+                <note><para>These templates need to be packaged before use, see example.</para></note>
+          '';
+          example = literalExpression ''
+                let
+                  # Let's package the bootstrap3 theme
+                  template-bootstrap3 = pkgs.stdenv.mkDerivation {
+                    name = "bootstrap3";
+                    # Download the theme from the dokuwiki site
+                    src = pkgs.fetchurl {
+                      url = "https://github.com/giterlizzi/dokuwiki-template-bootstrap3/archive/v2019-05-22.zip";
+                      sha256 = "4de5ff31d54dd61bbccaf092c9e74c1af3a4c53e07aa59f60457a8f00cfb23a6";
+                    };
+                    # We need unzip to build this package
+                    buildInputs = [ pkgs.unzip ];
+                    # Installing simply means copying all files to the output directory
+                    installPhase = "mkdir -p $out; cp -R * $out/";
+                  };
+                # And then pass this theme to the template list like this:
+                in [ template-bootstrap3 ]
+          '';
+        };
+
+        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 DokuWiki PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+            for details on configuration directives.
+          '';
+        };
+
+        extraConfig = mkOption {
+          type = types.nullOr types.lines;
+          default = null;
+          example = ''
+            $conf['title'] = 'My Wiki';
+            $conf['userewrite'] = 1;
+          '';
+          description = ''
+            DokuWiki configuration. Refer to
+            <link xlink:href="https://www.dokuwiki.org/config"/>
+            for details on supported values.
+          '';
+        };
+
+      };
+
+    };
+in
+{
+  # interface
+  options = {
+    services.dokuwiki = {
+
+      sites = mkOption {
+        type = types.attrsOf (types.submodule siteOpts);
+        default = {};
+        description = "Specification of one or more DokuWiki sites to serve";
+      };
+
+      webserver = mkOption {
+        type = types.enum [ "nginx" "caddy" ];
+        default = "nginx";
+        description = ''
+          Whether to use nginx or caddy for virtual host management.
+
+          Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.&lt;name&gt;</literal>.
+          See <xref linkend="opt-services.nginx.virtualHosts"/> for further information.
+
+          Further apache2 configuration can be done by adapting <literal>services.httpd.virtualHosts.&lt;name&gt;</literal>.
+          See <xref linkend="opt-services.httpd.virtualHosts"/> for further information.
+        '';
+      };
+
+    };
+  };
+
+  # implementation
+  config = mkIf (eachSite != {}) (mkMerge [{
+
+    assertions = flatten (mapAttrsToList (hostName: cfg:
+    [{
+      assertion = cfg.aclUse -> (cfg.acl != null || cfg.aclFile != null);
+      message = "Either services.dokuwiki.sites.${hostName}.acl or services.dokuwiki.sites.${hostName}.aclFile is mandatory if aclUse true";
+    }
+    {
+      assertion = cfg.usersFile != null -> cfg.aclUse != false;
+      message = "services.dokuwiki.sites.${hostName}.aclUse must must be true if usersFile is not null";
+    }
+    ]) eachSite);
+
+    services.phpfpm.pools = mapAttrs' (hostName: cfg: (
+      nameValuePair "dokuwiki-${hostName}" {
+        inherit user;
+        group = webserver.group;
+
+        # Not yet compatible with php 8 https://www.dokuwiki.org/requirements
+        # https://github.com/splitbrain/dokuwiki/issues/3545
+        phpPackage = pkgs.php74;
+        phpEnv = {
+          DOKUWIKI_LOCAL_CONFIG = "${dokuwikiLocalConfig hostName cfg}";
+          DOKUWIKI_PLUGINS_LOCAL_CONFIG = "${dokuwikiPluginsLocalConfig hostName cfg}";
+        } // optionalAttrs (cfg.usersFile != null) {
+          DOKUWIKI_USERS_AUTH_CONFIG = "${cfg.usersFile}";
+        } //optionalAttrs (cfg.aclUse) {
+          DOKUWIKI_ACL_AUTH_CONFIG = if (cfg.acl != null) then "${dokuwikiAclAuthConfig hostName cfg}" else "${toString cfg.aclFile}";
+        };
+
+        settings = {
+          "listen.owner" = webserver.user;
+          "listen.group" = webserver.group;
+        } // cfg.poolConfig;
+      }
+    )) eachSite;
+
+  }
+
+  {
+    systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
+      "d ${stateDir hostName}/attic 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/cache 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/index 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/locks 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/media 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/media_attic 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/media_meta 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/meta 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/pages 0750 ${user} ${webserver.group} - -"
+      "d ${stateDir hostName}/tmp 0750 ${user} ${webserver.group} - -"
+    ] ++ lib.optional (cfg.aclFile != null) "C ${cfg.aclFile} 0640 ${user} ${webserver.group} - ${pkg hostName cfg}/share/dokuwiki/conf/acl.auth.php.dist"
+    ++ lib.optional (cfg.usersFile != null) "C ${cfg.usersFile} 0640 ${user} ${webserver.group} - ${pkg hostName cfg}/share/dokuwiki/conf/users.auth.php.dist"
+    ) eachSite);
+
+    users.users.${user} = {
+      group = webserver.group;
+      isSystemUser = true;
+    };
+  }
+
+  (mkIf (cfg.webserver == "nginx") {
+    services.nginx = {
+      enable = true;
+      virtualHosts = mapAttrs (hostName: cfg: {
+        serverName = mkDefault hostName;
+        root = "${pkg hostName cfg}/share/dokuwiki";
+
+        locations = {
+          "~ /(conf/|bin/|inc/|install.php)" = {
+            extraConfig = "deny all;";
+          };
+
+          "~ ^/data/" = {
+            root = "${stateDir hostName}";
+            extraConfig = "internal;";
+          };
+
+          "~ ^/lib.*\.(js|css|gif|png|ico|jpg|jpeg)$" = {
+            extraConfig = "expires 365d;";
+          };
+
+          "/" = {
+            priority = 1;
+            index = "doku.php";
+            extraConfig = ''try_files $uri $uri/ @dokuwiki;'';
+          };
+
+          "@dokuwiki" = {
+            extraConfig = ''
+              # rewrites "doku.php/" out of the URLs if you set the userwrite setting to .htaccess in dokuwiki config page
+              rewrite ^/_media/(.*) /lib/exe/fetch.php?media=$1 last;
+              rewrite ^/_detail/(.*) /lib/exe/detail.php?media=$1 last;
+              rewrite ^/_export/([^/]+)/(.*) /doku.php?do=export_$1&id=$2 last;
+              rewrite ^/(.*) /doku.php?id=$1&$args last;
+            '';
+          };
+
+          "~ \\.php$" = {
+            extraConfig = ''
+              try_files $uri $uri/ /doku.php;
+              include ${config.services.nginx.package}/conf/fastcgi_params;
+              fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+              fastcgi_param REDIRECT_STATUS 200;
+              fastcgi_pass unix:${config.services.phpfpm.pools."dokuwiki-${hostName}".socket};
+              '';
+          };
+
+        };
+      }) eachSite;
+    };
+  })
+
+  (mkIf (cfg.webserver == "caddy") {
+    services.caddy = {
+      enable = true;
+      virtualHosts = mapAttrs' (hostName: cfg: (
+        nameValuePair "http://${hostName}" {
+          extraConfig = ''
+            root * ${pkg hostName cfg}/share/dokuwiki
+            file_server
+
+            encode zstd gzip
+            php_fastcgi unix/${config.services.phpfpm.pools."dokuwiki-${hostName}".socket}
+
+            @restrict_files {
+              path /data/* /conf/* /bin/* /inc/* /vendor/* /install.php
+            }
+
+            respond @restrict_files 404
+
+            @allow_media {
+              path_regexp path ^/_media/(.*)$
+            }
+            rewrite @allow_media /lib/exe/fetch.php?media=/{http.regexp.path.1}
+
+            @allow_detail   {
+              path /_detail*
+            }
+            rewrite @allow_detail /lib/exe/detail.php?media={path}
+
+            @allow_export   {
+              path /_export*
+              path_regexp export /([^/]+)/(.*)
+            }
+            rewrite @allow_export /doku.php?do=export_{http.regexp.export.1}&id={http.regexp.export.2}
+
+            try_files {path} {path}/ /doku.php?id={path}&{query}
+          '';
+        }
+      )) eachSite;
+    };
+  })
+
+  ]);
+
+  meta.maintainers = with maintainers; [
+    _1000101
+    onny
+    dandellion
+  ];
+}
diff --git a/nixos/modules/services/web-apps/engelsystem.nix b/nixos/modules/services/web-apps/engelsystem.nix
new file mode 100644
index 00000000000..06c3c6dfc3d
--- /dev/null
+++ b/nixos/modules/services/web-apps/engelsystem.nix
@@ -0,0 +1,186 @@
+{ config, lib, pkgs, utils, ... }:
+
+let
+  inherit (lib) mkDefault mkEnableOption mkIf mkOption types literalExpression;
+  cfg = config.services.engelsystem;
+in {
+  options = {
+    services.engelsystem = {
+      enable = mkOption {
+        default = false;
+        example = true;
+        description = ''
+          Whether to enable engelsystem, an online tool for coordinating volunteers
+          and shifts on large events.
+        '';
+        type = lib.types.bool;
+      };
+
+      domain = mkOption {
+        type = types.str;
+        example = "engelsystem.example.com";
+        description = "Domain to serve on.";
+      };
+
+      package = mkOption {
+        type = types.package;
+        description = "Engelsystem package used for the service.";
+        default = pkgs.engelsystem;
+        defaultText = literalExpression "pkgs.engelsystem";
+      };
+
+      createDatabase = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to create a local database automatically.
+          This will override every database setting in <option>services.engelsystem.config</option>.
+        '';
+      };
+    };
+
+    services.engelsystem.config = mkOption {
+      type = types.attrs;
+      default = {
+        database = {
+          host = "localhost";
+          database = "engelsystem";
+          username = "engelsystem";
+        };
+      };
+      example = {
+        maintenance = false;
+        database = {
+          host = "database.example.com";
+          database = "engelsystem";
+          username = "engelsystem";
+          password._secret = "/var/keys/engelsystem/database";
+        };
+        email = {
+          driver = "smtp";
+          host = "smtp.example.com";
+          port = 587;
+          from.address = "engelsystem@example.com";
+          from.name = "example engelsystem";
+          encryption = "tls";
+          username = "engelsystem@example.com";
+          password._secret = "/var/keys/engelsystem/mail";
+        };
+        autoarrive = true;
+        min_password_length = 6;
+        default_locale = "de_DE";
+      };
+      description = ''
+        Options to be added to config.php, as a nix attribute set. Options containing secret data
+        should be set to an attribute set containing the attribute _secret - a string pointing to a
+        file containing the value the option should be set to. See the example to get a better
+        picture of this: in the resulting config.php file, the email.password key will be set to
+        the contents of the /var/keys/engelsystem/mail file.
+
+        See https://engelsystem.de/doc/admin/configuration/ for available options.
+
+        Note that the admin user login credentials cannot be set here - they always default to
+        admin:asdfasdf. Log in and change them immediately.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # create database
+    services.mysql = mkIf cfg.createDatabase {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureUsers = [{
+        name = "engelsystem";
+        ensurePermissions = { "engelsystem.*" = "ALL PRIVILEGES"; };
+      }];
+      ensureDatabases = [ "engelsystem" ];
+    };
+
+    environment.etc."engelsystem/config.php".source =
+      pkgs.writeText "config.php" ''
+        <?php
+        return json_decode(file_get_contents("/var/lib/engelsystem/config.json"), true);
+      '';
+
+    services.phpfpm.pools.engelsystem = {
+      user = "engelsystem";
+      settings = {
+        "listen.owner" = config.services.nginx.user;
+        "pm" = "dynamic";
+        "pm.max_children" = 32;
+        "pm.max_requests" = 500;
+        "pm.start_servers" = 2;
+        "pm.min_spare_servers" = 2;
+        "pm.max_spare_servers" = 5;
+        "php_admin_value[error_log]" = "stderr";
+        "php_admin_flag[log_errors]" = true;
+        "catch_workers_output" = true;
+      };
+    };
+
+    services.nginx = {
+      enable = true;
+      virtualHosts."${cfg.domain}".locations = {
+        "/" = {
+          root = "${cfg.package}/share/engelsystem/public";
+          extraConfig = ''
+            index index.php;
+            try_files $uri $uri/ /index.php?$args;
+            autoindex off;
+          '';
+        };
+        "~ \\.php$" = {
+          root = "${cfg.package}/share/engelsystem/public";
+          extraConfig = ''
+            fastcgi_pass unix:${config.services.phpfpm.pools.engelsystem.socket};
+            fastcgi_index index.php;
+            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+            include ${config.services.nginx.package}/conf/fastcgi_params;
+            include ${config.services.nginx.package}/conf/fastcgi.conf;
+          '';
+        };
+      };
+    };
+
+    systemd.services."engelsystem-init" = {
+      wantedBy = [ "multi-user.target" ];
+      serviceConfig = { Type = "oneshot"; };
+      script =
+        let
+          genConfigScript = pkgs.writeScript "engelsystem-gen-config.sh"
+            (utils.genJqSecretsReplacementSnippet cfg.config "config.json");
+        in ''
+          umask 077
+          mkdir -p /var/lib/engelsystem/storage/app
+          mkdir -p /var/lib/engelsystem/storage/cache/views
+          cd /var/lib/engelsystem
+          ${genConfigScript}
+          chmod 400 config.json
+          chown -R engelsystem .
+      '';
+    };
+    systemd.services."engelsystem-migrate" = {
+      wantedBy = [ "multi-user.target" ];
+      serviceConfig = {
+        Type = "oneshot";
+        User = "engelsystem";
+        Group = "engelsystem";
+      };
+      script = ''
+        ${cfg.package}/bin/migrate
+      '';
+      after = [ "engelsystem-init.service" "mysql.service" ];
+    };
+    systemd.services."phpfpm-engelsystem".after =
+      [ "engelsystem-migrate.service" ];
+
+    users.users.engelsystem = {
+      isSystemUser = true;
+      createHome = true;
+      home = "/var/lib/engelsystem/storage";
+      group = "engelsystem";
+    };
+    users.groups.engelsystem = { };
+  };
+}
diff --git a/nixos/modules/services/web-apps/ethercalc.nix b/nixos/modules/services/web-apps/ethercalc.nix
new file mode 100644
index 00000000000..d74def59c6c
--- /dev/null
+++ b/nixos/modules/services/web-apps/ethercalc.nix
@@ -0,0 +1,62 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.ethercalc;
+in {
+  options = {
+    services.ethercalc = {
+      enable = mkOption {
+        default = false;
+        type = types.bool;
+        description = ''
+          ethercalc, an online collaborative spreadsheet server.
+
+          Persistent state will be maintained under
+          <filename>/var/lib/ethercalc</filename>. Upstream supports using a
+          redis server for storage and recommends the redis backend for
+          intensive use; however, the Nix module doesn't currently support
+          redis.
+
+          Note that while ethercalc is a good and robust project with an active
+          issue tracker, there haven't been new commits since the end of 2020.
+        '';
+      };
+
+      package = mkOption {
+        default = pkgs.ethercalc;
+        defaultText = literalExpression "pkgs.ethercalc";
+        type = types.package;
+        description = "Ethercalc package to use.";
+      };
+
+      host = mkOption {
+        type = types.str;
+        default = "0.0.0.0";
+        description = "Address to listen on (use 0.0.0.0 to allow access from any address).";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 8000;
+        description = "Port to bind to.";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.ethercalc = {
+      description = "Ethercalc service";
+      wantedBy    = [ "multi-user.target" ];
+      after       = [ "network.target" ];
+      serviceConfig = {
+        DynamicUser    =   true;
+        ExecStart        = "${cfg.package}/bin/ethercalc --host ${cfg.host} --port ${toString cfg.port}";
+        Restart          = "always";
+        StateDirectory   = "ethercalc";
+        WorkingDirectory = "/var/lib/ethercalc";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/fluidd.nix b/nixos/modules/services/web-apps/fluidd.nix
new file mode 100644
index 00000000000..6ac1acc9d03
--- /dev/null
+++ b/nixos/modules/services/web-apps/fluidd.nix
@@ -0,0 +1,66 @@
+{ config, lib, pkgs, ... }:
+with lib;
+let
+  cfg = config.services.fluidd;
+  moonraker = config.services.moonraker;
+in
+{
+  options.services.fluidd = {
+    enable = mkEnableOption "Fluidd, a Klipper web interface for managing your 3d printer";
+
+    package = mkOption {
+      type = types.package;
+      description = "Fluidd package to be used in the module";
+      default = pkgs.fluidd;
+      defaultText = literalExpression "pkgs.fluidd";
+    };
+
+    hostName = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = "Hostname to serve fluidd on";
+    };
+
+    nginx = mkOption {
+      type = types.submodule
+        (import ../web-servers/nginx/vhost-options.nix { inherit config lib; });
+      default = { };
+      example = literalExpression ''
+        {
+          serverAliases = [ "fluidd.''${config.networking.domain}" ];
+        }
+      '';
+      description = "Extra configuration for the nginx virtual host of fluidd.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.nginx = {
+      enable = true;
+      upstreams.fluidd-apiserver.servers."${moonraker.address}:${toString moonraker.port}" = { };
+      virtualHosts."${cfg.hostName}" = mkMerge [
+        cfg.nginx
+        {
+          root = mkForce "${cfg.package}/share/fluidd/htdocs";
+          locations = {
+            "/" = {
+              index = "index.html";
+              tryFiles = "$uri $uri/ /index.html";
+            };
+            "/index.html".extraConfig = ''
+              add_header Cache-Control "no-store, no-cache, must-revalidate";
+            '';
+            "/websocket" = {
+              proxyWebsockets = true;
+              proxyPass = "http://fluidd-apiserver/websocket";
+            };
+            "~ ^/(printer|api|access|machine|server)/" = {
+              proxyWebsockets = true;
+              proxyPass = "http://fluidd-apiserver$request_uri";
+            };
+          };
+        }
+      ];
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/galene.nix b/nixos/modules/services/web-apps/galene.nix
new file mode 100644
index 00000000000..1d0a620585b
--- /dev/null
+++ b/nixos/modules/services/web-apps/galene.nix
@@ -0,0 +1,185 @@
+{ config, lib, options, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.galene;
+  opt = options.services.galene;
+  defaultstateDir = "/var/lib/galene";
+  defaultrecordingsDir = "${cfg.stateDir}/recordings";
+  defaultgroupsDir = "${cfg.stateDir}/groups";
+  defaultdataDir = "${cfg.stateDir}/data";
+in
+{
+  options = {
+    services.galene = {
+      enable = mkEnableOption "Galene Service.";
+
+      stateDir = mkOption {
+        default = defaultstateDir;
+        type = types.str;
+        description = ''
+          The directory where Galene stores its internal state. If left as the default
+          value this directory will automatically be created before the Galene server
+          starts, otherwise the sysadmin is responsible for ensuring the directory
+          exists with appropriate ownership and permissions.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "galene";
+        description = "User account under which galene runs.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "galene";
+        description = "Group under which galene runs.";
+      };
+
+      insecure = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether Galene should listen in http or in https. If left as the default
+          value (false), Galene needs to be fed a private key and a certificate.
+        '';
+      };
+
+      certFile = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/path/to/your/cert.pem";
+        description = ''
+          Path to the server's certificate. The file is copied at runtime to
+          Galene's data directory where it needs to reside.
+        '';
+      };
+
+      keyFile = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/path/to/your/key.pem";
+        description = ''
+          Path to the server's private key. The file is copied at runtime to
+          Galene's data directory where it needs to reside.
+        '';
+      };
+
+      httpAddress = mkOption {
+        type = types.str;
+        default = "";
+        description = "HTTP listen address for galene.";
+      };
+
+      httpPort = mkOption {
+        type = types.port;
+        default = 8443;
+        description = "HTTP listen port.";
+      };
+
+      staticDir = mkOption {
+        type = types.str;
+        default = "${cfg.package.static}/static";
+        defaultText = literalExpression ''"''${package.static}/static"'';
+        example = "/var/lib/galene/static";
+        description = "Web server directory.";
+      };
+
+      recordingsDir = mkOption {
+        type = types.str;
+        default = defaultrecordingsDir;
+        defaultText = literalExpression ''"''${config.${opt.stateDir}}/recordings"'';
+        example = "/var/lib/galene/recordings";
+        description = "Recordings directory.";
+      };
+
+      dataDir = mkOption {
+        type = types.str;
+        default = defaultdataDir;
+        defaultText = literalExpression ''"''${config.${opt.stateDir}}/data"'';
+        example = "/var/lib/galene/data";
+        description = "Data directory.";
+      };
+
+      groupsDir = mkOption {
+        type = types.str;
+        default = defaultgroupsDir;
+        defaultText = literalExpression ''"''${config.${opt.stateDir}}/groups"'';
+        example = "/var/lib/galene/groups";
+        description = "Web server directory.";
+      };
+
+      package = mkOption {
+        default = pkgs.galene;
+        defaultText = literalExpression "pkgs.galene";
+        type = types.package;
+        description = ''
+          Package for running Galene.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = cfg.insecure || (cfg.certFile != null && cfg.keyFile != null);
+        message = ''
+          Galene needs both certFile and keyFile defined for encryption, or
+          the insecure flag.
+        '';
+      }
+    ];
+
+    systemd.services.galene = {
+      description = "galene";
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+
+      preStart = ''
+        ${optionalString (cfg.insecure != true) ''
+           install -m 700 -o '${cfg.user}' -g '${cfg.group}' ${cfg.certFile} ${cfg.dataDir}/cert.pem
+           install -m 700 -o '${cfg.user}' -g '${cfg.group}' ${cfg.keyFile} ${cfg.dataDir}/key.pem
+        ''}
+      '';
+
+      serviceConfig = mkMerge [
+        {
+          Type = "simple";
+          User = cfg.user;
+          Group = cfg.group;
+          WorkingDirectory = cfg.stateDir;
+          ExecStart = ''${cfg.package}/bin/galene \
+          ${optionalString (cfg.insecure) "-insecure"} \
+          -data ${cfg.dataDir} \
+          -groups ${cfg.groupsDir} \
+          -recordings ${cfg.recordingsDir} \
+          -static ${cfg.staticDir}'';
+          Restart = "always";
+          # Upstream Requirements
+          LimitNOFILE = 65536;
+          StateDirectory = [ ] ++
+            optional (cfg.stateDir == defaultstateDir) "galene" ++
+            optional (cfg.dataDir == defaultdataDir) "galene/data" ++
+            optional (cfg.groupsDir == defaultgroupsDir) "galene/groups" ++
+            optional (cfg.recordingsDir == defaultrecordingsDir) "galene/recordings";
+        }
+      ];
+    };
+
+    users.users = mkIf (cfg.user == "galene")
+      {
+        galene = {
+          description = "galene Service";
+          group = cfg.group;
+          isSystemUser = true;
+        };
+      };
+
+    users.groups = mkIf (cfg.group == "galene") {
+      galene = { };
+    };
+  };
+  meta.maintainers = with lib.maintainers; [ rgrunbla ];
+}
diff --git a/nixos/modules/services/web-apps/gerrit.nix b/nixos/modules/services/web-apps/gerrit.nix
new file mode 100644
index 00000000000..6bfc67368dd
--- /dev/null
+++ b/nixos/modules/services/web-apps/gerrit.nix
@@ -0,0 +1,242 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.gerrit;
+
+  # NixOS option type for git-like configs
+  gitIniType = with types;
+    let
+      primitiveType = either str (either bool int);
+      multipleType = either primitiveType (listOf primitiveType);
+      sectionType = lazyAttrsOf multipleType;
+      supersectionType = lazyAttrsOf (either multipleType sectionType);
+    in lazyAttrsOf supersectionType;
+
+  gerritConfig = pkgs.writeText "gerrit.conf" (
+    lib.generators.toGitINI cfg.settings
+  );
+
+  replicationConfig = pkgs.writeText "replication.conf" (
+    lib.generators.toGitINI cfg.replicationSettings
+  );
+
+  # Wrap the gerrit java with all the java options so it can be called
+  # like a normal CLI app
+  gerrit-cli = pkgs.writeShellScriptBin "gerrit" ''
+    set -euo pipefail
+    jvmOpts=(
+      ${lib.escapeShellArgs cfg.jvmOpts}
+      -Xmx${cfg.jvmHeapLimit}
+    )
+    exec ${cfg.jvmPackage}/bin/java \
+      "''${jvmOpts[@]}" \
+      -jar ${cfg.package}/webapps/${cfg.package.name}.war \
+      "$@"
+  '';
+
+  gerrit-plugins = pkgs.runCommand
+    "gerrit-plugins"
+    {
+      buildInputs = [ gerrit-cli ];
+    }
+    ''
+      shopt -s nullglob
+      mkdir $out
+
+      for name in ${toString cfg.builtinPlugins}; do
+        echo "Installing builtin plugin $name.jar"
+        gerrit cat plugins/$name.jar > $out/$name.jar
+      done
+
+      for file in ${toString cfg.plugins}; do
+        name=$(echo "$file" | cut -d - -f 2-)
+        echo "Installing plugin $name"
+        ln -sf "$file" $out/$name
+      done
+    '';
+in
+{
+  options = {
+    services.gerrit = {
+      enable = mkEnableOption "Gerrit service";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.gerrit;
+        defaultText = literalExpression "pkgs.gerrit";
+        description = "Gerrit package to use";
+      };
+
+      jvmPackage = mkOption {
+        type = types.package;
+        default = pkgs.jre_headless;
+        defaultText = literalExpression "pkgs.jre_headless";
+        description = "Java Runtime Environment package to use";
+      };
+
+      jvmOpts = mkOption {
+        type = types.listOf types.str;
+        default = [
+          "-Dflogger.backend_factory=com.google.common.flogger.backend.log4j.Log4jBackendFactory#getInstance"
+          "-Dflogger.logging_context=com.google.gerrit.server.logging.LoggingContext#getInstance"
+        ];
+        description = "A list of JVM options to start gerrit with.";
+      };
+
+      jvmHeapLimit = mkOption {
+        type = types.str;
+        default = "1024m";
+        description = ''
+          How much memory to allocate to the JVM heap
+        '';
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "[::]:8080";
+        description = ''
+          <literal>hostname:port</literal> to listen for HTTP traffic.
+
+          This is bound using the systemd socket activation.
+        '';
+      };
+
+      settings = mkOption {
+        type = gitIniType;
+        default = {};
+        description = ''
+          Gerrit configuration. This will be generated to the
+          <literal>etc/gerrit.config</literal> file.
+        '';
+      };
+
+      replicationSettings = mkOption {
+        type = gitIniType;
+        default = {};
+        description = ''
+          Replication configuration. This will be generated to the
+          <literal>etc/replication.config</literal> file.
+        '';
+      };
+
+      plugins = mkOption {
+        type = types.listOf types.package;
+        default = [];
+        description = ''
+          List of plugins to add to Gerrit. Each derivation is a jar file
+          itself where the name of the derivation is the name of plugin.
+        '';
+      };
+
+      builtinPlugins = mkOption {
+        type = types.listOf (types.enum cfg.package.passthru.plugins);
+        default = [];
+        description = ''
+          List of builtins plugins to install. Those are shipped in the
+          <literal>gerrit.war</literal> file.
+        '';
+      };
+
+      serverId = mkOption {
+        type = types.str;
+        description = ''
+          Set a UUID that uniquely identifies the server.
+
+          This can be generated with
+          <literal>nix-shell -p util-linux --run uuidgen</literal>.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+
+    assertions = [
+      {
+        assertion = cfg.replicationSettings != {} -> elem "replication" cfg.builtinPlugins;
+        message = "Gerrit replicationSettings require enabling the replication plugin";
+      }
+    ];
+
+    services.gerrit.settings = {
+      cache.directory = "/var/cache/gerrit";
+      container.heapLimit = cfg.jvmHeapLimit;
+      gerrit.basePath = lib.mkDefault "git";
+      gerrit.serverId = cfg.serverId;
+      httpd.inheritChannel = "true";
+      httpd.listenUrl = lib.mkDefault "http://${cfg.listenAddress}";
+      index.type = lib.mkDefault "lucene";
+    };
+
+    # Add the gerrit CLI to the system to run `gerrit init` and friends.
+    environment.systemPackages = [ gerrit-cli ];
+
+    systemd.sockets.gerrit = {
+      unitConfig.Description = "Gerrit HTTP socket";
+      wantedBy = [ "sockets.target" ];
+      listenStreams = [ cfg.listenAddress ];
+    };
+
+    systemd.services.gerrit = {
+      description = "Gerrit";
+
+      wantedBy = [ "multi-user.target" ];
+      requires = [ "gerrit.socket" ];
+      after = [ "gerrit.socket" "network.target" ];
+
+      path = [
+        gerrit-cli
+        pkgs.bash
+        pkgs.coreutils
+        pkgs.git
+        pkgs.openssh
+      ];
+
+      environment = {
+        GERRIT_HOME = "%S/gerrit";
+        GERRIT_TMP = "%T";
+        HOME = "%S/gerrit";
+        XDG_CONFIG_HOME = "%S/gerrit/.config";
+      };
+
+      preStart = ''
+        set -euo pipefail
+
+        # bootstrap if nothing exists
+        if [[ ! -d git ]]; then
+          gerrit init --batch --no-auto-start
+        fi
+
+        # install gerrit.war for the plugin manager
+        rm -rf bin
+        mkdir bin
+        ln -sfv ${cfg.package}/webapps/${cfg.package.name}.war bin/gerrit.war
+
+        # copy the config, keep it mutable because Gerrit
+        ln -sfv ${gerritConfig} etc/gerrit.config
+        ln -sfv ${replicationConfig} etc/replication.config
+
+        # install the plugins
+        rm -rf plugins
+        ln -sv ${gerrit-plugins} plugins
+      ''
+      ;
+
+      serviceConfig = {
+        CacheDirectory = "gerrit";
+        DynamicUser = true;
+        ExecStart = "${gerrit-cli}/bin/gerrit daemon --console-log";
+        LimitNOFILE = 4096;
+        StandardInput = "socket";
+        StandardOutput = "journal";
+        StateDirectory = "gerrit";
+        WorkingDirectory = "%S/gerrit";
+      };
+    };
+  };
+
+  meta.maintainers = with lib.maintainers; [ edef zimbatm ];
+  # uses attributes of the linked package
+  meta.buildDocsInSandbox = false;
+}
diff --git a/nixos/modules/services/web-apps/gotify-server.nix b/nixos/modules/services/web-apps/gotify-server.nix
new file mode 100644
index 00000000000..03e01f46a94
--- /dev/null
+++ b/nixos/modules/services/web-apps/gotify-server.nix
@@ -0,0 +1,49 @@
+{ pkgs, lib, config, ... }:
+
+with lib;
+
+let
+  cfg = config.services.gotify;
+in {
+  options = {
+    services.gotify = {
+      enable = mkEnableOption "Gotify webserver";
+
+      port = mkOption {
+        type = types.port;
+        description = ''
+          Port the server listens to.
+        '';
+      };
+
+      stateDirectoryName = mkOption {
+        type = types.str;
+        default = "gotify-server";
+        description = ''
+          The name of the directory below <filename>/var/lib</filename> where
+          gotify stores its runtime data.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.gotify-server = {
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network.target" ];
+      description = "Simple server for sending and receiving messages";
+
+      environment = {
+        GOTIFY_SERVER_PORT = toString cfg.port;
+      };
+
+      serviceConfig = {
+        WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}";
+        StateDirectory = cfg.stateDirectoryName;
+        Restart = "always";
+        DynamicUser = "yes";
+        ExecStart = "${pkgs.gotify-server}/bin/server";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/grocy.nix b/nixos/modules/services/web-apps/grocy.nix
new file mode 100644
index 00000000000..be2de638dd9
--- /dev/null
+++ b/nixos/modules/services/web-apps/grocy.nix
@@ -0,0 +1,172 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.grocy;
+in {
+  options.services.grocy = {
+    enable = mkEnableOption "grocy";
+
+    hostName = mkOption {
+      type = types.str;
+      description = ''
+        FQDN for the grocy instance.
+      '';
+    };
+
+    nginx.enableSSL = mkOption {
+      type = types.bool;
+      default = true;
+      description = ''
+        Whether or not to enable SSL (with ACME and let's encrypt)
+        for the grocy vhost.
+      '';
+    };
+
+    phpfpm.settings = mkOption {
+      type = with types; attrsOf (oneOf [ int str bool ]);
+      default = {
+        "pm" = "dynamic";
+        "php_admin_value[error_log]" = "stderr";
+        "php_admin_flag[log_errors]" = true;
+        "listen.owner" = "nginx";
+        "catch_workers_output" = true;
+        "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 grocy's PHPFPM pool.
+      '';
+    };
+
+    dataDir = mkOption {
+      type = types.str;
+      default = "/var/lib/grocy";
+      description = ''
+        Home directory of the <literal>grocy</literal> user which contains
+        the application's state.
+      '';
+    };
+
+    settings = {
+      currency = mkOption {
+        type = types.str;
+        default = "USD";
+        example = "EUR";
+        description = ''
+          ISO 4217 code for the currency to display.
+        '';
+      };
+
+      culture = mkOption {
+        type = types.enum [ "de" "en" "da" "en_GB" "es" "fr" "hu" "it" "nl" "no" "pl" "pt_BR" "ru" "sk_SK" "sv_SE" "tr" ];
+        default = "en";
+        description = ''
+          Display language of the frontend.
+        '';
+      };
+
+      calendar = {
+        showWeekNumber = mkOption {
+          default = true;
+          type = types.bool;
+          description = ''
+            Show the number of the weeks in the calendar views.
+          '';
+        };
+        firstDayOfWeek = mkOption {
+          default = null;
+          type = types.nullOr (types.enum (range 0 6));
+          description = ''
+            Which day of the week (0=Sunday, 1=Monday etc.) should be the
+            first day.
+          '';
+        };
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    environment.etc."grocy/config.php".text = ''
+      <?php
+      Setting('CULTURE', '${cfg.settings.culture}');
+      Setting('CURRENCY', '${cfg.settings.currency}');
+      Setting('CALENDAR_FIRST_DAY_OF_WEEK', '${toString cfg.settings.calendar.firstDayOfWeek}');
+      Setting('CALENDAR_SHOW_WEEK_OF_YEAR', ${boolToString cfg.settings.calendar.showWeekNumber});
+    '';
+
+    users.users.grocy = {
+      isSystemUser = true;
+      createHome = true;
+      home = cfg.dataDir;
+      group = "nginx";
+    };
+
+    systemd.tmpfiles.rules = map (
+      dirName: "d '${cfg.dataDir}/${dirName}' - grocy nginx - -"
+    ) [ "viewcache" "plugins" "settingoverrides" "storage" ];
+
+    services.phpfpm.pools.grocy = {
+      user = "grocy";
+      group = "nginx";
+
+      # PHP 7.4 is the only version which is supported/tested by upstream:
+      # https://github.com/grocy/grocy/blob/v3.0.0/README.md#how-to-install
+      phpPackage = pkgs.php74;
+
+      inherit (cfg.phpfpm) settings;
+
+      phpEnv = {
+        GROCY_CONFIG_FILE = "/etc/grocy/config.php";
+        GROCY_DB_FILE = "${cfg.dataDir}/grocy.db";
+        GROCY_STORAGE_DIR = "${cfg.dataDir}/storage";
+        GROCY_PLUGIN_DIR = "${cfg.dataDir}/plugins";
+        GROCY_CACHE_DIR = "${cfg.dataDir}/viewcache";
+      };
+    };
+
+    services.nginx = {
+      enable = true;
+      virtualHosts."${cfg.hostName}" = mkMerge [
+        { root = "${pkgs.grocy}/public";
+          locations."/".extraConfig = ''
+            rewrite ^ /index.php;
+          '';
+          locations."~ \\.php$".extraConfig = ''
+            fastcgi_split_path_info ^(.+\.php)(/.+)$;
+            fastcgi_pass unix:${config.services.phpfpm.pools.grocy.socket};
+            include ${config.services.nginx.package}/conf/fastcgi.conf;
+            include ${config.services.nginx.package}/conf/fastcgi_params;
+          '';
+          locations."~ \\.(js|css|ttf|woff2?|png|jpe?g|svg)$".extraConfig = ''
+            add_header Cache-Control "public, max-age=15778463";
+            add_header X-Content-Type-Options nosniff;
+            add_header X-XSS-Protection "1; mode=block";
+            add_header X-Robots-Tag none;
+            add_header X-Download-Options noopen;
+            add_header X-Permitted-Cross-Domain-Policies none;
+            add_header Referrer-Policy no-referrer;
+            access_log off;
+          '';
+          extraConfig = ''
+            try_files $uri /index.php;
+          '';
+        }
+        (mkIf cfg.nginx.enableSSL {
+          enableACME = true;
+          forceSSL = true;
+        })
+      ];
+    };
+  };
+
+  meta = {
+    maintainers = with maintainers; [ ma27 ];
+    doc = ./grocy.xml;
+  };
+}
diff --git a/nixos/modules/services/web-apps/grocy.xml b/nixos/modules/services/web-apps/grocy.xml
new file mode 100644
index 00000000000..fdf6d00f4b1
--- /dev/null
+++ b/nixos/modules/services/web-apps/grocy.xml
@@ -0,0 +1,77 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-grocy">
+
+  <title>Grocy</title>
+  <para>
+    <link xlink:href="https://grocy.info/">Grocy</link> is a web-based self-hosted groceries
+    &amp; household management solution for your home.
+  </para>
+
+  <section xml:id="module-services-grocy-basic-usage">
+   <title>Basic usage</title>
+   <para>
+    A very basic configuration may look like this:
+<programlisting>{ pkgs, ... }:
+{
+  services.grocy = {
+    <link linkend="opt-services.grocy.enable">enable</link> = true;
+    <link linkend="opt-services.grocy.hostName">hostName</link> = "grocy.tld";
+  };
+}</programlisting>
+    This configures a simple vhost using <link linkend="opt-services.nginx.enable">nginx</link>
+    which listens to <literal>grocy.tld</literal> with fully configured ACME/LE (this can be
+    disabled by setting <link linkend="opt-services.grocy.nginx.enableSSL">services.grocy.nginx.enableSSL</link>
+    to <literal>false</literal>). After the initial setup the credentials <literal>admin:admin</literal>
+    can be used to login.
+   </para>
+   <para>
+    The application's state is persisted at <literal>/var/lib/grocy/grocy.db</literal> in a
+    <package>sqlite3</package> database. The migration is applied when requesting the <literal>/</literal>-route
+    of the application.
+   </para>
+  </section>
+
+  <section xml:id="module-services-grocy-settings">
+   <title>Settings</title>
+   <para>
+    The configuration for <literal>grocy</literal> is located at <literal>/etc/grocy/config.php</literal>.
+    By default, the following settings can be defined in the NixOS-configuration:
+<programlisting>{ pkgs, ... }:
+{
+  services.grocy.settings = {
+    # The default currency in the system for invoices etc.
+    # Please note that exchange rates aren't taken into account, this
+    # is just the setting for what's shown in the frontend.
+    <link linkend="opt-services.grocy.settings.currency">currency</link> = "EUR";
+
+    # The display language (and locale configuration) for grocy.
+    <link linkend="opt-services.grocy.settings.currency">culture</link> = "de";
+
+    calendar = {
+      # Whether or not to show the week-numbers
+      # in the calendar.
+      <link linkend="opt-services.grocy.settings.calendar.showWeekNumber">showWeekNumber</link> = true;
+
+      # Index of the first day to be shown in the calendar (0=Sunday, 1=Monday,
+      # 2=Tuesday and so on).
+      <link linkend="opt-services.grocy.settings.calendar.firstDayOfWeek">firstDayOfWeek</link> = 2;
+    };
+  };
+}</programlisting>
+   </para>
+   <para>
+    If you want to alter the configuration file on your own, you can do this manually with
+    an expression like this:
+<programlisting>{ lib, ... }:
+{
+  environment.etc."grocy/config.php".text = lib.mkAfter ''
+    // Arbitrary PHP code in grocy's configuration file
+  '';
+}</programlisting>
+   </para>
+  </section>
+
+</chapter>
diff --git a/nixos/modules/services/web-apps/hedgedoc.nix b/nixos/modules/services/web-apps/hedgedoc.nix
new file mode 100644
index 00000000000..9eeabb9d566
--- /dev/null
+++ b/nixos/modules/services/web-apps/hedgedoc.nix
@@ -0,0 +1,1038 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.hedgedoc;
+
+  # 21.03 will not be an official release - it was instead 21.05.  This
+  # versionAtLeast statement remains set to 21.03 for backwards compatibility.
+  # See https://github.com/NixOS/nixpkgs/pull/108899 and
+  # https://github.com/NixOS/rfcs/blob/master/rfcs/0080-nixos-release-schedule.md.
+  name = if versionAtLeast config.system.stateVersion "21.03"
+    then "hedgedoc"
+    else "codimd";
+
+  prettyJSON = conf:
+    pkgs.runCommandLocal "hedgedoc-config.json" {
+      nativeBuildInputs = [ pkgs.jq ];
+    } ''
+      echo '${builtins.toJSON conf}' | jq \
+        '{production:del(.[]|nulls)|del(.[][]?|nulls)}' > $out
+    '';
+in
+{
+  imports = [
+    (mkRenamedOptionModule [ "services" "codimd" ] [ "services" "hedgedoc" ])
+  ];
+
+  options.services.hedgedoc = {
+    enable = mkEnableOption "the HedgeDoc Markdown Editor";
+
+    groups = mkOption {
+      type = types.listOf types.str;
+      default = [];
+      description = ''
+        Groups to which the service user should be added.
+      '';
+    };
+
+    workDir = mkOption {
+      type = types.path;
+      default = "/var/lib/${name}";
+      description = ''
+        Working directory for the HedgeDoc service.
+      '';
+    };
+
+    configuration = {
+      debug = mkEnableOption "debug mode";
+      domain = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "hedgedoc.org";
+        description = ''
+          Domain name for the HedgeDoc instance.
+        '';
+      };
+      urlPath = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/url/path/to/hedgedoc";
+        description = ''
+          Path under which HedgeDoc is accessible.
+        '';
+      };
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = ''
+          Address to listen on.
+        '';
+      };
+      port = mkOption {
+        type = types.int;
+        default = 3000;
+        example = 80;
+        description = ''
+          Port to listen on.
+        '';
+      };
+      path = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/run/hedgedoc.sock";
+        description = ''
+          Specify where a UNIX domain socket should be placed.
+        '';
+      };
+      allowOrigin = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "localhost" "hedgedoc.org" ];
+        description = ''
+          List of domains to whitelist.
+        '';
+      };
+      useSSL = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enable to use SSL server. This will also enable
+          <option>protocolUseSSL</option>.
+        '';
+      };
+      hsts = {
+        enable = mkOption {
+          type = types.bool;
+          default = true;
+          description = ''
+            Whether to enable HSTS if HTTPS is also enabled.
+          '';
+        };
+        maxAgeSeconds = mkOption {
+          type = types.int;
+          default = 31536000;
+          description = ''
+            Max duration for clients to keep the HSTS status.
+          '';
+        };
+        includeSubdomains = mkOption {
+          type = types.bool;
+          default = true;
+          description = ''
+            Whether to include subdomains in HSTS.
+          '';
+        };
+        preload = mkOption {
+          type = types.bool;
+          default = true;
+          description = ''
+            Whether to allow preloading of the site's HSTS status.
+          '';
+        };
+      };
+      csp = mkOption {
+        type = types.nullOr types.attrs;
+        default = null;
+        example = literalExpression ''
+          {
+            enable = true;
+            directives = {
+              scriptSrc = "trustworthy.scripts.example.com";
+            };
+            upgradeInsecureRequest = "auto";
+            addDefaults = true;
+          }
+        '';
+        description = ''
+          Specify the Content Security Policy which is passed to Helmet.
+          For configuration details see <link xlink:href="https://helmetjs.github.io/docs/csp/"
+          >https://helmetjs.github.io/docs/csp/</link>.
+        '';
+      };
+      protocolUseSSL = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enable to use TLS for resource paths.
+          This only applies when <option>domain</option> is set.
+        '';
+      };
+      urlAddPort = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enable to add the port to callback URLs.
+          This only applies when <option>domain</option> is set
+          and only for ports other than 80 and 443.
+        '';
+      };
+      useCDN = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether to use CDN resources or not.
+        '';
+      };
+      allowAnonymous = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to allow anonymous usage.
+        '';
+      };
+      allowAnonymousEdits = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether to allow guests to edit existing notes with the `freely' permission,
+          when <option>allowAnonymous</option> is enabled.
+        '';
+      };
+      allowFreeURL = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether to allow note creation by accessing a nonexistent note URL.
+        '';
+      };
+      defaultPermission = mkOption {
+        type = types.enum [ "freely" "editable" "limited" "locked" "private" ];
+        default = "editable";
+        description = ''
+          Default permissions for notes.
+          This only applies for signed-in users.
+        '';
+      };
+      dbURL = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = ''
+          postgres://user:pass@host:5432/dbname
+        '';
+        description = ''
+          Specify which database to use.
+          HedgeDoc supports mysql, postgres, sqlite and mssql.
+          See <link xlink:href="https://sequelize.readthedocs.io/en/v3/">
+          https://sequelize.readthedocs.io/en/v3/</link> for more information.
+          Note: This option overrides <option>db</option>.
+        '';
+      };
+      db = mkOption {
+        type = types.attrs;
+        default = {};
+        example = literalExpression ''
+          {
+            dialect = "sqlite";
+            storage = "/var/lib/${name}/db.${name}.sqlite";
+          }
+        '';
+        description = ''
+          Specify the configuration for sequelize.
+          HedgeDoc supports mysql, postgres, sqlite and mssql.
+          See <link xlink:href="https://sequelize.readthedocs.io/en/v3/">
+          https://sequelize.readthedocs.io/en/v3/</link> for more information.
+          Note: This option overrides <option>db</option>.
+        '';
+      };
+      sslKeyPath= mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/var/lib/hedgedoc/hedgedoc.key";
+        description = ''
+          Path to the SSL key. Needed when <option>useSSL</option> is enabled.
+        '';
+      };
+      sslCertPath = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/var/lib/hedgedoc/hedgedoc.crt";
+        description = ''
+          Path to the SSL cert. Needed when <option>useSSL</option> is enabled.
+        '';
+      };
+      sslCAPath = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = [ "/var/lib/hedgedoc/ca.crt" ];
+        description = ''
+          SSL ca chain. Needed when <option>useSSL</option> is enabled.
+        '';
+      };
+      dhParamPath = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "/var/lib/hedgedoc/dhparam.pem";
+        description = ''
+          Path to the SSL dh params. Needed when <option>useSSL</option> is enabled.
+        '';
+      };
+      tmpPath = mkOption {
+        type = types.str;
+        default = "/tmp";
+        description = ''
+          Path to the temp directory HedgeDoc should use.
+          Note that <option>serviceConfig.PrivateTmp</option> is enabled for
+          the HedgeDoc systemd service by default.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      defaultNotePath = mkOption {
+        type = types.nullOr types.str;
+        default = "./public/default.md";
+        description = ''
+          Path to the default Note file.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      docsPath = mkOption {
+        type = types.nullOr types.str;
+        default = "./public/docs";
+        description = ''
+          Path to the docs directory.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      indexPath = mkOption {
+        type = types.nullOr types.str;
+        default = "./public/views/index.ejs";
+        description = ''
+          Path to the index template file.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      hackmdPath = mkOption {
+        type = types.nullOr types.str;
+        default = "./public/views/hackmd.ejs";
+        description = ''
+          Path to the hackmd template file.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      errorPath = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        defaultText = literalExpression "./public/views/error.ejs";
+        description = ''
+          Path to the error template file.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      prettyPath = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        defaultText = literalExpression "./public/views/pretty.ejs";
+        description = ''
+          Path to the pretty template file.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      slidePath = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        defaultText = literalExpression "./public/views/slide.hbs";
+        description = ''
+          Path to the slide template file.
+          (Non-canonical paths are relative to HedgeDoc's base directory)
+        '';
+      };
+      uploadsPath = mkOption {
+        type = types.str;
+        default = "${cfg.workDir}/uploads";
+        defaultText = literalExpression "/var/lib/${name}/uploads";
+        description = ''
+          Path under which uploaded files are saved.
+        '';
+      };
+      sessionName = mkOption {
+        type = types.str;
+        default = "connect.sid";
+        description = ''
+          Specify the name of the session cookie.
+        '';
+      };
+      sessionSecret = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = ''
+          Specify the secret used to sign the session cookie.
+          If unset, one will be generated on startup.
+        '';
+      };
+      sessionLife = mkOption {
+        type = types.int;
+        default = 1209600000;
+        description = ''
+          Session life time in milliseconds.
+        '';
+      };
+      heartbeatInterval = mkOption {
+        type = types.int;
+        default = 5000;
+        description = ''
+          Specify the socket.io heartbeat interval.
+        '';
+      };
+      heartbeatTimeout = mkOption {
+        type = types.int;
+        default = 10000;
+        description = ''
+          Specify the socket.io heartbeat timeout.
+        '';
+      };
+      documentMaxLength = mkOption {
+        type = types.int;
+        default = 100000;
+        description = ''
+          Specify the maximum document length.
+        '';
+      };
+      email = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to enable email sign-in.
+        '';
+      };
+      allowEmailRegister = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to enable email registration.
+        '';
+      };
+      allowGravatar = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to use gravatar as profile picture source.
+        '';
+      };
+      imageUploadType = mkOption {
+        type = types.enum [ "imgur" "s3" "minio" "filesystem" ];
+        default = "filesystem";
+        description = ''
+          Specify where to upload images.
+        '';
+      };
+      minio = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            accessKey = mkOption {
+              type = types.str;
+              description = ''
+                Minio access key.
+              '';
+            };
+            secretKey = mkOption {
+              type = types.str;
+              description = ''
+                Minio secret key.
+              '';
+            };
+            endpoint = mkOption {
+              type = types.str;
+              description = ''
+                Minio endpoint.
+              '';
+            };
+            port = mkOption {
+              type = types.int;
+              default = 9000;
+              description = ''
+                Minio listen port.
+              '';
+            };
+            secure = mkOption {
+              type = types.bool;
+              default = true;
+              description = ''
+                Whether to use HTTPS for Minio.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the minio third-party integration.";
+      };
+      s3 = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            accessKeyId = mkOption {
+              type = types.str;
+              description = ''
+                AWS access key id.
+              '';
+            };
+            secretAccessKey = mkOption {
+              type = types.str;
+              description = ''
+                AWS access key.
+              '';
+            };
+            region = mkOption {
+              type = types.str;
+              description = ''
+                AWS S3 region.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the s3 third-party integration.";
+      };
+      s3bucket = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = ''
+          Specify the bucket name for upload types <literal>s3</literal> and <literal>minio</literal>.
+        '';
+      };
+      allowPDFExport = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to enable PDF exports.
+        '';
+      };
+      imgur.clientId = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = ''
+          Imgur API client ID.
+        '';
+      };
+      azure = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            connectionString = mkOption {
+              type = types.str;
+              description = ''
+                Azure Blob Storage connection string.
+              '';
+            };
+            container = mkOption {
+              type = types.str;
+              description = ''
+                Azure Blob Storage container name.
+                It will be created if non-existent.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the azure third-party integration.";
+      };
+      oauth2 = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            authorizationURL = mkOption {
+              type = types.str;
+              description = ''
+                Specify the OAuth authorization URL.
+              '';
+            };
+            tokenURL = mkOption {
+              type = types.str;
+              description = ''
+                Specify the OAuth token URL.
+              '';
+            };
+            baseURL = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the OAuth base URL.
+              '';
+            };
+            userProfileURL = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the OAuth userprofile URL.
+              '';
+            };
+            userProfileUsernameAttr = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the name of the attribute for the username from the claim.
+              '';
+            };
+            userProfileDisplayNameAttr = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the name of the attribute for the display name from the claim.
+              '';
+            };
+            userProfileEmailAttr = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the name of the attribute for the email from the claim.
+              '';
+            };
+            scope = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the OAuth scope.
+              '';
+            };
+            providerName = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the name to be displayed for this strategy.
+              '';
+            };
+            rolesClaim = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify the role claim name.
+              '';
+            };
+            accessRole = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              description = ''
+                Specify role which should be included in the ID token roles claim to grant access
+              '';
+            };
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                Specify the OAuth client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                Specify the OAuth client secret.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the OAuth integration.";
+      };
+      facebook = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                Facebook API client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                Facebook API client secret.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the facebook third-party integration";
+      };
+      twitter = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            consumerKey = mkOption {
+              type = types.str;
+              description = ''
+                Twitter API consumer key.
+              '';
+            };
+            consumerSecret = mkOption {
+              type = types.str;
+              description = ''
+                Twitter API consumer secret.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the Twitter third-party integration.";
+      };
+      github = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                GitHub API client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                Github API client secret.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the GitHub third-party integration.";
+      };
+      gitlab = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            baseURL = mkOption {
+              type = types.str;
+              default = "";
+              description = ''
+                GitLab API authentication endpoint.
+                Only needed for other endpoints than gitlab.com.
+              '';
+            };
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                GitLab API client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                GitLab API client secret.
+              '';
+            };
+            scope = mkOption {
+              type = types.enum [ "api" "read_user" ];
+              default = "api";
+              description = ''
+                GitLab API requested scope.
+                GitLab snippet import/export requires api scope.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the GitLab third-party integration.";
+      };
+      mattermost = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            baseURL = mkOption {
+              type = types.str;
+              description = ''
+                Mattermost authentication endpoint.
+              '';
+            };
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                Mattermost API client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                Mattermost API client secret.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the Mattermost third-party integration.";
+      };
+      dropbox = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                Dropbox API client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                Dropbox API client secret.
+              '';
+            };
+            appKey = mkOption {
+              type = types.str;
+              description = ''
+                Dropbox app key.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the Dropbox third-party integration.";
+      };
+      google = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            clientID = mkOption {
+              type = types.str;
+              description = ''
+                Google API client ID.
+              '';
+            };
+            clientSecret = mkOption {
+              type = types.str;
+              description = ''
+                Google API client secret.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the Google third-party integration.";
+      };
+      ldap = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            providerName = mkOption {
+              type = types.str;
+              default = "";
+              description = ''
+                Optional name to be displayed at login form, indicating the LDAP provider.
+              '';
+            };
+            url = mkOption {
+              type = types.str;
+              example = "ldap://localhost";
+              description = ''
+                URL of LDAP server.
+              '';
+            };
+            bindDn = mkOption {
+              type = types.str;
+              description = ''
+                Bind DN for LDAP access.
+              '';
+            };
+            bindCredentials = mkOption {
+              type = types.str;
+              description = ''
+                Bind credentials for LDAP access.
+              '';
+            };
+            searchBase = mkOption {
+              type = types.str;
+              example = "o=users,dc=example,dc=com";
+              description = ''
+                LDAP directory to begin search from.
+              '';
+            };
+            searchFilter = mkOption {
+              type = types.str;
+              example = "(uid={{username}})";
+              description = ''
+                LDAP filter to search with.
+              '';
+            };
+            searchAttributes = mkOption {
+              type = types.listOf types.str;
+              example = [ "displayName" "mail" ];
+              description = ''
+                LDAP attributes to search with.
+              '';
+            };
+            userNameField = mkOption {
+              type = types.str;
+              default = "";
+              description = ''
+                LDAP field which is used as the username on HedgeDoc.
+                By default <option>useridField</option> is used.
+              '';
+            };
+            useridField = mkOption {
+              type = types.str;
+              example = "uid";
+              description = ''
+                LDAP field which is a unique identifier for users on HedgeDoc.
+              '';
+            };
+            tlsca = mkOption {
+              type = types.str;
+              example = "server-cert.pem,root.pem";
+              description = ''
+                Root CA for LDAP TLS in PEM format.
+              '';
+            };
+          };
+        });
+        default = null;
+        description = "Configure the LDAP integration.";
+      };
+      saml = mkOption {
+        type = types.nullOr (types.submodule {
+          options = {
+            idpSsoUrl = mkOption {
+              type = types.str;
+              example = "https://idp.example.com/sso";
+              description = ''
+                IdP authentication endpoint.
+              '';
+            };
+            idpCert = mkOption {
+              type = types.path;
+              example = "/path/to/cert.pem";
+              description = ''
+                Path to IdP certificate file in PEM format.
+              '';
+            };
+            issuer = mkOption {
+              type = types.str;
+              default = "";
+              description = ''
+                Optional identity of the service provider.
+                This defaults to the server URL.
+              '';
+            };
+            identifierFormat = mkOption {
+              type = types.str;
+              default = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
+              description = ''
+                Optional name identifier format.
+              '';
+            };
+            groupAttribute = mkOption {
+              type = types.str;
+              default = "";
+              example = "memberOf";
+              description = ''
+                Optional attribute name for group list.
+              '';
+            };
+            externalGroups = mkOption {
+              type = types.listOf types.str;
+              default = [];
+              example = [ "Temporary-staff" "External-users" ];
+              description = ''
+                Excluded group names.
+              '';
+            };
+            requiredGroups = mkOption {
+              type = types.listOf types.str;
+              default = [];
+              example = [ "Hedgedoc-Users" ];
+              description = ''
+                Required group names.
+              '';
+            };
+            attribute = {
+              id = mkOption {
+                type = types.str;
+                default = "";
+                description = ''
+                  Attribute map for `id'.
+                  Defaults to `NameID' of SAML response.
+                '';
+              };
+              username = mkOption {
+                type = types.str;
+                default = "";
+                description = ''
+                  Attribute map for `username'.
+                  Defaults to `NameID' of SAML response.
+                '';
+              };
+              email = mkOption {
+                type = types.str;
+                default = "";
+                description = ''
+                  Attribute map for `email'.
+                  Defaults to `NameID' of SAML response if
+                  <option>identifierFormat</option> has
+                  the default value.
+                '';
+              };
+            };
+          };
+        });
+        default = null;
+        description = "Configure the SAML integration.";
+      };
+    };
+
+    environmentFile = mkOption {
+      type = with types; nullOr path;
+      default = null;
+      example = "/var/lib/hedgedoc/hedgedoc.env";
+      description = ''
+        Environment file as defined in <citerefentry>
+        <refentrytitle>systemd.exec</refentrytitle><manvolnum>5</manvolnum>
+        </citerefentry>.
+
+        Secrets may be passed to the service without adding them to the world-readable
+        Nix store, by specifying placeholder variables as the option value in Nix and
+        setting these variables accordingly in the environment file.
+
+        <programlisting>
+          # snippet of HedgeDoc-related config
+          services.hedgedoc.configuration.dbURL = "postgres://hedgedoc:\''${DB_PASSWORD}@db-host:5432/hedgedocdb";
+          services.hedgedoc.configuration.minio.secretKey = "$MINIO_SECRET_KEY";
+        </programlisting>
+
+        <programlisting>
+          # content of the environment file
+          DB_PASSWORD=verysecretdbpassword
+          MINIO_SECRET_KEY=verysecretminiokey
+        </programlisting>
+
+        Note that this file needs to be available on the host on which
+        <literal>HedgeDoc</literal> is running.
+      '';
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.hedgedoc;
+      defaultText = literalExpression "pkgs.hedgedoc";
+      description = ''
+        Package that provides HedgeDoc.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = [
+      { assertion = cfg.configuration.db == {} -> (
+          cfg.configuration.dbURL != "" && cfg.configuration.dbURL != null
+        );
+        message = "Database configuration for HedgeDoc missing."; }
+    ];
+    users.groups.${name} = {};
+    users.users.${name} = {
+      description = "HedgeDoc service user";
+      group = name;
+      extraGroups = cfg.groups;
+      home = cfg.workDir;
+      createHome = true;
+      isSystemUser = true;
+    };
+
+    systemd.services.hedgedoc = {
+      description = "HedgeDoc Service";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ];
+      preStart = ''
+        ${pkgs.envsubst}/bin/envsubst \
+          -o ${cfg.workDir}/config.json \
+          -i ${prettyJSON cfg.configuration}
+      '';
+      serviceConfig = {
+        WorkingDirectory = cfg.workDir;
+        ExecStart = "${cfg.package}/bin/hedgedoc";
+        EnvironmentFile = mkIf (cfg.environmentFile != null) [ cfg.environmentFile ];
+        Environment = [
+          "CMD_CONFIG_FILE=${cfg.workDir}/config.json"
+          "NODE_ENV=production"
+        ];
+        Restart = "always";
+        User = name;
+        PrivateTmp = true;
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/hledger-web.nix b/nixos/modules/services/web-apps/hledger-web.nix
new file mode 100644
index 00000000000..4f6a34e6d2f
--- /dev/null
+++ b/nixos/modules/services/web-apps/hledger-web.nix
@@ -0,0 +1,142 @@
+{ lib, pkgs, config, ... }:
+with lib;
+let
+  cfg = config.services.hledger-web;
+in {
+  options.services.hledger-web = {
+
+    enable = mkEnableOption "hledger-web service";
+
+    serveApi = mkEnableOption "Serve only the JSON web API, without the web UI.";
+
+    host = mkOption {
+      type = types.str;
+      default = "127.0.0.1";
+      description = ''
+        Address to listen on.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5000;
+      example = 80;
+      description = ''
+        Port to listen on.
+      '';
+    };
+
+    capabilities = {
+      view = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Enable the view capability.
+        '';
+      };
+      add = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enable the add capability.
+        '';
+      };
+      manage = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enable the manage capability.
+        '';
+      };
+    };
+
+    stateDir = mkOption {
+      type = types.path;
+      default = "/var/lib/hledger-web";
+      description = ''
+        Path the service has access to. If left as the default value this
+        directory will automatically be created before the hledger-web server
+        starts, otherwise the sysadmin is responsible for ensuring the
+        directory exists with appropriate ownership and permissions.
+      '';
+    };
+
+    journalFiles = mkOption {
+      type = types.listOf types.str;
+      default = [ ".hledger.journal" ];
+      description = ''
+        Paths to journal files relative to <option>services.hledger-web.stateDir</option>.
+      '';
+    };
+
+    baseUrl = mkOption {
+      type = with types; nullOr str;
+      default = null;
+      example = "https://example.org";
+      description = ''
+        Base URL, when sharing over a network.
+      '';
+    };
+
+    extraOptions = mkOption {
+      type = types.listOf types.str;
+      default = [];
+      example = [ "--forecast" ];
+      description = ''
+        Extra command line arguments to pass to hledger-web.
+      '';
+    };
+
+  };
+
+  config = mkIf cfg.enable {
+
+    users.users.hledger = {
+      name = "hledger";
+      group = "hledger";
+      isSystemUser = true;
+      home = cfg.stateDir;
+      useDefaultShell = true;
+    };
+
+    users.groups.hledger = {};
+
+    systemd.services.hledger-web = let
+      capabilityString = with cfg.capabilities; concatStringsSep "," (
+        (optional view "view")
+        ++ (optional add "add")
+        ++ (optional manage "manage")
+      );
+      serverArgs = with cfg; escapeShellArgs ([
+        "--serve"
+        "--host=${host}"
+        "--port=${toString port}"
+        "--capabilities=${capabilityString}"
+        (optionalString (cfg.baseUrl != null) "--base-url=${cfg.baseUrl}")
+        (optionalString (cfg.serveApi) "--serve-api")
+      ] ++ (map (f: "--file=${stateDir}/${f}") cfg.journalFiles)
+        ++ extraOptions);
+    in {
+      description = "hledger-web - web-app for the hledger accounting tool.";
+      documentation = [ "https://hledger.org/hledger-web.html" ];
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ];
+      serviceConfig = mkMerge [
+        {
+          ExecStart = "${pkgs.hledger-web}/bin/hledger-web ${serverArgs}";
+          Restart = "always";
+          WorkingDirectory = cfg.stateDir;
+          User = "hledger";
+          Group = "hledger";
+          PrivateTmp = true;
+        }
+        (mkIf (cfg.stateDir == "/var/lib/hledger-web") {
+          StateDirectory = "hledger-web";
+        })
+      ];
+    };
+
+  };
+
+  meta.maintainers = with lib.maintainers; [ marijanp erictapen ];
+}
diff --git a/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix b/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix
new file mode 100644
index 00000000000..b9761061aaa
--- /dev/null
+++ b/nixos/modules/services/web-apps/icingaweb2/icingaweb2.nix
@@ -0,0 +1,262 @@
+{ config, lib, pkgs, ... }: with lib; let
+  cfg = config.services.icingaweb2;
+  fpm = config.services.phpfpm.pools.${poolName};
+  poolName = "icingaweb2";
+
+  defaultConfig = {
+    global = {
+      module_path = "${pkgs.icingaweb2}/modules";
+    };
+  };
+in {
+  meta.maintainers = with maintainers; [ das_j ];
+
+  options.services.icingaweb2 = with types; {
+    enable = mkEnableOption "the icingaweb2 web interface";
+
+    pool = mkOption {
+      type = str;
+      default = poolName;
+      description = ''
+         Name of existing PHP-FPM pool that is used to run Icingaweb2.
+         If not specified, a pool will automatically created with default values.
+      '';
+    };
+
+    libraryPaths = mkOption {
+      type = attrsOf package;
+      default = { };
+      description = ''
+        Libraries to add to the Icingaweb2 library path.
+        The name of the attribute is the name of the library, the value
+        is the package to add.
+      '';
+    };
+
+    virtualHost = mkOption {
+      type = nullOr str;
+      default = "icingaweb2";
+      description = ''
+        Name of the nginx virtualhost to use and setup. If null, no virtualhost is set up.
+      '';
+    };
+
+    timezone = mkOption {
+      type = str;
+      default = "UTC";
+      example = "Europe/Berlin";
+      description = "PHP-compliant timezone specification";
+    };
+
+    modules = {
+      doc.enable = mkEnableOption "the icingaweb2 doc module";
+      migrate.enable = mkEnableOption "the icingaweb2 migrate module";
+      setup.enable = mkEnableOption "the icingaweb2 setup module";
+      test.enable = mkEnableOption "the icingaweb2 test module";
+      translation.enable = mkEnableOption "the icingaweb2 translation module";
+    };
+
+    modulePackages = mkOption {
+      type = attrsOf package;
+      default = {};
+      example = literalExpression ''
+        {
+          "snow" = icingaweb2Modules.theme-snow;
+        }
+      '';
+      description = ''
+        Name-package attrset of Icingaweb 2 modules packages to enable.
+
+        If you enable modules manually (e.g. via the web ui), they will not be touched.
+      '';
+    };
+
+    generalConfig = mkOption {
+      type = nullOr attrs;
+      default = null;
+      example = {
+        general = {
+          showStacktraces = 1;
+          config_resource = "icingaweb_db";
+        };
+        logging = {
+          log = "syslog";
+          level = "CRITICAL";
+        };
+      };
+      description = ''
+        config.ini contents.
+        Will automatically be converted to a .ini file.
+        If you don't set global.module_path, the module will take care of it.
+
+        If the value is null, no config.ini is created and you can
+        modify it manually (e.g. via the web interface).
+        Note that you need to update module_path manually.
+      '';
+    };
+
+    resources = mkOption {
+      type = nullOr attrs;
+      default = null;
+      example = {
+        icingaweb_db = {
+          type = "db";
+          db = "mysql";
+          host = "localhost";
+          username = "icingaweb2";
+          password = "icingaweb2";
+          dbname = "icingaweb2";
+        };
+      };
+      description = ''
+        resources.ini contents.
+        Will automatically be converted to a .ini file.
+
+        If the value is null, no resources.ini is created and you can
+        modify it manually (e.g. via the web interface).
+        Note that if you set passwords here, they will go into the nix store.
+      '';
+    };
+
+    authentications = mkOption {
+      type = nullOr attrs;
+      default = null;
+      example = {
+        icingaweb = {
+          backend = "db";
+          resource = "icingaweb_db";
+        };
+      };
+      description = ''
+        authentication.ini contents.
+        Will automatically be converted to a .ini file.
+
+        If the value is null, no authentication.ini is created and you can
+        modify it manually (e.g. via the web interface).
+      '';
+    };
+
+    groupBackends = mkOption {
+      type = nullOr attrs;
+      default = null;
+      example = {
+        icingaweb = {
+          backend = "db";
+          resource = "icingaweb_db";
+        };
+      };
+      description = ''
+        groups.ini contents.
+        Will automatically be converted to a .ini file.
+
+        If the value is null, no groups.ini is created and you can
+        modify it manually (e.g. via the web interface).
+      '';
+    };
+
+    roles = mkOption {
+      type = nullOr attrs;
+      default = null;
+      example = {
+        Administrators = {
+          users = "admin";
+          permissions = "*";
+        };
+      };
+      description = ''
+        roles.ini contents.
+        Will automatically be converted to a .ini file.
+
+        If the value is null, no roles.ini is created and you can
+        modify it manually (e.g. via the web interface).
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
+      ${poolName} = {
+        user = "icingaweb2";
+        phpEnv = {
+          ICINGAWEB_LIBDIR = toString (pkgs.linkFarm "icingaweb2-libdir" (mapAttrsToList (name: path: { inherit name path; }) cfg.libraryPaths));
+        };
+        phpPackage = pkgs.php.withExtensions ({ enabled, all }: [ all.imagick ] ++ enabled);
+        phpOptions = ''
+          date.timezone = "${cfg.timezone}"
+        '';
+        settings = mapAttrs (name: mkDefault) {
+          "listen.owner" = "nginx";
+          "listen.group" = "nginx";
+          "listen.mode" = "0600";
+          "pm" = "dynamic";
+          "pm.max_children" = 75;
+          "pm.start_servers" = 2;
+          "pm.min_spare_servers" = 2;
+          "pm.max_spare_servers" = 10;
+        };
+      };
+    };
+
+    services.icingaweb2.libraryPaths = {
+      ipl = pkgs.icingaweb2-ipl;
+      thirdparty = pkgs.icingaweb2-thirdparty;
+    };
+
+    systemd.services."phpfpm-${poolName}".serviceConfig.ReadWritePaths = [ "/etc/icingaweb2" ];
+
+    services.nginx = {
+      enable = true;
+      virtualHosts = mkIf (cfg.virtualHost != null) {
+        ${cfg.virtualHost} = {
+          root = "${pkgs.icingaweb2}/public";
+
+          extraConfig = ''
+            index index.php;
+            try_files $1 $uri $uri/ /index.php$is_args$args;
+          '';
+
+          locations."~ ..*/.*.php$".extraConfig = ''
+            return 403;
+          '';
+
+          locations."~ ^/index.php(.*)$".extraConfig = ''
+            fastcgi_intercept_errors on;
+            fastcgi_index index.php;
+            include ${config.services.nginx.package}/conf/fastcgi.conf;
+            try_files $uri =404;
+            fastcgi_split_path_info ^(.+\.php)(/.+)$;
+            fastcgi_pass unix:${fpm.socket};
+            fastcgi_param SCRIPT_FILENAME ${pkgs.icingaweb2}/public/index.php;
+          '';
+        };
+      };
+    };
+
+    # /etc/icingaweb2
+    environment.etc = let
+      doModule = name: optionalAttrs (cfg.modules.${name}.enable) { "icingaweb2/enabledModules/${name}".source = "${pkgs.icingaweb2}/modules/${name}"; };
+    in {}
+      # Module packages
+      // (mapAttrs' (k: v: nameValuePair "icingaweb2/enabledModules/${k}" { source = v; }) cfg.modulePackages)
+      # Built-in modules
+      // doModule "doc"
+      // doModule "migrate"
+      // doModule "setup"
+      // doModule "test"
+      // doModule "translation"
+      # Configs
+      // optionalAttrs (cfg.generalConfig != null) { "icingaweb2/config.ini".text = generators.toINI {} (defaultConfig // cfg.generalConfig); }
+      // optionalAttrs (cfg.resources != null) { "icingaweb2/resources.ini".text = generators.toINI {} cfg.resources; }
+      // optionalAttrs (cfg.authentications != null) { "icingaweb2/authentication.ini".text = generators.toINI {} cfg.authentications; }
+      // optionalAttrs (cfg.groupBackends != null) { "icingaweb2/groups.ini".text = generators.toINI {} cfg.groupBackends; }
+      // optionalAttrs (cfg.roles != null) { "icingaweb2/roles.ini".text = generators.toINI {} cfg.roles; };
+
+    # User and group
+    users.groups.icingaweb2 = {};
+    users.users.icingaweb2 = {
+      description = "Icingaweb2 service user";
+      group = "icingaweb2";
+      isSystemUser = true;
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix b/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix
new file mode 100644
index 00000000000..e9c1d4ffe5e
--- /dev/null
+++ b/nixos/modules/services/web-apps/icingaweb2/module-monitoring.nix
@@ -0,0 +1,157 @@
+{ config, lib, pkgs, ... }: with lib; let
+  cfg = config.services.icingaweb2.modules.monitoring;
+
+  configIni = ''
+    [security]
+    protected_customvars = "${concatStringsSep "," cfg.generalConfig.protectedVars}"
+  '';
+
+  backendsIni = let
+    formatBool = b: if b then "1" else "0";
+  in concatStringsSep "\n" (mapAttrsToList (name: config: ''
+    [${name}]
+    type = "ido"
+    resource = "${config.resource}"
+    disabled = "${formatBool config.disabled}"
+  '') cfg.backends);
+
+  transportsIni = concatStringsSep "\n" (mapAttrsToList (name: config: ''
+    [${name}]
+    type = "${config.type}"
+    ${optionalString (config.instance != null) ''instance = "${config.instance}"''}
+    ${optionalString (config.type == "local" || config.type == "remote") ''path = "${config.path}"''}
+    ${optionalString (config.type != "local") ''
+      host = "${config.host}"
+      ${optionalString (config.port != null) ''port = "${toString config.port}"''}
+      user${optionalString (config.type == "api") "name"} = "${config.username}"
+    ''}
+    ${optionalString (config.type == "api") ''password = "${config.password}"''}
+    ${optionalString (config.type == "remote") ''resource = "${config.resource}"''}
+  '') cfg.transports);
+
+in {
+  options.services.icingaweb2.modules.monitoring = with types; {
+    enable = mkOption {
+      type = bool;
+      default = true;
+      description = "Whether to enable the icingaweb2 monitoring module.";
+    };
+
+    generalConfig = {
+      mutable = mkOption {
+        type = bool;
+        default = false;
+        description = "Make config.ini of the monitoring module mutable (e.g. via the web interface).";
+      };
+
+      protectedVars = mkOption {
+        type = listOf str;
+        default = [ "*pw*" "*pass*" "community" ];
+        description = "List of string patterns for custom variables which should be excluded from user’s view.";
+      };
+    };
+
+    mutableBackends = mkOption {
+      type = bool;
+      default = false;
+      description = "Make backends.ini of the monitoring module mutable (e.g. via the web interface).";
+    };
+
+    backends = mkOption {
+      default = { icinga = { resource = "icinga_ido"; }; };
+      description = "Monitoring backends to define";
+      type = attrsOf (submodule ({ name, ... }: {
+        options = {
+          name = mkOption {
+            visible = false;
+            default = name;
+            type = str;
+            description = "Name of this backend";
+          };
+
+          resource = mkOption {
+            type = str;
+            description = "Name of the IDO resource";
+          };
+
+          disabled = mkOption {
+            type = bool;
+            default = false;
+            description = "Disable this backend";
+          };
+        };
+      }));
+    };
+
+    mutableTransports = mkOption {
+      type = bool;
+      default = true;
+      description = "Make commandtransports.ini of the monitoring module mutable (e.g. via the web interface).";
+    };
+
+    transports = mkOption {
+      default = {};
+      description = "Command transports to define";
+      type = attrsOf (submodule ({ name, ... }: {
+        options = {
+          name = mkOption {
+            visible = false;
+            default = name;
+            type = str;
+            description = "Name of this transport";
+          };
+
+          type = mkOption {
+            type = enum [ "api" "local" "remote" ];
+            default = "api";
+            description = "Type of  this transport";
+          };
+
+          instance = mkOption {
+            type = nullOr str;
+            default = null;
+            description = "Assign a icinga instance to this transport";
+          };
+
+          path = mkOption {
+            type = str;
+            description = "Path to the socket for local or remote transports";
+          };
+
+          host = mkOption {
+            type = str;
+            description = "Host for the api or remote transport";
+          };
+
+          port = mkOption {
+            type = nullOr str;
+            default = null;
+            description = "Port to connect to for the api or remote transport";
+          };
+
+          username = mkOption {
+            type = str;
+            description = "Username for the api or remote transport";
+          };
+
+          password = mkOption {
+            type = str;
+            description = "Password for the api transport";
+          };
+
+          resource = mkOption {
+            type = str;
+            description = "SSH identity resource for the remote transport";
+          };
+        };
+      }));
+    };
+  };
+
+  config = mkIf (config.services.icingaweb2.enable && cfg.enable) {
+    environment.etc = { "icingaweb2/enabledModules/monitoring" = { source = "${pkgs.icingaweb2}/modules/monitoring"; }; }
+      // optionalAttrs (!cfg.generalConfig.mutable) { "icingaweb2/modules/monitoring/config.ini".text = configIni; }
+      // optionalAttrs (!cfg.mutableBackends) { "icingaweb2/modules/monitoring/backends.ini".text = backendsIni; }
+      // optionalAttrs (!cfg.mutableTransports) { "icingaweb2/modules/monitoring/commandtransports.ini".text = transportsIni; };
+  };
+}
diff --git a/nixos/modules/services/web-apps/ihatemoney/default.nix b/nixos/modules/services/web-apps/ihatemoney/default.nix
new file mode 100644
index 00000000000..ad314c885ba
--- /dev/null
+++ b/nixos/modules/services/web-apps/ihatemoney/default.nix
@@ -0,0 +1,153 @@
+{ config, pkgs, lib, ... }:
+with lib;
+let
+  cfg = config.services.ihatemoney;
+  user = "ihatemoney";
+  group = "ihatemoney";
+  db = "ihatemoney";
+  python3 = config.services.uwsgi.package.python3;
+  pkg = python3.pkgs.ihatemoney;
+  toBool = x: if x then "True" else "False";
+  configFile = pkgs.writeText "ihatemoney.cfg" ''
+        from secrets import token_hex
+        # load a persistent secret key
+        SECRET_KEY_FILE = "/var/lib/ihatemoney/secret_key"
+        SECRET_KEY = ""
+        try:
+          with open(SECRET_KEY_FILE) as f:
+            SECRET_KEY = f.read()
+        except FileNotFoundError:
+          pass
+        if not SECRET_KEY:
+          print("ihatemoney: generating a new secret key")
+          SECRET_KEY = token_hex(50)
+          with open(SECRET_KEY_FILE, "w") as f:
+            f.write(SECRET_KEY)
+        del token_hex
+        del SECRET_KEY_FILE
+
+        # "normal" configuration
+        DEBUG = False
+        SQLALCHEMY_DATABASE_URI = '${
+          if cfg.backend == "sqlite"
+          then "sqlite:////var/lib/ihatemoney/ihatemoney.sqlite"
+          else "postgresql:///${db}"}'
+        SQLALCHEMY_TRACK_MODIFICATIONS = False
+        MAIL_DEFAULT_SENDER = (r"${cfg.defaultSender.name}", r"${cfg.defaultSender.email}")
+        ACTIVATE_DEMO_PROJECT = ${toBool cfg.enableDemoProject}
+        ADMIN_PASSWORD = r"${toString cfg.adminHashedPassword /*toString null == ""*/}"
+        ALLOW_PUBLIC_PROJECT_CREATION = ${toBool cfg.enablePublicProjectCreation}
+        ACTIVATE_ADMIN_DASHBOARD = ${toBool cfg.enableAdminDashboard}
+        SESSION_COOKIE_SECURE = ${toBool cfg.secureCookie}
+        ENABLE_CAPTCHA = ${toBool cfg.enableCaptcha}
+        LEGAL_LINK = r"${toString cfg.legalLink}"
+
+        ${cfg.extraConfig}
+  '';
+in
+  {
+    options.services.ihatemoney = {
+      enable = mkEnableOption "ihatemoney webapp. Note that this will set uwsgi to emperor mode";
+      backend = mkOption {
+        type = types.enum [ "sqlite" "postgresql" ];
+        default = "sqlite";
+        description = ''
+          The database engine to use for ihatemoney.
+          If <literal>postgresql</literal> is selected, then a database called
+          <literal>${db}</literal> will be created. If you disable this option,
+          it will however not be removed.
+        '';
+      };
+      adminHashedPassword = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "The hashed password of the administrator. To obtain it, run <literal>ihatemoney generate_password_hash</literal>";
+      };
+      uwsgiConfig = mkOption {
+        type = types.attrs;
+        example = {
+          http = ":8000";
+        };
+        description = "Additionnal configuration of the UWSGI vassal running ihatemoney. It should notably specify on which interfaces and ports the vassal should listen.";
+      };
+      defaultSender = {
+        name = mkOption {
+          type = types.str;
+          default = "Budget manager";
+          description = "The display name of the sender of ihatemoney emails";
+        };
+        email = mkOption {
+          type = types.str;
+          default = "ihatemoney@${config.networking.hostName}";
+          defaultText = literalExpression ''"ihatemoney@''${config.networking.hostName}"'';
+          description = "The email of the sender of ihatemoney emails";
+        };
+      };
+      secureCookie = mkOption {
+        type = types.bool;
+        default = true;
+        description = "Use secure cookies. Disable this when ihatemoney is served via http instead of https";
+      };
+      enableDemoProject = mkEnableOption "access to the demo project in ihatemoney";
+      enablePublicProjectCreation = mkEnableOption "permission to create projects in ihatemoney by anyone";
+      enableAdminDashboard = mkEnableOption "ihatemoney admin dashboard";
+      enableCaptcha = mkEnableOption "a simplistic captcha for some forms";
+      legalLink = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "The URL to a page explaining legal statements about your service, eg. GDPR-related information.";
+      };
+      extraConfig = mkOption {
+        type = types.str;
+        default = "";
+        description = "Extra configuration appended to ihatemoney's configuration file. It is a python file, so pay attention to indentation.";
+      };
+    };
+    config = mkIf cfg.enable {
+      services.postgresql = mkIf (cfg.backend == "postgresql") {
+        enable = true;
+        ensureDatabases = [ db ];
+        ensureUsers = [ {
+          name = user;
+          ensurePermissions = {
+            "DATABASE ${db}" = "ALL PRIVILEGES";
+          };
+        } ];
+      };
+      systemd.services.postgresql = mkIf (cfg.backend == "postgresql") {
+        wantedBy = [ "uwsgi.service" ];
+        before = [ "uwsgi.service" ];
+      };
+      systemd.tmpfiles.rules = [
+        "d /var/lib/ihatemoney 770 ${user} ${group}"
+      ];
+      users = {
+        users.${user} = {
+          isSystemUser = true;
+          inherit group;
+        };
+        groups.${group} = {};
+      };
+      services.uwsgi = {
+        enable = true;
+        plugins = [ "python3" ];
+        instance = {
+          type = "emperor";
+          vassals.ihatemoney = {
+            type = "normal";
+            strict = true;
+            immediate-uid = user;
+            immediate-gid = group;
+            # apparently flask uses threads: https://github.com/spiral-project/ihatemoney/commit/c7815e48781b6d3a457eaff1808d179402558f8c
+            enable-threads = true;
+            module = "wsgi:application";
+            chdir = "${pkg}/${pkg.pythonModule.sitePackages}/ihatemoney";
+            env = [ "IHATEMONEY_SETTINGS_FILE_PATH=${configFile}" ];
+            pythonPackages = self: [ self.ihatemoney ];
+          } // cfg.uwsgiConfig;
+        };
+      };
+    };
+  }
+
+
diff --git a/nixos/modules/services/web-apps/invidious.nix b/nixos/modules/services/web-apps/invidious.nix
new file mode 100644
index 00000000000..10b30bf1fd1
--- /dev/null
+++ b/nixos/modules/services/web-apps/invidious.nix
@@ -0,0 +1,264 @@
+{ lib, config, pkgs, options, ... }:
+let
+  cfg = config.services.invidious;
+  # To allow injecting secrets with jq, json (instead of yaml) is used
+  settingsFormat = pkgs.formats.json { };
+  inherit (lib) types;
+
+  settingsFile = settingsFormat.generate "invidious-settings" cfg.settings;
+
+  serviceConfig = {
+    systemd.services.invidious = {
+      description = "Invidious (An alternative YouTube front-end)";
+      wants = [ "network-online.target" ];
+      after = [ "network-online.target" ];
+      wantedBy = [ "multi-user.target" ];
+
+      script =
+        let
+          jqFilter = "."
+            + lib.optionalString (cfg.database.host != null) "[0].db.password = \"'\"'\"$(cat ${lib.escapeShellArg cfg.database.passwordFile})\"'\"'\""
+            + " | .[0]"
+            + lib.optionalString (cfg.extraSettingsFile != null) " * .[1]";
+          jqFiles = [ settingsFile ] ++ lib.optional (cfg.extraSettingsFile != null) cfg.extraSettingsFile;
+        in
+        ''
+          export INVIDIOUS_CONFIG="$(${pkgs.jq}/bin/jq -s "${jqFilter}" ${lib.escapeShellArgs jqFiles})"
+          exec ${cfg.package}/bin/invidious
+        '';
+
+      serviceConfig = {
+        RestartSec = "2s";
+        DynamicUser = true;
+
+        CapabilityBoundingSet = "";
+        PrivateDevices = true;
+        PrivateUsers = true;
+        ProtectHome = true;
+        ProtectKernelLogs = true;
+        ProtectProc = "invisible";
+        RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
+        RestrictNamespaces = true;
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
+      };
+    };
+
+    services.invidious.settings = {
+      inherit (cfg) port;
+
+      # Automatically initialises and migrates the database if necessary
+      check_tables = true;
+
+      db = {
+        user = lib.mkDefault "kemal";
+        dbname = lib.mkDefault "invidious";
+        port = cfg.database.port;
+        # Blank for unix sockets, see
+        # https://github.com/will/crystal-pg/blob/1548bb255210/src/pq/conninfo.cr#L100-L108
+        host = if cfg.database.host == null then "" else cfg.database.host;
+        # Not needed because peer authentication is enabled
+        password = lib.mkIf (cfg.database.host == null) "";
+      };
+    } // (lib.optionalAttrs (cfg.domain != null) {
+      inherit (cfg) domain;
+    });
+
+    assertions = [{
+      assertion = cfg.database.host != null -> cfg.database.passwordFile != null;
+      message = "If database host isn't null, database password needs to be set";
+    }];
+  };
+
+  # Settings necessary for running with an automatically managed local database
+  localDatabaseConfig = lib.mkIf cfg.database.createLocally {
+    # Default to using the local database if we create it
+    services.invidious.database.host = lib.mkDefault null;
+
+    services.postgresql = {
+      enable = true;
+      ensureDatabases = lib.singleton cfg.settings.db.dbname;
+      ensureUsers = lib.singleton {
+        name = cfg.settings.db.user;
+        ensurePermissions = {
+          "DATABASE ${cfg.settings.db.dbname}" = "ALL PRIVILEGES";
+        };
+      };
+      # This is only needed because the unix user invidious isn't the same as
+      # the database user. This tells postgres to map one to the other.
+      identMap = ''
+        invidious invidious ${cfg.settings.db.user}
+      '';
+      # And this specifically enables peer authentication for only this
+      # database, which allows passwordless authentication over the postgres
+      # unix socket for the user map given above.
+      authentication = ''
+        local ${cfg.settings.db.dbname} ${cfg.settings.db.user} peer map=invidious
+      '';
+    };
+
+    systemd.services.invidious-db-clean = {
+      description = "Invidious database cleanup";
+      documentation = [ "https://docs.invidious.io/Database-Information-and-Maintenance.md" ];
+      startAt = lib.mkDefault "weekly";
+      path = [ config.services.postgresql.package ];
+      script = ''
+        psql ${cfg.settings.db.dbname} ${cfg.settings.db.user} -c "DELETE FROM nonces * WHERE expire < current_timestamp"
+        psql ${cfg.settings.db.dbname} ${cfg.settings.db.user} -c "TRUNCATE TABLE videos"
+      '';
+      serviceConfig = {
+        DynamicUser = true;
+        User = "invidious";
+      };
+    };
+
+    systemd.services.invidious = {
+      requires = [ "postgresql.service" ];
+      after = [ "postgresql.service" ];
+
+      serviceConfig = {
+        User = "invidious";
+      };
+    };
+  };
+
+  nginxConfig = lib.mkIf cfg.nginx.enable {
+    services.invidious.settings = {
+      https_only = config.services.nginx.virtualHosts.${cfg.domain}.forceSSL;
+      external_port = 80;
+    };
+
+    services.nginx = {
+      enable = true;
+      virtualHosts.${cfg.domain} = {
+        locations."/".proxyPass = "http://127.0.0.1:${toString cfg.port}";
+
+        enableACME = lib.mkDefault true;
+        forceSSL = lib.mkDefault true;
+      };
+    };
+
+    assertions = [{
+      assertion = cfg.domain != null;
+      message = "To use services.invidious.nginx, you need to set services.invidious.domain";
+    }];
+  };
+in
+{
+  options.services.invidious = {
+    enable = lib.mkEnableOption "Invidious";
+
+    package = lib.mkOption {
+      type = types.package;
+      default = pkgs.invidious;
+      defaultText = "pkgs.invidious";
+      description = "The Invidious package to use.";
+    };
+
+    settings = lib.mkOption {
+      type = settingsFormat.type;
+      default = { };
+      description = ''
+        The settings Invidious should use.
+
+        See <link xlink:href="https://github.com/iv-org/invidious/blob/master/config/config.example.yml">config.example.yml</link> for a list of all possible options.
+      '';
+    };
+
+    extraSettingsFile = lib.mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        A file including Invidious settings.
+
+        It gets merged with the setttings specified in <option>services.invidious.settings</option>
+        and can be used to store secrets like <literal>hmac_key</literal> outside of the nix store.
+      '';
+    };
+
+    # This needs to be outside of settings to avoid infinite recursion
+    # (determining if nginx should be enabled and therefore the settings
+    # modified).
+    domain = lib.mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        The FQDN Invidious is reachable on.
+
+        This is used to configure nginx and for building absolute URLs.
+      '';
+    };
+
+    port = lib.mkOption {
+      type = types.port;
+      # Default from https://docs.invidious.io/Configuration.md
+      default = 3000;
+      description = ''
+        The port Invidious should listen on.
+
+        To allow access from outside,
+        you can use either <option>services.invidious.nginx</option>
+        or add <literal>config.services.invidious.port</literal> to <option>networking.firewall.allowedTCPPorts</option>.
+      '';
+    };
+
+    database = {
+      createLocally = lib.mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to create a local database with PostgreSQL.
+        '';
+      };
+
+      host = lib.mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = ''
+          The database host Invidious should use.
+
+          If <literal>null</literal>, the local unix socket is used. Otherwise
+          TCP is used.
+        '';
+      };
+
+      port = lib.mkOption {
+        type = types.port;
+        default = options.services.postgresql.port.default;
+        defaultText = lib.literalExpression "options.services.postgresql.port.default";
+        description = ''
+          The port of the database Invidious should use.
+
+          Defaults to the the default postgresql port.
+        '';
+      };
+
+      passwordFile = lib.mkOption {
+        type = types.nullOr types.str;
+        apply = lib.mapNullable toString;
+        default = null;
+        description = ''
+          Path to file containing the database password.
+        '';
+      };
+    };
+
+    nginx.enable = lib.mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Whether to configure nginx as a reverse proxy for Invidious.
+
+        It serves it under the domain specified in <option>services.invidious.settings.domain</option> with enabled TLS and ACME.
+        Further configuration can be done through <option>services.nginx.virtualHosts.''${config.services.invidious.settings.domain}.*</option>,
+        which can also be used to disable AMCE and TLS.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable (lib.mkMerge [
+    serviceConfig
+    localDatabaseConfig
+    nginxConfig
+  ]);
+}
diff --git a/nixos/modules/services/web-apps/invoiceplane.nix b/nixos/modules/services/web-apps/invoiceplane.nix
new file mode 100644
index 00000000000..095eec36dec
--- /dev/null
+++ b/nixos/modules/services/web-apps/invoiceplane.nix
@@ -0,0 +1,305 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg = config.services.invoiceplane;
+  eachSite = cfg.sites;
+  user = "invoiceplane";
+  webserver = config.services.${cfg.webserver};
+
+  invoiceplane-config = hostName: cfg: pkgs.writeText "ipconfig.php" ''
+    IP_URL=http://${hostName}
+    ENABLE_DEBUG=false
+    DISABLE_SETUP=false
+    REMOVE_INDEXPHP=false
+    DB_HOSTNAME=${cfg.database.host}
+    DB_USERNAME=${cfg.database.user}
+    # NOTE: file_get_contents adds newline at the end of returned string
+    DB_PASSWORD=${if cfg.database.passwordFile == null then "" else "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")"}
+    DB_DATABASE=${cfg.database.name}
+    DB_PORT=${toString cfg.database.port}
+    SESS_EXPIRATION=864000
+    ENABLE_INVOICE_DELETION=false
+    DISABLE_READ_ONLY=false
+    ENCRYPTION_KEY=
+    ENCRYPTION_CIPHER=AES-256
+    SETUP_COMPLETED=false
+  '';
+
+  extraConfig = hostName: cfg: pkgs.writeText "extraConfig.php" ''
+    ${toString cfg.extraConfig}
+  '';
+
+  pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
+    pname = "invoiceplane-${hostName}";
+    version = src.version;
+    src = pkgs.invoiceplane;
+
+    patchPhase = ''
+      # Patch index.php file to load additional config file
+      substituteInPlace index.php \
+        --replace "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = new \Dotenv\Dotenv(__DIR__, 'extraConfig.php'); \$dotenv->load();";
+    '';
+
+    installPhase = ''
+      mkdir -p $out
+      cp -r * $out/
+
+      # symlink uploads and log directories
+      rm -r $out/uploads $out/application/logs $out/vendor/mpdf/mpdf/tmp
+      ln -sf ${cfg.stateDir}/uploads $out/
+      ln -sf ${cfg.stateDir}/logs $out/application/
+      ln -sf ${cfg.stateDir}/tmp $out/vendor/mpdf/mpdf/
+
+      # symlink the InvoicePlane config
+      ln -s ${cfg.stateDir}/ipconfig.php $out/ipconfig.php
+
+      # symlink the extraConfig file
+      ln -s ${extraConfig hostName cfg} $out/extraConfig.php
+
+      # symlink additional templates
+      ${concatMapStringsSep "\n" (template: "cp -r ${template}/. $out/application/views/invoice_templates/pdf/") cfg.invoiceTemplates}
+    '';
+  };
+
+  siteOpts = { lib, name, ... }:
+    {
+      options = {
+
+        enable = mkEnableOption "InvoicePlane web application";
+
+        stateDir = mkOption {
+          type = types.path;
+          default = "/var/lib/invoiceplane/${name}";
+          description = ''
+            This directory is used for uploads of attachements and cache.
+            The directory passed here is automatically created and permissions
+            adjusted as required.
+          '';
+        };
+
+        database = {
+          host = mkOption {
+            type = types.str;
+            default = "localhost";
+            description = "Database host address.";
+          };
+
+          port = mkOption {
+            type = types.port;
+            default = 3306;
+            description = "Database host port.";
+          };
+
+          name = mkOption {
+            type = types.str;
+            default = "invoiceplane";
+            description = "Database name.";
+          };
+
+          user = mkOption {
+            type = types.str;
+            default = "invoiceplane";
+            description = "Database user.";
+          };
+
+          passwordFile = mkOption {
+            type = types.nullOr types.path;
+            default = null;
+            example = "/run/keys/invoiceplane-dbpassword";
+            description = ''
+              A file containing the password corresponding to
+              <option>database.user</option>.
+            '';
+          };
+
+          createLocally = mkOption {
+            type = types.bool;
+            default = true;
+            description = "Create the database and database user locally.";
+          };
+        };
+
+        invoiceTemplates = mkOption {
+          type = types.listOf types.path;
+          default = [];
+          description = ''
+            List of path(s) to respective template(s) which are copied from the 'invoice_templates/pdf' directory.
+            <note><para>These templates need to be packaged before use, see example.</para></note>
+          '';
+          example = literalExpression ''
+            let
+              # Let's package an example template
+              template-vtdirektmarketing = pkgs.stdenv.mkDerivation {
+                name = "vtdirektmarketing";
+                # Download the template from a public repository
+                src = pkgs.fetchgit {
+                  url = "https://git.project-insanity.org/onny/invoiceplane-vtdirektmarketing.git";
+                  sha256 = "1hh0q7wzsh8v8x03i82p6qrgbxr4v5fb05xylyrpp975l8axyg2z";
+                };
+                sourceRoot = ".";
+                # Installing simply means copying template php file to the output directory
+                installPhase = ""
+                  mkdir -p $out
+                  cp invoiceplane-vtdirektmarketing/vtdirektmarketing.php $out/
+                "";
+              };
+            # And then pass this package to the template list like this:
+            in [ template-vtdirektmarketing ]
+          '';
+        };
+
+        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 InvoicePlane PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+            for details on configuration directives.
+          '';
+        };
+
+        extraConfig = mkOption {
+          type = types.nullOr types.lines;
+          default = null;
+          example = ''
+            SETUP_COMPLETED=true
+            DISABLE_SETUP=true
+            IP_URL=https://invoice.example.com
+          '';
+          description = ''
+            InvoicePlane configuration. Refer to
+            <link xlink:href="https://github.com/InvoicePlane/InvoicePlane/blob/master/ipconfig.php.example"/>
+            for details on supported values.
+          '';
+        };
+
+      };
+
+    };
+in
+{
+  # interface
+  options = {
+    services.invoiceplane = mkOption {
+      type = types.submodule {
+
+        options.sites = mkOption {
+          type = types.attrsOf (types.submodule siteOpts);
+          default = {};
+          description = "Specification of one or more WordPress sites to serve";
+        };
+
+        options.webserver = mkOption {
+          type = types.enum [ "caddy" ];
+          default = "caddy";
+          description = ''
+            Which webserver to use for virtual host management. Currently only
+            caddy is supported.
+          '';
+        };
+      };
+      default = {};
+      description = "InvoicePlane configuration.";
+    };
+
+  };
+
+  # implementation
+  config = mkIf (eachSite != {}) (mkMerge [{
+
+    assertions = flatten (mapAttrsToList (hostName: cfg:
+      [{ assertion = cfg.database.createLocally -> cfg.database.user == user;
+        message = ''services.invoiceplane.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
+        message = ''services.invoiceplane.sites."${hostName}".database.passwordFile cannot be specified if services.invoiceplane.sites."${hostName}".database.createLocally is set to true.'';
+      }]
+    ) eachSite);
+
+    services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
+      ensureUsers = mapAttrsToList (hostName: cfg:
+        { name = cfg.database.user;
+          ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
+        }
+      ) eachSite;
+    };
+
+    services.phpfpm = {
+      phpPackage = pkgs.php74;
+      pools = mapAttrs' (hostName: cfg: (
+        nameValuePair "invoiceplane-${hostName}" {
+          inherit user;
+          group = webserver.group;
+          settings = {
+            "listen.owner" = webserver.user;
+            "listen.group" = webserver.group;
+          } // cfg.poolConfig;
+        }
+      )) eachSite;
+    };
+
+  }
+
+  {
+    systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
+      "d ${cfg.stateDir} 0750 ${user} ${webserver.group} - -"
+      "f ${cfg.stateDir}/ipconfig.php 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/logs 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/uploads 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/uploads/archive 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/uploads/customer_files 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/uploads/temp 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/uploads/temp/mpdf 0750 ${user} ${webserver.group} - -"
+      "d ${cfg.stateDir}/tmp 0750 ${user} ${webserver.group} - -"
+    ]) eachSite);
+
+    systemd.services.invoiceplane-config = {
+      serviceConfig.Type = "oneshot";
+      script = concatStrings (mapAttrsToList (hostName: cfg:
+        ''
+          mkdir -p ${cfg.stateDir}/logs \
+                   ${cfg.stateDir}/uploads
+          if ! grep -q IP_URL "${cfg.stateDir}/ipconfig.php"; then
+            cp "${invoiceplane-config hostName cfg}" "${cfg.stateDir}/ipconfig.php"
+          fi
+        '') eachSite);
+      wantedBy = [ "multi-user.target" ];
+    };
+
+    users.users.${user} = {
+      group = webserver.group;
+      isSystemUser = true;
+    };
+  }
+
+  (mkIf (cfg.webserver == "caddy") {
+    services.caddy = {
+      enable = true;
+      virtualHosts = mapAttrs' (hostName: cfg: (
+        nameValuePair "http://${hostName}" {
+          extraConfig = ''
+            root    * ${pkg hostName cfg}
+            file_server
+
+            php_fastcgi unix/${config.services.phpfpm.pools."invoiceplane-${hostName}".socket}
+          '';
+        }
+      )) eachSite;
+    };
+  })
+
+
+  ]);
+}
+
diff --git a/nixos/modules/services/web-apps/isso.nix b/nixos/modules/services/web-apps/isso.nix
new file mode 100644
index 00000000000..4c01781a6a2
--- /dev/null
+++ b/nixos/modules/services/web-apps/isso.nix
@@ -0,0 +1,69 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (lib) mkEnableOption mkIf mkOption types literalExpression;
+
+  cfg = config.services.isso;
+
+  settingsFormat = pkgs.formats.ini { };
+  configFile = settingsFormat.generate "isso.conf" cfg.settings;
+in {
+
+  options = {
+    services.isso = {
+      enable = mkEnableOption ''
+        A commenting server similar to Disqus.
+
+        Note: The application's author suppose to run isso behind a reverse proxy.
+        The embedded solution offered by NixOS is also only suitable for small installations
+        below 20 requests per second.
+      '';
+
+      settings = mkOption {
+        description = ''
+          Configuration for <package>isso</package>.
+
+          See <link xlink:href="https://posativ.org/isso/docs/configuration/server/">Isso Server Configuration</link>
+          for supported values.
+        '';
+
+        type = types.submodule {
+          freeformType = settingsFormat.type;
+        };
+
+        example = literalExpression ''
+          {
+            general = {
+              host = "http://localhost";
+            };
+          }
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.isso.settings.general.dbpath = lib.mkDefault "/var/lib/isso/comments.db";
+
+    systemd.services.isso = {
+      description = "isso, a commenting server similar to Disqus";
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        User = "isso";
+        Group = "isso";
+
+        DynamicUser = true;
+
+        StateDirectory = "isso";
+
+        ExecStart = ''
+          ${pkgs.isso}/bin/isso -c ${configFile}
+        '';
+
+        Restart = "on-failure";
+        RestartSec = 1;
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/jirafeau.nix b/nixos/modules/services/web-apps/jirafeau.nix
new file mode 100644
index 00000000000..328c61c8e64
--- /dev/null
+++ b/nixos/modules/services/web-apps/jirafeau.nix
@@ -0,0 +1,173 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.jirafeau;
+
+  group = config.services.nginx.group;
+  user = config.services.nginx.user;
+
+  withTrailingSlash = str: if hasSuffix "/" str then str else "${str}/";
+
+  localConfig = pkgs.writeText "config.local.php" ''
+    <?php
+      $cfg['admin_password'] = '${cfg.adminPasswordSha256}';
+      $cfg['web_root'] = 'http://${withTrailingSlash cfg.hostName}';
+      $cfg['var_root'] = '${withTrailingSlash cfg.dataDir}';
+      $cfg['maximal_upload_size'] = ${builtins.toString cfg.maxUploadSizeMegabytes};
+      $cfg['installation_done'] = true;
+
+      ${cfg.extraConfig}
+  '';
+in
+{
+  options.services.jirafeau = {
+    adminPasswordSha256 = mkOption {
+      type = types.str;
+      default = "";
+      description = ''
+        SHA-256 of the desired administration password. Leave blank/unset for no password.
+      '';
+    };
+
+    dataDir = mkOption {
+      type = types.path;
+      default = "/var/lib/jirafeau/data/";
+      description = "Location of Jirafeau storage directory.";
+    };
+
+    enable = mkEnableOption "Jirafeau file upload application.";
+
+    extraConfig = mkOption {
+      type = types.lines;
+      default = "";
+      example = ''
+        $cfg['style'] = 'courgette';
+        $cfg['organisation'] = 'ACME';
+      '';
+      description = let
+        documentationLink =
+          "https://gitlab.com/mojo42/Jirafeau/-/blob/${cfg.package.version}/lib/config.original.php";
+      in
+        ''
+          Jirefeau configuration. Refer to <link xlink:href="${documentationLink}"/> for supported
+          values.
+        '';
+    };
+
+    hostName = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = "URL of instance. Must have trailing slash.";
+    };
+
+    maxUploadSizeMegabytes = mkOption {
+      type = types.int;
+      default = 0;
+      description = "Maximum upload size of accepted files.";
+    };
+
+    maxUploadTimeout = mkOption {
+      type = types.str;
+      default = "30m";
+      description = let
+        nginxCoreDocumentation = "http://nginx.org/en/docs/http/ngx_http_core_module.html";
+      in
+        ''
+          Timeout for reading client request bodies and headers. Refer to
+          <link xlink:href="${nginxCoreDocumentation}#client_body_timeout"/> and
+          <link xlink:href="${nginxCoreDocumentation}#client_header_timeout"/> for accepted values.
+        '';
+    };
+
+    nginxConfig = mkOption {
+      type = types.submodule
+        (import ../web-servers/nginx/vhost-options.nix { inherit config lib; });
+      default = {};
+      example = literalExpression ''
+        {
+          serverAliases = [ "wiki.''${config.networking.domain}" ];
+        }
+      '';
+      description = "Extra configuration for the nginx virtual host of Jirafeau.";
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.jirafeau;
+      defaultText = literalExpression "pkgs.jirafeau";
+      description = "Jirafeau package to use";
+    };
+
+    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 Jirafeau PHP pool. See documentation on <literal>php-fpm.conf</literal> for
+        details on configuration directives.
+      '';
+    };
+  };
+
+
+  config = mkIf cfg.enable {
+    services = {
+      nginx = {
+        enable = true;
+        virtualHosts."${cfg.hostName}" = mkMerge [
+          cfg.nginxConfig
+          {
+            extraConfig = let
+              clientMaxBodySize =
+                if cfg.maxUploadSizeMegabytes == 0 then "0" else "${cfg.maxUploadSizeMegabytes}m";
+            in
+              ''
+                index index.php;
+                client_max_body_size ${clientMaxBodySize};
+                client_body_timeout ${cfg.maxUploadTimeout};
+                client_header_timeout ${cfg.maxUploadTimeout};
+              '';
+            locations = {
+              "~ \\.php$".extraConfig = ''
+                include ${config.services.nginx.package}/conf/fastcgi_params;
+                fastcgi_split_path_info ^(.+\.php)(/.+)$;
+                fastcgi_index index.php;
+                fastcgi_pass unix:${config.services.phpfpm.pools.jirafeau.socket};
+                fastcgi_param PATH_INFO $fastcgi_path_info;
+                fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+              '';
+            };
+            root = mkForce "${cfg.package}";
+          }
+        ];
+      };
+
+      phpfpm.pools.jirafeau = {
+        inherit group user;
+        phpEnv."JIRAFEAU_CONFIG" = "${localConfig}";
+        settings = {
+          "listen.mode" = "0660";
+          "listen.owner" = user;
+          "listen.group" = group;
+        } // cfg.poolConfig;
+      };
+    };
+
+    systemd.tmpfiles.rules = [
+      "d ${cfg.dataDir} 0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/files/ 0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/links/ 0750 ${user} ${group} - -"
+      "d ${cfg.dataDir}/async/ 0750 ${user} ${group} - -"
+    ];
+  };
+
+  # uses attributes of the linked package
+  meta.buildDocsInSandbox = false;
+}
diff --git a/nixos/modules/services/web-apps/jitsi-meet.nix b/nixos/modules/services/web-apps/jitsi-meet.nix
new file mode 100644
index 00000000000..2f1c4acec1e
--- /dev/null
+++ b/nixos/modules/services/web-apps/jitsi-meet.nix
@@ -0,0 +1,452 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.jitsi-meet;
+
+  # The configuration files are JS of format "var <<string>> = <<JSON>>;". In order to
+  # override only some settings, we need to extract the JSON, use jq to merge it with
+  # the config provided by user, and then reconstruct the file.
+  overrideJs =
+    source: varName: userCfg: appendExtra:
+    let
+      extractor = pkgs.writeText "extractor.js" ''
+        var fs = require("fs");
+        eval(fs.readFileSync(process.argv[2], 'utf8'));
+        process.stdout.write(JSON.stringify(eval(process.argv[3])));
+      '';
+      userJson = pkgs.writeText "user.json" (builtins.toJSON userCfg);
+    in (pkgs.runCommand "${varName}.js" { } ''
+      ${pkgs.nodejs}/bin/node ${extractor} ${source} ${varName} > default.json
+      (
+        echo "var ${varName} = "
+        ${pkgs.jq}/bin/jq -s '.[0] * .[1]' default.json ${userJson}
+        echo ";"
+        echo ${escapeShellArg appendExtra}
+      ) > $out
+    '');
+
+  # Essential config - it's probably not good to have these as option default because
+  # types.attrs doesn't do merging. Let's merge explicitly, can still be overriden if
+  # user desires.
+  defaultCfg = {
+    hosts = {
+      domain = cfg.hostName;
+      muc = "conference.${cfg.hostName}";
+      focus = "focus.${cfg.hostName}";
+    };
+    bosh = "//${cfg.hostName}/http-bind";
+    websocket = "wss://${cfg.hostName}/xmpp-websocket";
+
+    fileRecordingsEnabled = true;
+    liveStreamingEnabled = true;
+    hiddenDomain = "recorder.${cfg.hostName}";
+  };
+in
+{
+  options.services.jitsi-meet = with types; {
+    enable = mkEnableOption "Jitsi Meet - Secure, Simple and Scalable Video Conferences";
+
+    hostName = mkOption {
+      type = str;
+      example = "meet.example.org";
+      description = ''
+        FQDN of the Jitsi Meet instance.
+      '';
+    };
+
+    config = mkOption {
+      type = attrs;
+      default = { };
+      example = literalExpression ''
+        {
+          enableWelcomePage = false;
+          defaultLang = "fi";
+        }
+      '';
+      description = ''
+        Client-side web application settings that override the defaults in <filename>config.js</filename>.
+
+        See <link xlink:href="https://github.com/jitsi/jitsi-meet/blob/master/config.js" /> for default
+        configuration with comments.
+      '';
+    };
+
+    extraConfig = mkOption {
+      type = lines;
+      default = "";
+      description = ''
+        Text to append to <filename>config.js</filename> web application config file.
+
+        Can be used to insert JavaScript logic to determine user's region in cascading bridges setup.
+      '';
+    };
+
+    interfaceConfig = mkOption {
+      type = attrs;
+      default = { };
+      example = literalExpression ''
+        {
+          SHOW_JITSI_WATERMARK = false;
+          SHOW_WATERMARK_FOR_GUESTS = false;
+        }
+      '';
+      description = ''
+        Client-side web-app interface settings that override the defaults in <filename>interface_config.js</filename>.
+
+        See <link xlink:href="https://github.com/jitsi/jitsi-meet/blob/master/interface_config.js" /> for
+        default configuration with comments.
+      '';
+    };
+
+    videobridge = {
+      enable = mkOption {
+        type = bool;
+        default = true;
+        description = ''
+          Whether to enable Jitsi Videobridge instance and configure it to connect to Prosody.
+
+          Additional configuration is possible with <option>services.jitsi-videobridge</option>.
+        '';
+      };
+
+      passwordFile = mkOption {
+        type = nullOr str;
+        default = null;
+        example = "/run/keys/videobridge";
+        description = ''
+          File containing password to the Prosody account for videobridge.
+
+          If <literal>null</literal>, a file with password will be generated automatically. Setting
+          this option is useful if you plan to connect additional videobridges to the XMPP server.
+        '';
+      };
+    };
+
+    jicofo.enable = mkOption {
+      type = bool;
+      default = true;
+      description = ''
+        Whether to enable JiCoFo instance and configure it to connect to Prosody.
+
+        Additional configuration is possible with <option>services.jicofo</option>.
+      '';
+    };
+
+    jibri.enable = mkOption {
+      type = bool;
+      default = false;
+      description = ''
+        Whether to enable a Jibri instance and configure it to connect to Prosody.
+
+        Additional configuration is possible with <option>services.jibri</option>, and
+        <option>services.jibri.finalizeScript</option> is especially useful.
+      '';
+    };
+
+    nginx.enable = mkOption {
+      type = bool;
+      default = true;
+      description = ''
+        Whether to enable nginx virtual host that will serve the javascript application and act as
+        a proxy for the XMPP server. Further nginx configuration can be done by adapting
+        <option>services.nginx.virtualHosts.&lt;hostName&gt;</option>.
+        When this is enabled, ACME will be used to retrieve a TLS certificate by default. To disable
+        this, set the <option>services.nginx.virtualHosts.&lt;hostName&gt;.enableACME</option> to
+        <literal>false</literal> and if appropriate do the same for
+        <option>services.nginx.virtualHosts.&lt;hostName&gt;.forceSSL</option>.
+      '';
+    };
+
+    caddy.enable = mkEnableOption "Whether to enablle caddy reverse proxy to expose jitsi-meet";
+
+    prosody.enable = mkOption {
+      type = bool;
+      default = true;
+      description = ''
+        Whether to configure Prosody to relay XMPP messages between Jitsi Meet components. Turn this
+        off if you want to configure it manually.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.prosody = mkIf cfg.prosody.enable {
+      enable = mkDefault true;
+      xmppComplianceSuite = mkDefault false;
+      modules = {
+        admin_adhoc = mkDefault false;
+        bosh = mkDefault true;
+        ping = mkDefault true;
+        roster = mkDefault true;
+        saslauth = mkDefault true;
+        smacks = mkDefault true;
+        tls = mkDefault true;
+        websocket = mkDefault true;
+      };
+      muc = [
+        {
+          domain = "conference.${cfg.hostName}";
+          name = "Jitsi Meet MUC";
+          roomLocking = false;
+          roomDefaultPublicJids = true;
+          extraConfig = ''
+            storage = "memory"
+          '';
+        }
+        {
+          domain = "internal.${cfg.hostName}";
+          name = "Jitsi Meet Videobridge MUC";
+          extraConfig = ''
+            storage = "memory"
+            admins = { "focus@auth.${cfg.hostName}", "jvb@auth.${cfg.hostName}" }
+          '';
+          #-- muc_room_cache_size = 1000
+        }
+      ];
+      extraModules = [ "pubsub" "smacks" ];
+      extraPluginPaths = [ "${pkgs.jitsi-meet-prosody}/share/prosody-plugins" ];
+      extraConfig = lib.mkMerge [ (mkAfter ''
+        Component "focus.${cfg.hostName}" "client_proxy"
+          target_address = "focus@auth.${cfg.hostName}"
+        '')
+        (mkBefore ''
+          cross_domain_websocket = true;
+          consider_websocket_secure = true;
+        '')
+      ];
+      virtualHosts.${cfg.hostName} = {
+        enabled = true;
+        domain = cfg.hostName;
+        extraConfig = ''
+          authentication = "anonymous"
+          c2s_require_encryption = false
+          admins = { "focus@auth.${cfg.hostName}" }
+          smacks_max_unacked_stanzas = 5
+          smacks_hibernation_time = 60
+          smacks_max_hibernated_sessions = 1
+          smacks_max_old_sessions = 1
+        '';
+        ssl = {
+          cert = "/var/lib/jitsi-meet/jitsi-meet.crt";
+          key = "/var/lib/jitsi-meet/jitsi-meet.key";
+        };
+      };
+      virtualHosts."auth.${cfg.hostName}" = {
+        enabled = true;
+        domain = "auth.${cfg.hostName}";
+        extraConfig = ''
+          authentication = "internal_plain"
+        '';
+        ssl = {
+          cert = "/var/lib/jitsi-meet/jitsi-meet.crt";
+          key = "/var/lib/jitsi-meet/jitsi-meet.key";
+        };
+      };
+      virtualHosts."recorder.${cfg.hostName}" = {
+        enabled = true;
+        domain = "recorder.${cfg.hostName}";
+        extraConfig = ''
+          authentication = "internal_plain"
+          c2s_require_encryption = false
+        '';
+      };
+    };
+    systemd.services.prosody.serviceConfig = mkIf cfg.prosody.enable {
+      EnvironmentFile = [ "/var/lib/jitsi-meet/secrets-env" ];
+      SupplementaryGroups = [ "jitsi-meet" ];
+    };
+
+    users.groups.jitsi-meet = {};
+    systemd.tmpfiles.rules = [
+      "d '/var/lib/jitsi-meet' 0750 root jitsi-meet - -"
+    ];
+
+    systemd.services.jitsi-meet-init-secrets = {
+      wantedBy = [ "multi-user.target" ];
+      before = [ "jicofo.service" "jitsi-videobridge2.service" ] ++ (optional cfg.prosody.enable "prosody.service");
+      path = [ config.services.prosody.package ];
+      serviceConfig = {
+        Type = "oneshot";
+      };
+
+      script = let
+        secrets = [ "jicofo-component-secret" "jicofo-user-secret" "jibri-auth-secret" "jibri-recorder-secret" ] ++ (optional (cfg.videobridge.passwordFile == null) "videobridge-secret");
+        videobridgeSecret = if cfg.videobridge.passwordFile != null then cfg.videobridge.passwordFile else "/var/lib/jitsi-meet/videobridge-secret";
+      in
+      ''
+        cd /var/lib/jitsi-meet
+        ${concatMapStringsSep "\n" (s: ''
+          if [ ! -f ${s} ]; then
+            tr -dc a-zA-Z0-9 </dev/urandom | head -c 64 > ${s}
+            chown root:jitsi-meet ${s}
+            chmod 640 ${s}
+          fi
+        '') secrets}
+
+        # for easy access in prosody
+        echo "JICOFO_COMPONENT_SECRET=$(cat jicofo-component-secret)" > secrets-env
+        chown root:jitsi-meet secrets-env
+        chmod 640 secrets-env
+      ''
+      + optionalString cfg.prosody.enable ''
+        prosodyctl register focus auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jicofo-user-secret)"
+        prosodyctl register jvb auth.${cfg.hostName} "$(cat ${videobridgeSecret})"
+        prosodyctl mod_roster_command subscribe focus.${cfg.hostName} focus@auth.${cfg.hostName}
+        prosodyctl register jibri auth.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jibri-auth-secret)"
+        prosodyctl register recorder recorder.${cfg.hostName} "$(cat /var/lib/jitsi-meet/jibri-recorder-secret)"
+
+        # generate self-signed certificates
+        if [ ! -f /var/lib/jitsi-meet.crt ]; then
+          ${getBin pkgs.openssl}/bin/openssl req \
+            -x509 \
+            -newkey rsa:4096 \
+            -keyout /var/lib/jitsi-meet/jitsi-meet.key \
+            -out /var/lib/jitsi-meet/jitsi-meet.crt \
+            -days 36500 \
+            -nodes \
+            -subj '/CN=${cfg.hostName}/CN=auth.${cfg.hostName}'
+          chmod 640 /var/lib/jitsi-meet/jitsi-meet.{crt,key}
+          chown root:jitsi-meet /var/lib/jitsi-meet/jitsi-meet.{crt,key}
+        fi
+      '';
+    };
+
+    services.nginx = mkIf cfg.nginx.enable {
+      enable = mkDefault true;
+      virtualHosts.${cfg.hostName} = {
+        enableACME = mkDefault true;
+        forceSSL = mkDefault true;
+        root = pkgs.jitsi-meet;
+        extraConfig = ''
+          ssi on;
+        '';
+        locations."@root_path".extraConfig = ''
+          rewrite ^/(.*)$ / break;
+        '';
+        locations."~ ^/([^/\\?&:'\"]+)$".tryFiles = "$uri @root_path";
+        locations."^~ /xmpp-websocket" = {
+          priority = 100;
+          proxyPass = "http://localhost:5280/xmpp-websocket";
+          proxyWebsockets = true;
+        };
+        locations."=/http-bind" = {
+          proxyPass = "http://localhost:5280/http-bind";
+          extraConfig = ''
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header Host $host;
+          '';
+        };
+        locations."=/external_api.js" = mkDefault {
+          alias = "${pkgs.jitsi-meet}/libs/external_api.min.js";
+        };
+        locations."=/config.js" = mkDefault {
+          alias = overrideJs "${pkgs.jitsi-meet}/config.js" "config" (recursiveUpdate defaultCfg cfg.config) cfg.extraConfig;
+        };
+        locations."=/interface_config.js" = mkDefault {
+          alias = overrideJs "${pkgs.jitsi-meet}/interface_config.js" "interfaceConfig" cfg.interfaceConfig "";
+        };
+      };
+    };
+
+    services.caddy = mkIf cfg.caddy.enable {
+      enable = mkDefault true;
+      virtualHosts.${cfg.hostName} = {
+        extraConfig =
+        let
+          templatedJitsiMeet = pkgs.runCommand "templated-jitsi-meet" {} ''
+            cp -R ${pkgs.jitsi-meet}/* .
+            for file in *.html **/*.html ; do
+              ${pkgs.sd}/bin/sd '<!--#include virtual="(.*)" -->' '{{ include "$1" }}' $file
+            done
+            rm config.js
+            rm interface_config.js
+            cp -R . $out
+            cp ${overrideJs "${pkgs.jitsi-meet}/config.js" "config" (recursiveUpdate defaultCfg cfg.config) cfg.extraConfig} $out/config.js
+            cp ${overrideJs "${pkgs.jitsi-meet}/interface_config.js" "interfaceConfig" cfg.interfaceConfig ""} $out/interface_config.js
+            cp ./libs/external_api.min.js $out/external_api.js
+          '';
+        in ''
+          handle /http-bind {
+            header Host ${cfg.hostName}
+            reverse_proxy 127.0.0.1:5280
+          }
+          handle /xmpp-websocket {
+            reverse_proxy 127.0.0.1:5280
+          }
+          handle {
+            templates
+            root * ${templatedJitsiMeet}
+            try_files {path} {path}
+            try_files {path} /index.html
+            file_server
+          }
+        '';
+      };
+    };
+
+    services.jitsi-videobridge = mkIf cfg.videobridge.enable {
+      enable = true;
+      xmppConfigs."localhost" = {
+        userName = "jvb";
+        domain = "auth.${cfg.hostName}";
+        passwordFile = "/var/lib/jitsi-meet/videobridge-secret";
+        mucJids = "jvbbrewery@internal.${cfg.hostName}";
+        disableCertificateVerification = true;
+      };
+    };
+
+    services.jicofo = mkIf cfg.jicofo.enable {
+      enable = true;
+      xmppHost = "localhost";
+      xmppDomain = cfg.hostName;
+      userDomain = "auth.${cfg.hostName}";
+      userName = "focus";
+      userPasswordFile = "/var/lib/jitsi-meet/jicofo-user-secret";
+      componentPasswordFile = "/var/lib/jitsi-meet/jicofo-component-secret";
+      bridgeMuc = "jvbbrewery@internal.${cfg.hostName}";
+      config = mkMerge [{
+        "org.jitsi.jicofo.ALWAYS_TRUST_MODE_ENABLED" = "true";
+      #} (lib.mkIf cfg.jibri.enable {
+       } (lib.mkIf (config.services.jibri.enable || cfg.jibri.enable) {
+        "org.jitsi.jicofo.jibri.BREWERY" = "JibriBrewery@internal.${cfg.hostName}";
+        "org.jitsi.jicofo.jibri.PENDING_TIMEOUT" = "90";
+      })];
+    };
+
+    services.jibri = mkIf cfg.jibri.enable {
+      enable = true;
+
+      xmppEnvironments."jitsi-meet" = {
+        xmppServerHosts = [ "localhost" ];
+        xmppDomain = cfg.hostName;
+
+        control.muc = {
+          domain = "internal.${cfg.hostName}";
+          roomName = "JibriBrewery";
+          nickname = "jibri";
+        };
+
+        control.login = {
+          domain = "auth.${cfg.hostName}";
+          username = "jibri";
+          passwordFile = "/var/lib/jitsi-meet/jibri-auth-secret";
+        };
+
+        call.login = {
+          domain = "recorder.${cfg.hostName}";
+          username = "recorder";
+          passwordFile = "/var/lib/jitsi-meet/jibri-recorder-secret";
+        };
+
+        usageTimeout = "0";
+        disableCertificateVerification = true;
+        stripFromRoomDomain = "conference.";
+      };
+    };
+  };
+
+  meta.doc = ./jitsi-meet.xml;
+  meta.maintainers = lib.teams.jitsi.members;
+}
diff --git a/nixos/modules/services/web-apps/jitsi-meet.xml b/nixos/modules/services/web-apps/jitsi-meet.xml
new file mode 100644
index 00000000000..ff44c724adf
--- /dev/null
+++ b/nixos/modules/services/web-apps/jitsi-meet.xml
@@ -0,0 +1,55 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-jitsi-meet">
+ <title>Jitsi Meet</title>
+ <para>
+   With Jitsi Meet on NixOS you can quickly configure a complete,
+   private, self-hosted video conferencing solution.
+ </para>
+
+ <section xml:id="module-services-jitsi-basic-usage">
+ <title>Basic usage</title>
+   <para>
+     A minimal configuration using Let's Encrypt for TLS certificates looks like this:
+<programlisting>{
+  services.jitsi-meet = {
+    <link linkend="opt-services.jitsi-meet.enable">enable</link> = true;
+    <link linkend="opt-services.jitsi-meet.enable">hostName</link> = "jitsi.example.com";
+  };
+  <link linkend="opt-services.jitsi-videobridge.openFirewall">services.jitsi-videobridge.openFirewall</link> = true;
+  <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ];
+  <link linkend="opt-security.acme.defaults.email">security.acme.email</link> = "me@example.com";
+  <link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link> = true;
+}</programlisting>
+   </para>
+ </section>
+
+ <section xml:id="module-services-jitsi-configuration">
+ <title>Configuration</title>
+   <para>
+     Here is the minimal configuration with additional configurations:
+<programlisting>{
+  services.jitsi-meet = {
+    <link linkend="opt-services.jitsi-meet.enable">enable</link> = true;
+    <link linkend="opt-services.jitsi-meet.enable">hostName</link> = "jitsi.example.com";
+    <link linkend="opt-services.jitsi-meet.config">config</link> = {
+      enableWelcomePage = false;
+      prejoinPageEnabled = true;
+      defaultLang = "fi";
+    };
+    <link linkend="opt-services.jitsi-meet.interfaceConfig">interfaceConfig</link> = {
+      SHOW_JITSI_WATERMARK = false;
+      SHOW_WATERMARK_FOR_GUESTS = false;
+    };
+  };
+  <link linkend="opt-services.jitsi-videobridge.openFirewall">services.jitsi-videobridge.openFirewall</link> = true;
+  <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ];
+  <link linkend="opt-security.acme.defaults.email">security.acme.email</link> = "me@example.com";
+  <link linkend="opt-security.acme.acceptTerms">security.acme.acceptTerms</link> = true;
+}</programlisting>
+   </para>
+ </section>
+
+</chapter>
diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix
new file mode 100644
index 00000000000..22c16be7613
--- /dev/null
+++ b/nixos/modules/services/web-apps/keycloak.nix
@@ -0,0 +1,819 @@
+{ config, options, pkgs, lib, ... }:
+
+let
+  cfg = config.services.keycloak;
+  opt = options.services.keycloak;
+
+  inherit (lib) types mkOption concatStringsSep mapAttrsToList
+    escapeShellArg recursiveUpdate optionalAttrs boolToString mkOrder
+    sort filterAttrs concatMapStringsSep concatStrings mkIf
+    optionalString optionals mkDefault literalExpression hasSuffix
+    foldl' isAttrs filter attrNames elem literalDocBook
+    maintainers;
+
+  inherit (builtins) match typeOf;
+in
+{
+  options.services.keycloak =
+    let
+      inherit (types) bool str nullOr attrsOf path enum anything
+        package port;
+    in
+    {
+      enable = mkOption {
+        type = bool;
+        default = false;
+        example = true;
+        description = ''
+          Whether to enable the Keycloak identity and access management
+          server.
+        '';
+      };
+
+      bindAddress = mkOption {
+        type = str;
+        default = "\${jboss.bind.address:0.0.0.0}";
+        example = "127.0.0.1";
+        description = ''
+          On which address Keycloak should accept new connections.
+
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
+
+      httpPort = mkOption {
+        type = str;
+        default = "\${jboss.http.port:80}";
+        example = "8080";
+        description = ''
+          On which port Keycloak should listen for new HTTP connections.
+
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
+
+      httpsPort = mkOption {
+        type = str;
+        default = "\${jboss.https.port:443}";
+        example = "8443";
+        description = ''
+          On which port Keycloak should listen for new HTTPS connections.
+
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
+
+      frontendUrl = mkOption {
+        type = str;
+        apply = x:
+          if x == "" || hasSuffix "/" x then
+            x
+          else
+            x + "/";
+        example = "keycloak.example.com/auth";
+        description = ''
+          The public URL used as base for all frontend requests. Should
+          normally include a trailing <literal>/auth</literal>.
+
+          See <link xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
+          Hostname section of the Keycloak server installation
+          manual</link> for more information.
+        '';
+      };
+
+      forceBackendUrlToFrontendUrl = mkOption {
+        type = bool;
+        default = false;
+        example = true;
+        description = ''
+          Whether Keycloak should force all requests to go through the
+          frontend URL configured in <xref
+          linkend="opt-services.keycloak.frontendUrl" />. By default,
+          Keycloak allows backend requests to instead use its local
+          hostname or IP address and may also advertise it to clients
+          through its OpenID Connect Discovery endpoint.
+
+          See <link
+          xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
+          Hostname section of the Keycloak server installation
+          manual</link> for more information.
+        '';
+      };
+
+      sslCertificate = mkOption {
+        type = nullOr path;
+        default = null;
+        example = "/run/keys/ssl_cert";
+        description = ''
+          The path to a PEM formatted certificate to use for TLS/SSL
+          connections.
+
+          This should be a string, not a Nix path, since Nix paths are
+          copied into the world-readable Nix store.
+        '';
+      };
+
+      sslCertificateKey = mkOption {
+        type = nullOr path;
+        default = null;
+        example = "/run/keys/ssl_key";
+        description = ''
+          The path to a PEM formatted private key to use for TLS/SSL
+          connections.
+
+          This should be a string, not a Nix path, since Nix paths are
+          copied into the world-readable Nix store.
+        '';
+      };
+
+      database = {
+        type = mkOption {
+          type = enum [ "mysql" "postgresql" ];
+          default = "postgresql";
+          example = "mysql";
+          description = ''
+            The type of database Keycloak should connect to.
+          '';
+        };
+
+        host = mkOption {
+          type = str;
+          default = "localhost";
+          description = ''
+            Hostname of the database to connect to.
+          '';
+        };
+
+        port =
+          let
+            dbPorts = {
+              postgresql = 5432;
+              mysql = 3306;
+            };
+          in
+          mkOption {
+            type = port;
+            default = dbPorts.${cfg.database.type};
+            defaultText = literalDocBook "default port of selected database";
+            description = ''
+              Port of the database to connect to.
+            '';
+          };
+
+        useSSL = mkOption {
+          type = bool;
+          default = cfg.database.host != "localhost";
+          defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
+          description = ''
+            Whether the database connection should be secured by SSL /
+            TLS.
+          '';
+        };
+
+        caCert = mkOption {
+          type = nullOr path;
+          default = null;
+          description = ''
+            The SSL / TLS CA certificate that verifies the identity of the
+            database server.
+
+            Required when PostgreSQL is used and SSL is turned on.
+
+            For MySQL, if left at <literal>null</literal>, the default
+            Java keystore is used, which should suffice if the server
+            certificate is issued by an official CA.
+          '';
+        };
+
+        createLocally = mkOption {
+          type = bool;
+          default = true;
+          description = ''
+            Whether a database should be automatically created on the
+            local host. Set this to false if you plan on provisioning a
+            local database yourself. This has no effect if
+            services.keycloak.database.host is customized.
+          '';
+        };
+
+        username = mkOption {
+          type = str;
+          default = "keycloak";
+          description = ''
+            Username to use when connecting to an external or manually
+            provisioned database; has no effect when a local database is
+            automatically provisioned.
+
+            To use this with a local database, set <xref
+            linkend="opt-services.keycloak.database.createLocally" /> to
+            <literal>false</literal> and create the database and user
+            manually. The database should be called
+            <literal>keycloak</literal>.
+          '';
+        };
+
+        passwordFile = mkOption {
+          type = path;
+          example = "/run/keys/db_password";
+          description = ''
+            File containing the database password.
+
+            This should be a string, not a Nix path, since Nix paths are
+            copied into the world-readable Nix store.
+          '';
+        };
+      };
+
+      package = mkOption {
+        type = package;
+        default = pkgs.keycloak;
+        defaultText = literalExpression "pkgs.keycloak";
+        description = ''
+          Keycloak package to use.
+        '';
+      };
+
+      initialAdminPassword = mkOption {
+        type = str;
+        default = "changeme";
+        description = ''
+          Initial password set for the <literal>admin</literal>
+          user. The password is not stored safely and should be changed
+          immediately in the admin panel.
+        '';
+      };
+
+      themes = mkOption {
+        type = attrsOf package;
+        default = { };
+        description = ''
+          Additional theme packages for Keycloak. Each theme is linked into
+          subdirectory with a corresponding attribute name.
+
+          Theme packages consist of several subdirectories which provide
+          different theme types: for example, <literal>account</literal>,
+          <literal>login</literal> etc. After adding a theme to this option you
+          can select it by its name in Keycloak administration console.
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = attrsOf anything;
+        default = { };
+        example = literalExpression ''
+          {
+            "subsystem=keycloak-server" = {
+              "spi=hostname" = {
+                "provider=default" = null;
+                "provider=fixed" = {
+                  enabled = true;
+                  properties.hostname = "keycloak.example.com";
+                };
+                default-provider = "fixed";
+              };
+            };
+          }
+        '';
+        description = ''
+          Additional Keycloak configuration options to set in
+          <literal>standalone.xml</literal>.
+
+          Options are expressed as a Nix attribute set which matches the
+          structure of the jboss-cli configuration. The configuration is
+          effectively overlayed on top of the default configuration
+          shipped with Keycloak. To remove existing nodes and undefine
+          attributes from the default configuration, set them to
+          <literal>null</literal>.
+
+          The example configuration does the equivalent of the following
+          script, which removes the hostname provider
+          <literal>default</literal>, adds the deprecated hostname
+          provider <literal>fixed</literal> and defines it the default:
+
+          <programlisting>
+          /subsystem=keycloak-server/spi=hostname/provider=default:remove()
+          /subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" })
+          /subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed")
+          </programlisting>
+
+          You can discover available options by using the <link
+          xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link>
+          program and by referring to the <link
+          xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak
+          Server Installation and Configuration Guide</link>.
+        '';
+      };
+
+    };
+
+  config =
+    let
+      # We only want to create a database if we're actually going to connect to it.
+      databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "localhost";
+      createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
+      createLocalMySQL = databaseActuallyCreateLocally && cfg.database.type == "mysql";
+
+      mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
+        ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
+      '';
+
+      # Both theme and theme type directories need to be actual directories in one hierarchy to pass Keycloak checks.
+      themesBundle = pkgs.runCommand "keycloak-themes" { } ''
+        linkTheme() {
+          theme="$1"
+          name="$2"
+
+          mkdir "$out/$name"
+          for typeDir in "$theme"/*; do
+            if [ -d "$typeDir" ]; then
+              type="$(basename "$typeDir")"
+              mkdir "$out/$name/$type"
+              for file in "$typeDir"/*; do
+                ln -sn "$file" "$out/$name/$type/$(basename "$file")"
+              done
+            fi
+          done
+        }
+
+        mkdir -p "$out"
+        for theme in ${cfg.package}/themes/*; do
+          if [ -d "$theme" ]; then
+            linkTheme "$theme" "$(basename "$theme")"
+          fi
+        done
+
+        ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
+      '';
+
+      keycloakConfig' = foldl' recursiveUpdate
+        {
+          "interface=public".inet-address = cfg.bindAddress;
+          "socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort;
+          "subsystem=keycloak-server" = {
+            "spi=hostname"."provider=default" = {
+              enabled = true;
+              properties = {
+                inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl;
+              };
+            };
+            "theme=defaults".dir = toString themesBundle;
+          };
+          "subsystem=datasources"."data-source=KeycloakDS" = {
+            max-pool-size = "20";
+            user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
+            password = "@db-password@";
+          };
+        } [
+        (optionalAttrs (cfg.database.type == "postgresql") {
+          "subsystem=datasources" = {
+            "jdbc-driver=postgresql" = {
+              driver-module-name = "org.postgresql";
+              driver-name = "postgresql";
+              driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource";
+            };
+            "data-source=KeycloakDS" = {
+              connection-url = "jdbc:postgresql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
+              driver-name = "postgresql";
+              "connection-properties=ssl".value = boolToString cfg.database.useSSL;
+            } // (optionalAttrs (cfg.database.caCert != null) {
+              "connection-properties=sslrootcert".value = cfg.database.caCert;
+              "connection-properties=sslmode".value = "verify-ca";
+            });
+          };
+        })
+        (optionalAttrs (cfg.database.type == "mysql") {
+          "subsystem=datasources" = {
+            "jdbc-driver=mysql" = {
+              driver-module-name = "com.mysql";
+              driver-name = "mysql";
+              driver-class-name = "com.mysql.jdbc.Driver";
+            };
+            "data-source=KeycloakDS" = {
+              connection-url = "jdbc:mysql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
+              driver-name = "mysql";
+              "connection-properties=useSSL".value = boolToString cfg.database.useSSL;
+              "connection-properties=requireSSL".value = boolToString cfg.database.useSSL;
+              "connection-properties=verifyServerCertificate".value = boolToString cfg.database.useSSL;
+              "connection-properties=characterEncoding".value = "UTF-8";
+              valid-connection-checker-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLValidConnectionChecker";
+              validate-on-match = true;
+              exception-sorter-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter";
+            } // (optionalAttrs (cfg.database.caCert != null) {
+              "connection-properties=trustCertificateKeyStoreUrl".value = "file:${mySqlCaKeystore}";
+              "connection-properties=trustCertificateKeyStorePassword".value = "notsosecretpassword";
+            });
+          };
+        })
+        (optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
+          "socket-binding-group=standard-sockets"."socket-binding=https".port = cfg.httpsPort;
+          "subsystem=elytron" = mkOrder 900 {
+            "key-store=httpsKS" = mkOrder 900 {
+              path = "/run/keycloak/ssl/certificate_private_key_bundle.p12";
+              credential-reference.clear-text = "notsosecretpassword";
+              type = "JKS";
+            };
+            "key-manager=httpsKM" = mkOrder 901 {
+              key-store = "httpsKS";
+              credential-reference.clear-text = "notsosecretpassword";
+            };
+            "server-ssl-context=httpsSSC" = mkOrder 902 {
+              key-manager = "httpsKM";
+            };
+          };
+          "subsystem=undertow" = mkOrder 901 {
+            "server=default-server"."https-listener=https".ssl-context = "httpsSSC";
+          };
+        })
+        cfg.extraConfig
+      ];
+
+
+      /* Produces a JBoss CLI script that creates paths and sets
+         attributes matching those described by `attrs`. When the
+         script is run, the existing settings are effectively overlayed
+         by those from `attrs`. Existing attributes can be unset by
+         defining them `null`.
+
+         JBoss paths and attributes / maps are distinguished by their
+         name, where paths follow a `key=value` scheme.
+
+         Example:
+           mkJbossScript {
+             "subsystem=keycloak-server"."spi=hostname" = {
+               "provider=fixed" = null;
+               "provider=default" = {
+                 enabled = true;
+                 properties = {
+                   inherit frontendUrl;
+                   forceBackendUrlToFrontendUrl = false;
+                 };
+               };
+             };
+           }
+           => ''
+             if (outcome != success) of /:read-resource()
+                 /:add()
+             end-if
+             if (outcome != success) of /subsystem=keycloak-server:read-resource()
+                 /subsystem=keycloak-server:add()
+             end-if
+             if (outcome != success) of /subsystem=keycloak-server/spi=hostname:read-resource()
+                 /subsystem=keycloak-server/spi=hostname:add()
+             end-if
+             if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=default:read-resource()
+                 /subsystem=keycloak-server/spi=hostname/provider=default:add(enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" })
+             end-if
+             if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled")
+               /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true)
+             end-if
+             if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl")
+               /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false)
+             end-if
+             if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl")
+               /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth")
+             end-if
+             if (outcome != success) of /subsystem=keycloak-server/spi=hostname/provider=fixed:read-resource()
+                 /subsystem=keycloak-server/spi=hostname/provider=fixed:remove()
+             end-if
+           ''
+      */
+      mkJbossScript = attrs:
+        let
+          /* From a JBoss path and an attrset, produces a JBoss CLI
+             snippet that writes the corresponding attributes starting
+             at `path`. Recurses down into subattrsets as necessary,
+             producing the variable name from its full path in the
+             attrset.
+
+             Example:
+               writeAttributes "/subsystem=keycloak-server/spi=hostname/provider=default" {
+                 enabled = true;
+                 properties = {
+                   forceBackendUrlToFrontendUrl = false;
+                   frontendUrl = "https://keycloak.example.com/auth";
+                 };
+               }
+               => ''
+                 if (result != true) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="enabled")
+                   /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=enabled, value=true)
+                 end-if
+                 if (result != false) of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.forceBackendUrlToFrontendUrl")
+                   /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.forceBackendUrlToFrontendUrl, value=false)
+                 end-if
+                 if (result != "https://keycloak.example.com/auth") of /subsystem=keycloak-server/spi=hostname/provider=default:read-attribute(name="properties.frontendUrl")
+                   /subsystem=keycloak-server/spi=hostname/provider=default:write-attribute(name=properties.frontendUrl, value="https://keycloak.example.com/auth")
+                 end-if
+               ''
+          */
+          writeAttributes = path: set:
+            let
+              # JBoss expressions like `${var}` need to be prefixed
+              # with `expression` to evaluate.
+              prefixExpression = string:
+                let
+                  matchResult = match ''"\$\{.*}"'' string;
+                in
+                if matchResult != null then
+                  "expression " + string
+                else
+                  string;
+
+              writeAttribute = attribute: value:
+                let
+                  type = typeOf value;
+                in
+                if type == "set" then
+                  let
+                    names = attrNames value;
+                  in
+                  foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names
+                else if value == null then ''
+                  if (outcome == success) of ${path}:read-attribute(name="${attribute}")
+                      ${path}:undefine-attribute(name="${attribute}")
+                  end-if
+                ''
+                else if elem type [ "string" "path" "bool" ] then
+                  let
+                    value' = if type == "bool" then boolToString value else ''"${value}"'';
+                  in
+                  ''
+                    if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}")
+                      ${path}:write-attribute(name=${attribute}, value=${value'})
+                    end-if
+                  ''
+                else throw "Unsupported type '${type}' for path '${path}'!";
+            in
+            concatStrings
+              (mapAttrsToList
+                (attribute: value: (writeAttribute attribute value))
+                set);
+
+
+          /* Produces an argument list for the JBoss `add()` function,
+             which adds a JBoss path and takes as its arguments the
+             required subpaths and attributes.
+
+             Example:
+               makeArgList {
+                 enabled = true;
+                 properties = {
+                   forceBackendUrlToFrontendUrl = false;
+                   frontendUrl = "https://keycloak.example.com/auth";
+                 };
+               }
+               => ''
+                 enabled = true, properties = { forceBackendUrlToFrontendUrl = false, frontendUrl = "https://keycloak.example.com/auth" }
+               ''
+          */
+          makeArgList = set:
+            let
+              makeArg = attribute: value:
+                let
+                  type = typeOf value;
+                in
+                if type == "set" then
+                  "${attribute} = { " + (makeArgList value) + " }"
+                else if elem type [ "string" "path" "bool" ] then
+                  "${attribute} = ${if type == "bool" then boolToString value else ''"${value}"''}"
+                else if value == null then
+                  ""
+                else
+                  throw "Unsupported type '${type}' for attribute '${attribute}'!";
+
+            in
+            concatStringsSep ", " (mapAttrsToList makeArg set);
+
+
+          /* Recurses into the `nodeValue` attrset. Only subattrsets that
+             are JBoss paths, i.e. follows the `key=value` format, are recursed
+             into - the rest are considered JBoss attributes / maps.
+          */
+          recurse = nodePath: nodeValue:
+            let
+              nodeContent =
+                if isAttrs nodeValue && nodeValue._type or "" == "order" then
+                  nodeValue.content
+                else
+                  nodeValue;
+              isPath = name:
+                let
+                  value = nodeContent.${name};
+                in
+                if (match ".*([=]).*" name) == [ "=" ] then
+                  if isAttrs value || value == null then
+                    true
+                  else
+                    throw "Parsing path '${concatStringsSep "." (nodePath ++ [ name ])}' failed: JBoss attributes cannot contain '='!"
+                else
+                  false;
+              jbossPath = "/" + concatStringsSep "/" nodePath;
+              children = if !isAttrs nodeContent then { } else nodeContent;
+              subPaths = filter isPath (attrNames children);
+              getPriority = name:
+                let
+                  value = children.${name};
+                in
+                if value._type or "" == "order" then value.priority else 1000;
+              orderedSubPaths = sort (a: b: getPriority a < getPriority b) subPaths;
+              jbossAttrs = filterAttrs (name: _: !(isPath name)) children;
+              text =
+                if nodeContent != null then
+                  ''
+                    if (outcome != success) of ${jbossPath}:read-resource()
+                        ${jbossPath}:add(${makeArgList jbossAttrs})
+                    end-if
+                  '' + writeAttributes jbossPath jbossAttrs
+                else
+                  ''
+                    if (outcome == success) of ${jbossPath}:read-resource()
+                        ${jbossPath}:remove()
+                    end-if
+                  '';
+            in
+            text + concatMapStringsSep "\n" (name: recurse (nodePath ++ [ name ]) children.${name}) orderedSubPaths;
+        in
+        recurse [ ] attrs;
+
+      jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig');
+
+      keycloakConfig = pkgs.runCommand "keycloak-config"
+        {
+          nativeBuildInputs = [ cfg.package ];
+        }
+        ''
+          export JBOSS_BASE_DIR="$(pwd -P)";
+          export JBOSS_MODULEPATH="${cfg.package}/modules";
+          export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log";
+
+          cp -r ${cfg.package}/standalone/configuration .
+          chmod -R u+rwX ./configuration
+
+          mkdir -p {deployments,ssl}
+
+          standalone.sh&
+
+          attempt=1
+          max_attempts=30
+          while ! jboss-cli.sh --connect ':read-attribute(name=server-state)'; do
+              if [[ "$attempt" == "$max_attempts" ]]; then
+                  echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2
+                  exit 1
+              fi
+              echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)"
+              sleep 1
+              (( attempt++ ))
+          done
+
+          jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
+
+          cp configuration/standalone.xml $out
+        '';
+    in
+    mkIf cfg.enable
+      {
+        assertions = [
+          {
+            assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
+            message = "A CA certificate must be specified (in 'services.keycloak.database.caCert') when PostgreSQL is used with SSL";
+          }
+        ];
+
+        environment.systemPackages = [ cfg.package ];
+
+        systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
+          after = [ "postgresql.service" ];
+          before = [ "keycloak.service" ];
+          bindsTo = [ "postgresql.service" ];
+          path = [ config.services.postgresql.package ];
+          serviceConfig = {
+            Type = "oneshot";
+            RemainAfterExit = true;
+            User = "postgres";
+            Group = "postgres";
+            LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
+          };
+          script = ''
+            set -o errexit -o pipefail -o nounset -o errtrace
+            shopt -s inherit_errexit
+
+            create_role="$(mktemp)"
+            trap 'rm -f "$create_role"' ERR EXIT
+
+            db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
+            echo "CREATE ROLE keycloak WITH LOGIN PASSWORD '$db_password' CREATEDB" > "$create_role"
+            psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='keycloak'" | grep -q 1 || psql -tA --file="$create_role"
+            psql -tAc "SELECT 1 FROM pg_database WHERE datname = 'keycloak'" | grep -q 1 || psql -tAc 'CREATE DATABASE "keycloak" OWNER "keycloak"'
+          '';
+        };
+
+        systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
+          after = [ "mysql.service" ];
+          before = [ "keycloak.service" ];
+          bindsTo = [ "mysql.service" ];
+          path = [ config.services.mysql.package ];
+          serviceConfig = {
+            Type = "oneshot";
+            RemainAfterExit = true;
+            User = config.services.mysql.user;
+            Group = config.services.mysql.group;
+            LoadCredential = [ "db_password:${cfg.database.passwordFile}" ];
+          };
+          script = ''
+            set -o errexit -o pipefail -o nounset -o errtrace
+            shopt -s inherit_errexit
+            db_password="$(<"$CREDENTIALS_DIRECTORY/db_password")"
+            ( echo "CREATE USER IF NOT EXISTS 'keycloak'@'localhost' IDENTIFIED BY '$db_password';"
+              echo "CREATE DATABASE IF NOT EXISTS keycloak CHARACTER SET utf8 COLLATE utf8_unicode_ci;"
+              echo "GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'localhost';"
+            ) | mysql -N
+          '';
+        };
+
+        systemd.services.keycloak =
+          let
+            databaseServices =
+              if createLocalPostgreSQL then [
+                "keycloakPostgreSQLInit.service"
+                "postgresql.service"
+              ]
+              else if createLocalMySQL then [
+                "keycloakMySQLInit.service"
+                "mysql.service"
+              ]
+              else [ ];
+          in
+          {
+            after = databaseServices;
+            bindsTo = databaseServices;
+            wantedBy = [ "multi-user.target" ];
+            path = with pkgs; [
+              cfg.package
+              openssl
+              replace-secret
+            ];
+            environment = {
+              JBOSS_LOG_DIR = "/var/log/keycloak";
+              JBOSS_BASE_DIR = "/run/keycloak";
+              JBOSS_MODULEPATH = "${cfg.package}/modules";
+            };
+            serviceConfig = {
+              LoadCredential = [
+                "db_password:${cfg.database.passwordFile}"
+              ] ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
+                "ssl_cert:${cfg.sslCertificate}"
+                "ssl_key:${cfg.sslCertificateKey}"
+              ];
+              User = "keycloak";
+              Group = "keycloak";
+              DynamicUser = true;
+              RuntimeDirectory = map (p: "keycloak/" + p) [
+                "configuration"
+                "deployments"
+                "data"
+                "ssl"
+                "log"
+                "tmp"
+              ];
+              RuntimeDirectoryMode = 0700;
+              LogsDirectory = "keycloak";
+              AmbientCapabilities = "CAP_NET_BIND_SERVICE";
+            };
+            script = ''
+              set -o errexit -o pipefail -o nounset -o errtrace
+              shopt -s inherit_errexit
+
+              umask u=rwx,g=,o=
+
+              install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
+              install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
+
+              replace-secret '@db-password@' "$CREDENTIALS_DIRECTORY/db_password" /run/keycloak/configuration/standalone.xml
+
+              export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
+              add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
+            '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
+              pushd /run/keycloak/ssl/
+              cat "$CREDENTIALS_DIRECTORY/ssl_cert" <(echo) \
+                  "$CREDENTIALS_DIRECTORY/ssl_key" <(echo) \
+                  /etc/ssl/certs/ca-certificates.crt \
+                  > allcerts.pem
+              openssl pkcs12 -export -in "$CREDENTIALS_DIRECTORY/ssl_cert" -inkey "$CREDENTIALS_DIRECTORY/ssl_key" -chain \
+                             -name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \
+                             -CAfile allcerts.pem -passout pass:notsosecretpassword
+              popd
+            '' + ''
+              ${cfg.package}/bin/standalone.sh
+            '';
+          };
+
+        services.postgresql.enable = mkDefault createLocalPostgreSQL;
+        services.mysql.enable = mkDefault createLocalMySQL;
+        services.mysql.package = mkIf createLocalMySQL pkgs.mariadb;
+      };
+
+  meta.doc = ./keycloak.xml;
+  meta.maintainers = [ maintainers.talyz ];
+}
diff --git a/nixos/modules/services/web-apps/keycloak.xml b/nixos/modules/services/web-apps/keycloak.xml
new file mode 100644
index 00000000000..cb706932f48
--- /dev/null
+++ b/nixos/modules/services/web-apps/keycloak.xml
@@ -0,0 +1,222 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-keycloak">
+ <title>Keycloak</title>
+ <para>
+   <link xlink:href="https://www.keycloak.org/">Keycloak</link> is an
+   open source identity and access management server with support for
+   <link xlink:href="https://openid.net/connect/">OpenID
+   Connect</link>, <link xlink:href="https://oauth.net/2/">OAUTH
+   2.0</link> and <link
+   xlink:href="https://en.wikipedia.org/wiki/SAML_2.0">SAML
+   2.0</link>.
+ </para>
+   <section xml:id="module-services-keycloak-admin">
+     <title>Administration</title>
+     <para>
+       An administrative user with the username
+       <literal>admin</literal> is automatically created in the
+       <literal>master</literal> realm. Its initial password can be
+       configured by setting <xref linkend="opt-services.keycloak.initialAdminPassword" />
+       and defaults to <literal>changeme</literal>. The password is
+       not stored safely and should be changed immediately in the
+       admin panel.
+     </para>
+
+     <para>
+       Refer to the <link
+       xlink:href="https://www.keycloak.org/docs/latest/server_admin/index.html#admin-console">Admin
+       Console section of the Keycloak Server Administration Guide</link> for
+       information on how to administer your
+       <productname>Keycloak</productname> instance.
+     </para>
+   </section>
+
+   <section xml:id="module-services-keycloak-database">
+     <title>Database access</title>
+     <para>
+       <productname>Keycloak</productname> can be used with either
+       <productname>PostgreSQL</productname> or
+       <productname>MySQL</productname>. Which one is used can be
+       configured in <xref
+       linkend="opt-services.keycloak.database.type" />. The selected
+       database will automatically be enabled and a database and role
+       created unless <xref
+       linkend="opt-services.keycloak.database.host" /> is changed from
+       its default of <literal>localhost</literal> or <xref
+       linkend="opt-services.keycloak.database.createLocally" /> is set
+       to <literal>false</literal>.
+     </para>
+
+     <para>
+       External database access can also be configured by setting
+       <xref linkend="opt-services.keycloak.database.host" />, <xref
+       linkend="opt-services.keycloak.database.username" />, <xref
+       linkend="opt-services.keycloak.database.useSSL" /> and <xref
+       linkend="opt-services.keycloak.database.caCert" /> as
+       appropriate. Note that you need to manually create a database
+       called <literal>keycloak</literal> and allow the configured
+       database user full access to it.
+     </para>
+
+     <para>
+       <xref linkend="opt-services.keycloak.database.passwordFile" />
+       must be set to the path to a file containing the password used
+       to log in to the database. If <xref linkend="opt-services.keycloak.database.host" />
+       and <xref linkend="opt-services.keycloak.database.createLocally" />
+       are kept at their defaults, the database role
+       <literal>keycloak</literal> with that password is provisioned
+       on the local database instance.
+     </para>
+
+     <warning>
+       <para>
+         The path should be provided as a string, not a Nix path, since Nix
+         paths are copied into the world readable Nix store.
+       </para>
+     </warning>
+   </section>
+
+   <section xml:id="module-services-keycloak-frontendurl">
+     <title>Frontend URL</title>
+     <para>
+       The frontend URL is used as base for all frontend requests and
+       must be configured through <xref linkend="opt-services.keycloak.frontendUrl" />.
+       It should normally include a trailing <literal>/auth</literal>
+       (the default web context). If you use a reverse proxy, you need
+       to set this option to <literal>""</literal>, so that frontend URL
+       is derived from HTTP headers. <literal>X-Forwarded-*</literal> headers
+       support also should be enabled, using <link
+       xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html#identifying-client-ip-addresses">
+       respective guidelines</link>.
+     </para>
+
+     <para>
+       <xref linkend="opt-services.keycloak.forceBackendUrlToFrontendUrl" />
+       determines whether Keycloak should force all requests to go
+       through the frontend URL. By default,
+       <productname>Keycloak</productname> allows backend requests to
+       instead use its local hostname or IP address and may also
+       advertise it to clients through its OpenID Connect Discovery
+       endpoint.
+     </para>
+
+     <para>
+       See the <link
+       xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">Hostname
+       section of the Keycloak Server Installation and Configuration
+       Guide</link> for more information.
+     </para>
+   </section>
+
+   <section xml:id="module-services-keycloak-tls">
+     <title>Setting up TLS/SSL</title>
+     <para>
+       By default, <productname>Keycloak</productname> won't accept
+       unsecured HTTP connections originating from outside its local
+       network.
+     </para>
+
+     <para>
+       HTTPS support requires a TLS/SSL certificate and a private key,
+       both <link
+       xlink:href="https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail">PEM
+       formatted</link>. Their paths should be set through <xref
+       linkend="opt-services.keycloak.sslCertificate" /> and <xref
+       linkend="opt-services.keycloak.sslCertificateKey" />.
+     </para>
+
+     <warning>
+       <para>
+         The paths should be provided as a strings, not a Nix paths,
+         since Nix paths are copied into the world readable Nix store.
+       </para>
+     </warning>
+   </section>
+
+   <section xml:id="module-services-keycloak-themes">
+     <title>Themes</title>
+     <para>
+        You can package custom themes and make them visible to Keycloak via
+        <xref linkend="opt-services.keycloak.themes" />
+        option. See the <link xlink:href="https://www.keycloak.org/docs/latest/server_development/#_themes">
+        Themes section of the Keycloak Server Development Guide</link>
+        and respective NixOS option description for more information.
+     </para>
+   </section>
+
+   <section xml:id="module-services-keycloak-extra-config">
+     <title>Additional configuration</title>
+     <para>
+       Additional Keycloak configuration options, for which no
+       explicit <productname>NixOS</productname> options are provided,
+       can be set in <xref linkend="opt-services.keycloak.extraConfig" />.
+     </para>
+
+     <para>
+       Options are expressed as a Nix attribute set which matches the
+       structure of the jboss-cli configuration. The configuration is
+       effectively overlayed on top of the default configuration
+       shipped with Keycloak. To remove existing nodes and undefine
+       attributes from the default configuration, set them to
+       <literal>null</literal>.
+     </para>
+     <para>
+       For example, the following script, which removes the hostname
+       provider <literal>default</literal>, adds the deprecated
+       hostname provider <literal>fixed</literal> and defines it the
+       default:
+
+<programlisting>
+/subsystem=keycloak-server/spi=hostname/provider=default:remove()
+/subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" })
+/subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed")
+</programlisting>
+
+       would be expressed as
+
+<programlisting>
+services.keycloak.extraConfig = {
+  "subsystem=keycloak-server" = {
+    "spi=hostname" = {
+      "provider=default" = null;
+      "provider=fixed" = {
+        enabled = true;
+        properties.hostname = "keycloak.example.com";
+      };
+      default-provider = "fixed";
+    };
+  };
+};
+</programlisting>
+     </para>
+     <para>
+       You can discover available options by using the <link
+       xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link>
+       program and by referring to the <link
+       xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak
+       Server Installation and Configuration Guide</link>.
+     </para>
+   </section>
+
+   <section xml:id="module-services-keycloak-example-config">
+     <title>Example configuration</title>
+     <para>
+       A basic configuration with some custom settings could look like this:
+<programlisting>
+services.keycloak = {
+  <link linkend="opt-services.keycloak.enable">enable</link> = true;
+  <link linkend="opt-services.keycloak.initialAdminPassword">initialAdminPassword</link> = "e6Wcm0RrtegMEHl";  # change on first login
+  <link linkend="opt-services.keycloak.frontendUrl">frontendUrl</link> = "https://keycloak.example.com/auth";
+  <link linkend="opt-services.keycloak.forceBackendUrlToFrontendUrl">forceBackendUrlToFrontendUrl</link> = true;
+  <link linkend="opt-services.keycloak.sslCertificate">sslCertificate</link> = "/run/keys/ssl_cert";
+  <link linkend="opt-services.keycloak.sslCertificateKey">sslCertificateKey</link> = "/run/keys/ssl_key";
+  <link linkend="opt-services.keycloak.database.passwordFile">database.passwordFile</link> = "/run/keys/db_password";
+};
+</programlisting>
+     </para>
+
+   </section>
+ </chapter>
diff --git a/nixos/modules/services/web-apps/lemmy.md b/nixos/modules/services/web-apps/lemmy.md
new file mode 100644
index 00000000000..e6599cd843e
--- /dev/null
+++ b/nixos/modules/services/web-apps/lemmy.md
@@ -0,0 +1,34 @@
+# Lemmy {#module-services-lemmy}
+
+Lemmy is a federated alternative to reddit in rust.
+
+## Quickstart {#module-services-lemmy-quickstart}
+
+the minimum to start lemmy is
+
+```nix
+services.lemmy = {
+  enable = true;
+  settings = {
+    hostname = "lemmy.union.rocks";
+    database.createLocally = true;
+  };
+  jwtSecretPath = "/run/secrets/lemmyJwt";
+  caddy.enable = true;
+}
+```
+
+(note that you can use something like agenix to get your secret jwt to the specified path)
+
+this will start the backend on port 8536 and the frontend on port 1234.
+It will expose your instance with a caddy reverse proxy to the hostname you've provided.
+Postgres will be initialized on that same instance automatically.
+
+## Usage {#module-services-lemmy-usage}
+
+On first connection you will be asked to define an admin user.
+
+## Missing {#module-services-lemmy-missing}
+
+- Exposing with nginx is not implemented yet.
+- This has been tested using a local database with a unix socket connection. Using different database settings will likely require modifications
diff --git a/nixos/modules/services/web-apps/lemmy.nix b/nixos/modules/services/web-apps/lemmy.nix
new file mode 100644
index 00000000000..7cd2357c455
--- /dev/null
+++ b/nixos/modules/services/web-apps/lemmy.nix
@@ -0,0 +1,236 @@
+{ lib, pkgs, config, ... }:
+with lib;
+let
+  cfg = config.services.lemmy;
+  settingsFormat = pkgs.formats.json { };
+in
+{
+  meta.maintainers = with maintainers; [ happysalada ];
+  # Don't edit the docbook xml directly, edit the md and generate it:
+  # `pandoc lemmy.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > lemmy.xml`
+  meta.doc = ./lemmy.xml;
+
+  options.services.lemmy = {
+
+    enable = mkEnableOption "lemmy a federated alternative to reddit in rust";
+
+    jwtSecretPath = mkOption {
+      type = types.path;
+      description = "Path to read the jwt secret from.";
+    };
+
+    ui = {
+      port = mkOption {
+        type = types.port;
+        default = 1234;
+        description = "Port where lemmy-ui should listen for incoming requests.";
+      };
+    };
+
+    caddy.enable = mkEnableOption "exposing lemmy with the caddy reverse proxy";
+
+    settings = mkOption {
+      default = { };
+      description = "Lemmy configuration";
+
+      type = types.submodule {
+        freeformType = settingsFormat.type;
+
+        options.hostname = mkOption {
+          type = types.str;
+          default = null;
+          description = "The domain name of your instance (eg 'lemmy.ml').";
+        };
+
+        options.port = mkOption {
+          type = types.port;
+          default = 8536;
+          description = "Port where lemmy should listen for incoming requests.";
+        };
+
+        options.federation = {
+          enabled = mkEnableOption "activitypub federation";
+        };
+
+        options.captcha = {
+          enabled = mkOption {
+            type = types.bool;
+            default = true;
+            description = "Enable Captcha.";
+          };
+          difficulty = mkOption {
+            type = types.enum [ "easy" "medium" "hard" ];
+            default = "medium";
+            description = "The difficultly of the captcha to solve.";
+          };
+        };
+
+        options.database.createLocally = mkEnableOption "creation of database on the instance";
+
+      };
+    };
+
+  };
+
+  config =
+    let
+      localPostgres = (cfg.settings.database.host == "localhost" || cfg.settings.database.host == "/run/postgresql");
+    in
+    lib.mkIf cfg.enable {
+      services.lemmy.settings = (mapAttrs (name: mkDefault)
+        {
+          bind = "127.0.0.1";
+          tls_enabled = true;
+          pictrs_url = with config.services.pict-rs; "http://${address}:${toString port}";
+          actor_name_max_length = 20;
+
+          rate_limit.message = 180;
+          rate_limit.message_per_second = 60;
+          rate_limit.post = 6;
+          rate_limit.post_per_second = 600;
+          rate_limit.register = 3;
+          rate_limit.register_per_second = 3600;
+          rate_limit.image = 6;
+          rate_limit.image_per_second = 3600;
+        } // {
+        database = mapAttrs (name: mkDefault) {
+          user = "lemmy";
+          host = "/run/postgresql";
+          port = 5432;
+          database = "lemmy";
+          pool_size = 5;
+        };
+      });
+
+      services.postgresql = mkIf localPostgres {
+        enable = mkDefault true;
+      };
+
+      services.pict-rs.enable = true;
+
+      services.caddy = mkIf cfg.caddy.enable {
+        enable = mkDefault true;
+        virtualHosts."${cfg.settings.hostname}" = {
+          extraConfig = ''
+            handle_path /static/* {
+              root * ${pkgs.lemmy-ui}/dist
+              file_server
+            }
+            @for_backend {
+              path /api/* /pictrs/* feeds/* nodeinfo/*
+            }
+            handle @for_backend {
+              reverse_proxy 127.0.0.1:${toString cfg.settings.port}
+            }
+            @post {
+              method POST
+            }
+            handle @post {
+              reverse_proxy 127.0.0.1:${toString cfg.settings.port}
+            }
+            @jsonld {
+              header Accept "application/activity+json"
+              header Accept "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
+            }
+            handle @jsonld {
+              reverse_proxy 127.0.0.1:${toString cfg.settings.port}
+            }
+            handle {
+              reverse_proxy 127.0.0.1:${toString cfg.ui.port}
+            }
+          '';
+        };
+      };
+
+      assertions = [{
+        assertion = cfg.settings.database.createLocally -> localPostgres;
+        message = "if you want to create the database locally, you need to use a local database";
+      }];
+
+      systemd.services.lemmy = {
+        description = "Lemmy server";
+
+        environment = {
+          LEMMY_CONFIG_LOCATION = "/run/lemmy/config.hjson";
+
+          # Verify how this is used, and don't put the password in the nix store
+          LEMMY_DATABASE_URL = with cfg.settings.database;"postgres:///${database}?host=${host}";
+        };
+
+        documentation = [
+          "https://join-lemmy.org/docs/en/administration/from_scratch.html"
+          "https://join-lemmy.org/docs"
+        ];
+
+        wantedBy = [ "multi-user.target" ];
+
+        after = [ "pict-rs.service " ] ++ lib.optionals cfg.settings.database.createLocally [ "lemmy-postgresql.service" ];
+
+        requires = lib.optionals cfg.settings.database.createLocally [ "lemmy-postgresql.service" ];
+
+        # script is needed here since loadcredential is not accessible on ExecPreStart
+        script = ''
+          ${pkgs.coreutils}/bin/install -m 600 ${settingsFormat.generate "config.hjson" cfg.settings} /run/lemmy/config.hjson
+          jwtSecret="$(< $CREDENTIALS_DIRECTORY/jwt_secret )"
+          ${pkgs.jq}/bin/jq ".jwt_secret = \"$jwtSecret\"" /run/lemmy/config.hjson | ${pkgs.moreutils}/bin/sponge /run/lemmy/config.hjson
+          ${pkgs.lemmy-server}/bin/lemmy_server
+        '';
+
+        serviceConfig = {
+          DynamicUser = true;
+          RuntimeDirectory = "lemmy";
+          LoadCredential = "jwt_secret:${cfg.jwtSecretPath}";
+        };
+      };
+
+      systemd.services.lemmy-ui = {
+        description = "Lemmy ui";
+
+        environment = {
+          LEMMY_UI_HOST = "127.0.0.1:${toString cfg.ui.port}";
+          LEMMY_INTERNAL_HOST = "127.0.0.1:${toString cfg.settings.port}";
+          LEMMY_EXTERNAL_HOST = cfg.settings.hostname;
+          LEMMY_HTTPS = "false";
+        };
+
+        documentation = [
+          "https://join-lemmy.org/docs/en/administration/from_scratch.html"
+          "https://join-lemmy.org/docs"
+        ];
+
+        wantedBy = [ "multi-user.target" ];
+
+        after = [ "lemmy.service" ];
+
+        requires = [ "lemmy.service" ];
+
+        serviceConfig = {
+          DynamicUser = true;
+          WorkingDirectory = "${pkgs.lemmy-ui}";
+          ExecStart = "${pkgs.nodejs}/bin/node ${pkgs.lemmy-ui}/dist/js/server.js";
+        };
+      };
+
+      systemd.services.lemmy-postgresql = mkIf cfg.settings.database.createLocally {
+        description = "Lemmy postgresql db";
+        after = [ "postgresql.service" ];
+        partOf = [ "lemmy.service" ];
+        script = with cfg.settings.database; ''
+          PSQL() {
+            ${config.services.postgresql.package}/bin/psql --port=${toString cfg.settings.database.port} "$@"
+          }
+          # check if the database already exists
+          if ! PSQL -lqt | ${pkgs.coreutils}/bin/cut -d \| -f 1 | ${pkgs.gnugrep}/bin/grep -qw ${database} ; then
+            PSQL -tAc "CREATE ROLE ${user} WITH LOGIN;"
+            PSQL -tAc "CREATE DATABASE ${database} WITH OWNER ${user};"
+          fi
+        '';
+        serviceConfig = {
+          User = config.services.postgresql.superUser;
+          Type = "oneshot";
+          RemainAfterExit = true;
+        };
+      };
+    };
+
+}
diff --git a/nixos/modules/services/web-apps/lemmy.xml b/nixos/modules/services/web-apps/lemmy.xml
new file mode 100644
index 00000000000..0be9fb8aefa
--- /dev/null
+++ b/nixos/modules/services/web-apps/lemmy.xml
@@ -0,0 +1,56 @@
+<chapter xmlns="http://docbook.org/ns/docbook" xmlns:xlink="http://www.w3.org/1999/xlink" xml:id="module-services-lemmy">
+  <title>Lemmy</title>
+  <para>
+    Lemmy is a federated alternative to reddit in rust.
+  </para>
+  <section xml:id="module-services-lemmy-quickstart">
+    <title>Quickstart</title>
+    <para>
+      the minimum to start lemmy is
+    </para>
+    <programlisting language="bash">
+services.lemmy = {
+  enable = true;
+  settings = {
+    hostname = &quot;lemmy.union.rocks&quot;;
+    database.createLocally = true;
+  };
+  jwtSecretPath = &quot;/run/secrets/lemmyJwt&quot;;
+  caddy.enable = true;
+}
+</programlisting>
+    <para>
+      (note that you can use something like agenix to get your secret
+      jwt to the specified path)
+    </para>
+    <para>
+      this will start the backend on port 8536 and the frontend on port
+      1234. It will expose your instance with a caddy reverse proxy to
+      the hostname you’ve provided. Postgres will be initialized on that
+      same instance automatically.
+    </para>
+  </section>
+  <section xml:id="module-services-lemmy-usage">
+    <title>Usage</title>
+    <para>
+      On first connection you will be asked to define an admin user.
+    </para>
+  </section>
+  <section xml:id="module-services-lemmy-missing">
+    <title>Missing</title>
+    <itemizedlist spacing="compact">
+      <listitem>
+        <para>
+          Exposing with nginx is not implemented yet.
+        </para>
+      </listitem>
+      <listitem>
+        <para>
+          This has been tested using a local database with a unix socket
+          connection. Using different database settings will likely
+          require modifications
+        </para>
+      </listitem>
+    </itemizedlist>
+  </section>
+</chapter>
diff --git a/nixos/modules/services/web-apps/limesurvey.nix b/nixos/modules/services/web-apps/limesurvey.nix
new file mode 100644
index 00000000000..5ccd742a303
--- /dev/null
+++ b/nixos/modules/services/web-apps/limesurvey.nix
@@ -0,0 +1,280 @@
+{ config, lib, pkgs, ... }:
+
+let
+
+  inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption;
+  inherit (lib) literalExpression mapAttrs optional optionalString types;
+
+  cfg = config.services.limesurvey;
+  fpm = config.services.phpfpm.pools.limesurvey;
+
+  user = "limesurvey";
+  group = config.services.httpd.group;
+  stateDir = "/var/lib/limesurvey";
+
+  pkg = pkgs.limesurvey;
+
+  configType = with types; oneOf [ (attrsOf configType) str int bool ] // {
+    description = "limesurvey config type (str, int, bool or attribute set thereof)";
+  };
+
+  limesurveyConfig = pkgs.writeText "config.php" ''
+    <?php
+      return json_decode('${builtins.toJSON cfg.config}', true);
+    ?>
+  '';
+
+  mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql";
+  pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql";
+
+in
+{
+  # interface
+
+  options.services.limesurvey = {
+    enable = mkEnableOption "Limesurvey web application.";
+
+    database = {
+      type = mkOption {
+        type = types.enum [ "mysql" "pgsql" "odbc" "mssql" ];
+        example = "pgsql";
+        default = "mysql";
+        description = "Database engine to use.";
+      };
+
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = "Database host address.";
+      };
+
+      port = mkOption {
+        type = types.int;
+        default = if cfg.database.type == "pgsql" then 5442 else 3306;
+        defaultText = literalExpression "3306";
+        description = "Database host port.";
+      };
+
+      name = mkOption {
+        type = types.str;
+        default = "limesurvey";
+        description = "Database name.";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "limesurvey";
+        description = "Database user.";
+      };
+
+      passwordFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        example = "/run/keys/limesurvey-dbpassword";
+        description = ''
+          A file containing the password corresponding to
+          <option>database.user</option>.
+        '';
+      };
+
+      socket = mkOption {
+        type = types.nullOr types.path;
+        default =
+          if mysqlLocal then "/run/mysqld/mysqld.sock"
+          else if pgsqlLocal then "/run/postgresql"
+          else null
+        ;
+        defaultText = literalExpression "/run/mysqld/mysqld.sock";
+        description = "Path to the unix socket file to use for authentication.";
+      };
+
+      createLocally = mkOption {
+        type = types.bool;
+        default = cfg.database.type == "mysql";
+        defaultText = literalExpression "true";
+        description = ''
+          Create the database and database user locally.
+          This currently only applies if database type "mysql" is selected.
+        '';
+      };
+    };
+
+    virtualHost = mkOption {
+      type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
+      example = literalExpression ''
+        {
+          hostName = "survey.example.org";
+          adminAddr = "webmaster@example.org";
+          forceSSL = true;
+          enableACME = true;
+        }
+      '';
+      description = ''
+        Apache configuration can be done by adapting <literal>services.httpd.virtualHosts.&lt;name&gt;</literal>.
+        See <xref linkend="opt-services.httpd.virtualHosts"/> 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 LimeSurvey PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+        for details on configuration directives.
+      '';
+    };
+
+    config = mkOption {
+      type = configType;
+      default = {};
+      description = ''
+        LimeSurvey configuration. Refer to
+        <link xlink:href="https://manual.limesurvey.org/Optional_settings"/>
+        for details on supported values.
+      '';
+    };
+  };
+
+  # implementation
+
+  config = mkIf cfg.enable {
+
+    assertions = [
+      { assertion = cfg.database.createLocally -> cfg.database.type == "mysql";
+        message = "services.limesurvey.createLocally is currently only supported for database type 'mysql'";
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.user == user;
+        message = "services.limesurvey.database.user must be set to ${user} if services.limesurvey.database.createLocally is set true";
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.socket != null;
+        message = "services.limesurvey.database.socket must be set if services.limesurvey.database.createLocally is set to true";
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
+        message = "a password cannot be specified if services.limesurvey.database.createLocally is set to true";
+      }
+    ];
+
+    services.limesurvey.config = mapAttrs (name: mkDefault) {
+      runtimePath = "${stateDir}/tmp/runtime";
+      components = {
+        db = {
+          connectionString = "${cfg.database.type}:dbname=${cfg.database.name};host=${if pgsqlLocal then cfg.database.socket else cfg.database.host};port=${toString cfg.database.port}" +
+            optionalString mysqlLocal ";socket=${cfg.database.socket}";
+          username = cfg.database.user;
+          password = mkIf (cfg.database.passwordFile != null) "file_get_contents(\"${toString cfg.database.passwordFile}\");";
+          tablePrefix = "limesurvey_";
+        };
+        assetManager.basePath = "${stateDir}/tmp/assets";
+        urlManager = {
+          urlFormat = "path";
+          showScriptName = false;
+        };
+      };
+      config = {
+        tempdir = "${stateDir}/tmp";
+        uploaddir = "${stateDir}/upload";
+        force_ssl = mkIf (cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL) "on";
+        config.defaultlang = "en";
+      };
+    };
+
+    services.mysql = mkIf mysqlLocal {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        { name = cfg.database.user;
+          ensurePermissions = {
+            "${cfg.database.name}.*" = "SELECT, CREATE, INSERT, UPDATE, DELETE, ALTER, DROP, INDEX";
+          };
+        }
+      ];
+    };
+
+    services.phpfpm.pools.limesurvey = {
+      inherit user group;
+      phpEnv.LIMESURVEY_CONFIG = "${limesurveyConfig}";
+      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.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost {
+        documentRoot = mkForce "${pkg}/share/limesurvey";
+        extraConfig = ''
+          Alias "/tmp" "${stateDir}/tmp"
+          <Directory "${stateDir}">
+            AllowOverride all
+            Require all granted
+            Options -Indexes +FollowSymlinks
+          </Directory>
+
+          Alias "/upload" "${stateDir}/upload"
+          <Directory "${stateDir}/upload">
+            AllowOverride all
+            Require all granted
+            Options -Indexes
+          </Directory>
+
+          <Directory "${pkg}/share/limesurvey">
+            <FilesMatch "\.php$">
+              <If "-f %{REQUEST_FILENAME}">
+                SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
+              </If>
+            </FilesMatch>
+
+            AllowOverride all
+            Options -Indexes
+            DirectoryIndex index.php
+          </Directory>
+        '';
+      } ];
+    };
+
+    systemd.tmpfiles.rules = [
+      "d ${stateDir} 0750 ${user} ${group} - -"
+      "d ${stateDir}/tmp 0750 ${user} ${group} - -"
+      "d ${stateDir}/tmp/assets 0750 ${user} ${group} - -"
+      "d ${stateDir}/tmp/runtime 0750 ${user} ${group} - -"
+      "d ${stateDir}/tmp/upload 0750 ${user} ${group} - -"
+      "C ${stateDir}/upload 0750 ${user} ${group} - ${pkg}/share/limesurvey/upload"
+    ];
+
+    systemd.services.limesurvey-init = {
+      wantedBy = [ "multi-user.target" ];
+      before = [ "phpfpm-limesurvey.service" ];
+      after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
+      environment.LIMESURVEY_CONFIG = limesurveyConfig;
+      script = ''
+        # update or install the database as required
+        ${pkgs.php}/bin/php ${pkg}/share/limesurvey/application/commands/console.php updatedb || \
+        ${pkgs.php}/bin/php ${pkg}/share/limesurvey/application/commands/console.php install admin password admin admin@example.com verbose
+      '';
+      serviceConfig = {
+        User = user;
+        Group = group;
+        Type = "oneshot";
+      };
+    };
+
+    systemd.services.httpd.after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
+
+    users.users.${user} = {
+      group = group;
+      isSystemUser = true;
+    };
+
+  };
+}
diff --git a/nixos/modules/services/web-apps/mastodon.nix b/nixos/modules/services/web-apps/mastodon.nix
new file mode 100644
index 00000000000..8208c85bfd7
--- /dev/null
+++ b/nixos/modules/services/web-apps/mastodon.nix
@@ -0,0 +1,636 @@
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.mastodon;
+  # We only want to create a database if we're actually going to connect to it.
+  databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "/run/postgresql";
+
+  env = {
+    RAILS_ENV = "production";
+    NODE_ENV = "production";
+
+    # mastodon-web concurrency.
+    WEB_CONCURRENCY = toString cfg.webProcesses;
+    MAX_THREADS = toString cfg.webThreads;
+
+    # mastodon-streaming concurrency.
+    STREAMING_CLUSTER_NUM = toString cfg.streamingProcesses;
+
+    DB_USER = cfg.database.user;
+
+    REDIS_HOST = cfg.redis.host;
+    REDIS_PORT = toString(cfg.redis.port);
+    DB_HOST = cfg.database.host;
+    DB_PORT = toString(cfg.database.port);
+    DB_NAME = cfg.database.name;
+    LOCAL_DOMAIN = cfg.localDomain;
+    SMTP_SERVER = cfg.smtp.host;
+    SMTP_PORT = toString(cfg.smtp.port);
+    SMTP_FROM_ADDRESS = cfg.smtp.fromAddress;
+    PAPERCLIP_ROOT_PATH = "/var/lib/mastodon/public-system";
+    PAPERCLIP_ROOT_URL = "/system";
+    ES_ENABLED = if (cfg.elasticsearch.host != null) then "true" else "false";
+    ES_HOST = cfg.elasticsearch.host;
+    ES_PORT = toString(cfg.elasticsearch.port);
+
+    TRUSTED_PROXY_IP = cfg.trustedProxy;
+  }
+  // (if cfg.smtp.authenticate then { SMTP_LOGIN  = cfg.smtp.user; } else {})
+  // cfg.extraConfig;
+
+  systemCallsList = [ "@cpu-emulation" "@debug" "@keyring" "@ipc" "@mount" "@obsolete" "@privileged" "@setuid" ];
+
+  cfgService = {
+    # User and group
+    User = cfg.user;
+    Group = cfg.group;
+    # State directory and mode
+    StateDirectory = "mastodon";
+    StateDirectoryMode = "0750";
+    # Logs directory and mode
+    LogsDirectory = "mastodon";
+    LogsDirectoryMode = "0750";
+    # Proc filesystem
+    ProcSubset = "pid";
+    ProtectProc = "invisible";
+    # Access write directories
+    UMask = "0027";
+    # Capabilities
+    CapabilityBoundingSet = "";
+    # Security
+    NoNewPrivileges = true;
+    # Sandboxing
+    ProtectSystem = "strict";
+    ProtectHome = true;
+    PrivateTmp = true;
+    PrivateDevices = true;
+    PrivateUsers = true;
+    ProtectClock = true;
+    ProtectHostname = true;
+    ProtectKernelLogs = true;
+    ProtectKernelModules = true;
+    ProtectKernelTunables = true;
+    ProtectControlGroups = true;
+    RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
+    RestrictNamespaces = true;
+    LockPersonality = true;
+    MemoryDenyWriteExecute = false;
+    RestrictRealtime = true;
+    RestrictSUIDSGID = true;
+    RemoveIPC = true;
+    PrivateMounts = true;
+    # System Call Filtering
+    SystemCallArchitectures = "native";
+  };
+
+  envFile = pkgs.writeText "mastodon.env" (lib.concatMapStrings (s: s + "\n") (
+    (lib.concatLists (lib.mapAttrsToList (name: value:
+      if value != null then [
+        "${name}=\"${toString value}\""
+      ] else []
+    ) env))));
+
+  mastodonEnv = pkgs.writeShellScriptBin "mastodon-env" ''
+    set -a
+    export RAILS_ROOT="${cfg.package}"
+    source "${envFile}"
+    source /var/lib/mastodon/.secrets_env
+    eval -- "\$@"
+  '';
+
+in {
+
+  options = {
+    services.mastodon = {
+      enable = lib.mkEnableOption "Mastodon, a federated social network server";
+
+      configureNginx = lib.mkOption {
+        description = ''
+          Configure nginx as a reverse proxy for mastodon.
+          Note that this makes some assumptions on your setup, and sets settings that will
+          affect other virtualHosts running on your nginx instance, if any.
+          Alternatively you can configure a reverse-proxy of your choice to serve these paths:
+
+          <code>/ -> $(nix-instantiate --eval '&lt;nixpkgs&gt;' -A mastodon.outPath)/public</code>
+
+          <code>/ -> 127.0.0.1:{{ webPort }} </code>(If there was no file in the directory above.)
+
+          <code>/system/ -> /var/lib/mastodon/public-system/</code>
+
+          <code>/api/v1/streaming/ -> 127.0.0.1:{{ streamingPort }}</code>
+
+          Make sure that websockets are forwarded properly. You might want to set up caching
+          of some requests. Take a look at mastodon's provided nginx configuration at
+          <code>https://github.com/tootsuite/mastodon/blob/master/dist/nginx.conf</code>.
+        '';
+        type = lib.types.bool;
+        default = false;
+      };
+
+      user = lib.mkOption {
+        description = ''
+          User under which mastodon runs. If it is set to "mastodon",
+          that user will be created, otherwise it should be set to the
+          name of a user created elsewhere.  In both cases,
+          <package>mastodon</package> and a package containing only
+          the shell script <code>mastodon-env</code> will be added to
+          the user's package set. To run a command from
+          <package>mastodon</package> such as <code>tootctl</code>
+          with the environment configured by this module use
+          <code>mastodon-env</code>, as in:
+
+          <code>mastodon-env tootctl accounts create newuser --email newuser@example.com</code>
+        '';
+        type = lib.types.str;
+        default = "mastodon";
+      };
+
+      group = lib.mkOption {
+        description = ''
+          Group under which mastodon runs.
+        '';
+        type = lib.types.str;
+        default = "mastodon";
+      };
+
+      streamingPort = lib.mkOption {
+        description = "TCP port used by the mastodon-streaming service.";
+        type = lib.types.port;
+        default = 55000;
+      };
+      streamingProcesses = lib.mkOption {
+        description = ''
+          Processes used by the mastodon-streaming service.
+          Defaults to the number of CPU cores minus one.
+        '';
+        type = lib.types.nullOr lib.types.int;
+        default = null;
+      };
+
+      webPort = lib.mkOption {
+        description = "TCP port used by the mastodon-web service.";
+        type = lib.types.port;
+        default = 55001;
+      };
+      webProcesses = lib.mkOption {
+        description = "Processes used by the mastodon-web service.";
+        type = lib.types.int;
+        default = 2;
+      };
+      webThreads = lib.mkOption {
+        description = "Threads per process used by the mastodon-web service.";
+        type = lib.types.int;
+        default = 5;
+      };
+
+      sidekiqPort = lib.mkOption {
+        description = "TCP port used by the mastodon-sidekiq service.";
+        type = lib.types.port;
+        default = 55002;
+      };
+      sidekiqThreads = lib.mkOption {
+        description = "Worker threads used by the mastodon-sidekiq service.";
+        type = lib.types.int;
+        default = 25;
+      };
+
+      vapidPublicKeyFile = lib.mkOption {
+        description = ''
+          Path to file containing the public key used for Web Push
+          Voluntary Application Server Identification.  A new keypair can
+          be generated by running:
+
+          <code>nix build -f '&lt;nixpkgs&gt;' mastodon; cd result; bin/rake webpush:generate_keys</code>
+
+          If <option>mastodon.vapidPrivateKeyFile</option>does not
+          exist, it and this file will be created with a new keypair.
+        '';
+        default = "/var/lib/mastodon/secrets/vapid-public-key";
+        type = lib.types.str;
+      };
+
+      localDomain = lib.mkOption {
+        description = "The domain serving your Mastodon instance.";
+        example = "social.example.org";
+        type = lib.types.str;
+      };
+
+      secretKeyBaseFile = lib.mkOption {
+        description = ''
+          Path to file containing the secret key base.
+          A new secret key base can be generated by running:
+
+          <code>nix build -f '&lt;nixpkgs&gt;' mastodon; cd result; bin/rake secret</code>
+
+          If this file does not exist, it will be created with a new secret key base.
+        '';
+        default = "/var/lib/mastodon/secrets/secret-key-base";
+        type = lib.types.str;
+      };
+
+      otpSecretFile = lib.mkOption {
+        description = ''
+          Path to file containing the OTP secret.
+          A new OTP secret can be generated by running:
+
+          <code>nix build -f '&lt;nixpkgs&gt;' mastodon; cd result; bin/rake secret</code>
+
+          If this file does not exist, it will be created with a new OTP secret.
+        '';
+        default = "/var/lib/mastodon/secrets/otp-secret";
+        type = lib.types.str;
+      };
+
+      vapidPrivateKeyFile = lib.mkOption {
+        description = ''
+          Path to file containing the private key used for Web Push
+          Voluntary Application Server Identification.  A new keypair can
+          be generated by running:
+
+          <code>nix build -f '&lt;nixpkgs&gt;' mastodon; cd result; bin/rake webpush:generate_keys</code>
+
+          If this file does not exist, it will be created with a new
+          private key.
+        '';
+        default = "/var/lib/mastodon/secrets/vapid-private-key";
+        type = lib.types.str;
+      };
+
+      trustedProxy = lib.mkOption {
+        description = ''
+          You need to set it to the IP from which your reverse proxy sends requests to Mastodon's web process,
+          otherwise Mastodon will record the reverse proxy's own IP as the IP of all requests, which would be
+          bad because IP addresses are used for important rate limits and security functions.
+        '';
+        type = lib.types.str;
+        default = "127.0.0.1";
+      };
+
+      enableUnixSocket = lib.mkOption {
+        description = ''
+          Instead of binding to an IP address like 127.0.0.1, you may bind to a Unix socket. This variable
+          is process-specific, e.g. you need different values for every process, and it works for both web (Puma)
+          processes and streaming API (Node.js) processes.
+        '';
+        type = lib.types.bool;
+        default = true;
+      };
+
+      redis = {
+        createLocally = lib.mkOption {
+          description = "Configure local Redis server for Mastodon.";
+          type = lib.types.bool;
+          default = true;
+        };
+
+        host = lib.mkOption {
+          description = "Redis host.";
+          type = lib.types.str;
+          default = "127.0.0.1";
+        };
+
+        port = lib.mkOption {
+          description = "Redis port.";
+          type = lib.types.port;
+          default = 6379;
+        };
+      };
+
+      database = {
+        createLocally = lib.mkOption {
+          description = "Configure local PostgreSQL database server for Mastodon.";
+          type = lib.types.bool;
+          default = true;
+        };
+
+        host = lib.mkOption {
+          type = lib.types.str;
+          default = "/run/postgresql";
+          example = "192.168.23.42";
+          description = "Database host address or unix socket.";
+        };
+
+        port = lib.mkOption {
+          type = lib.types.int;
+          default = 5432;
+          description = "Database host port.";
+        };
+
+        name = lib.mkOption {
+          type = lib.types.str;
+          default = "mastodon";
+          description = "Database name.";
+        };
+
+        user = lib.mkOption {
+          type = lib.types.str;
+          default = "mastodon";
+          description = "Database user.";
+        };
+
+        passwordFile = lib.mkOption {
+          type = lib.types.nullOr lib.types.path;
+          default = "/var/lib/mastodon/secrets/db-password";
+          example = "/run/keys/mastodon-db-password";
+          description = ''
+            A file containing the password corresponding to
+            <option>database.user</option>.
+          '';
+        };
+      };
+
+      smtp = {
+        createLocally = lib.mkOption {
+          description = "Configure local Postfix SMTP server for Mastodon.";
+          type = lib.types.bool;
+          default = true;
+        };
+
+        authenticate = lib.mkOption {
+          description = "Authenticate with the SMTP server using username and password.";
+          type = lib.types.bool;
+          default = false;
+        };
+
+        host = lib.mkOption {
+          description = "SMTP host used when sending emails to users.";
+          type = lib.types.str;
+          default = "127.0.0.1";
+        };
+
+        port = lib.mkOption {
+          description = "SMTP port used when sending emails to users.";
+          type = lib.types.port;
+          default = 25;
+        };
+
+        fromAddress = lib.mkOption {
+          description = ''"From" address used when sending Emails to users.'';
+          type = lib.types.str;
+        };
+
+        user = lib.mkOption {
+          description = "SMTP login name.";
+          type = lib.types.str;
+        };
+
+        passwordFile = lib.mkOption {
+          description = ''
+            Path to file containing the SMTP password.
+          '';
+          default = "/var/lib/mastodon/secrets/smtp-password";
+          example = "/run/keys/mastodon-smtp-password";
+          type = lib.types.str;
+        };
+      };
+
+      elasticsearch = {
+        host = lib.mkOption {
+          description = ''
+            Elasticsearch host.
+            If it is not null, Elasticsearch full text search will be enabled.
+          '';
+          type = lib.types.nullOr lib.types.str;
+          default = null;
+        };
+
+        port = lib.mkOption {
+          description = "Elasticsearch port.";
+          type = lib.types.port;
+          default = 9200;
+        };
+      };
+
+      package = lib.mkOption {
+        type = lib.types.package;
+        default = pkgs.mastodon;
+        defaultText = lib.literalExpression "pkgs.mastodon";
+        description = "Mastodon package to use.";
+      };
+
+      extraConfig = lib.mkOption {
+        type = lib.types.attrs;
+        default = {};
+        description = ''
+          Extra environment variables to pass to all mastodon services.
+        '';
+      };
+
+      automaticMigrations = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        description = ''
+          Do automatic database migrations.
+        '';
+      };
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = databaseActuallyCreateLocally -> (cfg.user == cfg.database.user);
+        message = ''For local automatic database provisioning (services.mastodon.database.createLocally == true) with peer authentication (services.mastodon.database.host == "/run/postgresql") to work services.mastodon.user and services.mastodon.database.user must be identical.'';
+      }
+    ];
+
+    systemd.services.mastodon-init-dirs = {
+      script = ''
+        umask 077
+
+        if ! test -f ${cfg.secretKeyBaseFile}; then
+          mkdir -p $(dirname ${cfg.secretKeyBaseFile})
+          bin/rake secret > ${cfg.secretKeyBaseFile}
+        fi
+        if ! test -f ${cfg.otpSecretFile}; then
+          mkdir -p $(dirname ${cfg.otpSecretFile})
+          bin/rake secret > ${cfg.otpSecretFile}
+        fi
+        if ! test -f ${cfg.vapidPrivateKeyFile}; then
+          mkdir -p $(dirname ${cfg.vapidPrivateKeyFile}) $(dirname ${cfg.vapidPublicKeyFile})
+          keypair=$(bin/rake webpush:generate_keys)
+          echo $keypair | grep --only-matching "Private -> [^ ]\+" | sed 's/^Private -> //' > ${cfg.vapidPrivateKeyFile}
+          echo $keypair | grep --only-matching "Public -> [^ ]\+" | sed 's/^Public -> //' > ${cfg.vapidPublicKeyFile}
+        fi
+
+        cat > /var/lib/mastodon/.secrets_env <<EOF
+        SECRET_KEY_BASE="$(cat ${cfg.secretKeyBaseFile})"
+        OTP_SECRET="$(cat ${cfg.otpSecretFile})"
+        VAPID_PRIVATE_KEY="$(cat ${cfg.vapidPrivateKeyFile})"
+        VAPID_PUBLIC_KEY="$(cat ${cfg.vapidPublicKeyFile})"
+        DB_PASS="$(cat ${cfg.database.passwordFile})"
+      '' + (if cfg.smtp.authenticate then ''
+        SMTP_PASSWORD="$(cat ${cfg.smtp.passwordFile})"
+      '' else "") + ''
+        EOF
+      '';
+      environment = env;
+      serviceConfig = {
+        Type = "oneshot";
+        WorkingDirectory = cfg.package;
+        # System Call Filtering
+        SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ])) "@chown" "pipe" "pipe2" ];
+      } // cfgService;
+
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+    };
+
+    systemd.services.mastodon-init-db = lib.mkIf cfg.automaticMigrations {
+      script = ''
+        if [ `psql ${cfg.database.name} -c \
+                "select count(*) from pg_class c \
+                join pg_namespace s on s.oid = c.relnamespace \
+                where s.nspname not in ('pg_catalog', 'pg_toast', 'information_schema') \
+                and s.nspname not like 'pg_temp%';" | sed -n 3p` -eq 0 ]; then
+          SAFETY_ASSURED=1 rails db:schema:load
+          rails db:seed
+        else
+          rails db:migrate
+        fi
+      '';
+      path = [ cfg.package pkgs.postgresql ];
+      environment = env;
+      serviceConfig = {
+        Type = "oneshot";
+        EnvironmentFile = "/var/lib/mastodon/.secrets_env";
+        WorkingDirectory = cfg.package;
+        # System Call Filtering
+        SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ])) "@chown" "pipe" "pipe2" ];
+      } // cfgService;
+      after = [ "mastodon-init-dirs.service" "network.target" ] ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else []);
+      wantedBy = [ "multi-user.target" ];
+    };
+
+    systemd.services.mastodon-streaming = {
+      after = [ "network.target" ]
+        ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else [])
+        ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]);
+      description = "Mastodon streaming";
+      wantedBy = [ "multi-user.target" ];
+      environment = env // (if cfg.enableUnixSocket
+        then { SOCKET = "/run/mastodon-streaming/streaming.socket"; }
+        else { PORT = toString(cfg.streamingPort); }
+      );
+      serviceConfig = {
+        ExecStart = "${cfg.package}/run-streaming.sh";
+        Restart = "always";
+        RestartSec = 20;
+        EnvironmentFile = "/var/lib/mastodon/.secrets_env";
+        WorkingDirectory = cfg.package;
+        # Runtime directory and mode
+        RuntimeDirectory = "mastodon-streaming";
+        RuntimeDirectoryMode = "0750";
+        # System Call Filtering
+        SystemCallFilter = [ ("~" + lib.concatStringsSep " " (systemCallsList ++ [ "@memlock" "@resources" ])) "pipe" "pipe2" ];
+      } // cfgService;
+    };
+
+    systemd.services.mastodon-web = {
+      after = [ "network.target" ]
+        ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else [])
+        ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]);
+      description = "Mastodon web";
+      wantedBy = [ "multi-user.target" ];
+      environment = env // (if cfg.enableUnixSocket
+        then { SOCKET = "/run/mastodon-web/web.socket"; }
+        else { PORT = toString(cfg.webPort); }
+      );
+      serviceConfig = {
+        ExecStart = "${cfg.package}/bin/puma -C config/puma.rb";
+        Restart = "always";
+        RestartSec = 20;
+        EnvironmentFile = "/var/lib/mastodon/.secrets_env";
+        WorkingDirectory = cfg.package;
+        # Runtime directory and mode
+        RuntimeDirectory = "mastodon-web";
+        RuntimeDirectoryMode = "0750";
+        # System Call Filtering
+        SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ];
+      } // cfgService;
+      path = with pkgs; [ file imagemagick ffmpeg ];
+    };
+
+    systemd.services.mastodon-sidekiq = {
+      after = [ "network.target" ]
+        ++ (if databaseActuallyCreateLocally then [ "postgresql.service" ] else [])
+        ++ (if cfg.automaticMigrations then [ "mastodon-init-db.service" ] else [ "mastodon-init-dirs.service" ]);
+      description = "Mastodon sidekiq";
+      wantedBy = [ "multi-user.target" ];
+      environment = env // {
+        PORT = toString(cfg.sidekiqPort);
+        DB_POOL = toString cfg.sidekiqThreads;
+      };
+      serviceConfig = {
+        ExecStart = "${cfg.package}/bin/sidekiq -c ${toString cfg.sidekiqThreads} -r ${cfg.package}";
+        Restart = "always";
+        RestartSec = 20;
+        EnvironmentFile = "/var/lib/mastodon/.secrets_env";
+        WorkingDirectory = cfg.package;
+        # System Call Filtering
+        SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ];
+      } // cfgService;
+      path = with pkgs; [ file imagemagick ffmpeg ];
+    };
+
+    services.nginx = lib.mkIf cfg.configureNginx {
+      enable = true;
+      recommendedProxySettings = true; # required for redirections to work
+      virtualHosts."${cfg.localDomain}" = {
+        root = "${cfg.package}/public/";
+        forceSSL = true; # mastodon only supports https
+        enableACME = true;
+
+        locations."/system/".alias = "/var/lib/mastodon/public-system/";
+
+        locations."/" = {
+          tryFiles = "$uri @proxy";
+        };
+
+        locations."@proxy" = {
+          proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-web/web.socket" else "http://127.0.0.1:${toString(cfg.webPort)}");
+          proxyWebsockets = true;
+        };
+
+        locations."/api/v1/streaming/" = {
+          proxyPass = (if cfg.enableUnixSocket then "http://unix:/run/mastodon-streaming/streaming.socket" else "http://127.0.0.1:${toString(cfg.streamingPort)}/");
+          proxyWebsockets = true;
+        };
+      };
+    };
+
+    services.postfix = lib.mkIf (cfg.smtp.createLocally && cfg.smtp.host == "127.0.0.1") {
+      enable = true;
+      hostname = lib.mkDefault "${cfg.localDomain}";
+    };
+    services.redis = lib.mkIf (cfg.redis.createLocally && cfg.redis.host == "127.0.0.1") {
+      enable = true;
+    };
+    services.postgresql = lib.mkIf databaseActuallyCreateLocally {
+      enable = true;
+      ensureUsers = [
+        {
+          name = cfg.database.user;
+          ensurePermissions."DATABASE ${cfg.database.name}" = "ALL PRIVILEGES";
+        }
+      ];
+      ensureDatabases = [ cfg.database.name ];
+    };
+
+    users.users = lib.mkMerge [
+      (lib.mkIf (cfg.user == "mastodon") {
+        mastodon = {
+          isSystemUser = true;
+          home = cfg.package;
+          inherit (cfg) group;
+        };
+      })
+      (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package mastodonEnv ])
+    ];
+
+    users.groups.${cfg.group}.members = lib.optional cfg.configureNginx config.services.nginx.user;
+  };
+
+  meta.maintainers = with lib.maintainers; [ happy-river erictapen ];
+
+}
diff --git a/nixos/modules/services/web-apps/matomo-doc.xml b/nixos/modules/services/web-apps/matomo-doc.xml
new file mode 100644
index 00000000000..69d1170e452
--- /dev/null
+++ b/nixos/modules/services/web-apps/matomo-doc.xml
@@ -0,0 +1,107 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-matomo">
+ <title>Matomo</title>
+ <para>
+  Matomo is a real-time web analytics application. This module configures
+  php-fpm as backend for Matomo, optionally configuring an nginx vhost as well.
+ </para>
+ <para>
+  An automatic setup is not suported by Matomo, so you need to configure Matomo
+  itself in the browser-based Matomo setup.
+ </para>
+ <section xml:id="module-services-matomo-database-setup">
+  <title>Database Setup</title>
+
+  <para>
+   You also need to configure a MariaDB or MySQL database and -user for Matomo
+   yourself, and enter those credentials in your browser. You can use
+   passwordless database authentication via the UNIX_SOCKET authentication
+   plugin with the following SQL commands:
+<programlisting>
+# For MariaDB
+INSTALL PLUGIN unix_socket SONAME 'auth_socket';
+CREATE DATABASE matomo;
+CREATE USER 'matomo'@'localhost' IDENTIFIED WITH unix_socket;
+GRANT ALL PRIVILEGES ON matomo.* TO 'matomo'@'localhost';
+
+# For MySQL
+INSTALL PLUGIN auth_socket SONAME 'auth_socket.so';
+CREATE DATABASE matomo;
+CREATE USER 'matomo'@'localhost' IDENTIFIED WITH auth_socket;
+GRANT ALL PRIVILEGES ON matomo.* TO 'matomo'@'localhost';
+</programlisting>
+   Then fill in <literal>matomo</literal> as database user and database name,
+   and leave the password field blank. This authentication works by allowing
+   only the <literal>matomo</literal> unix user to authenticate as the
+   <literal>matomo</literal> database user (without needing a password), but no
+   other users. For more information on passwordless login, see
+   <link xlink:href="https://mariadb.com/kb/en/mariadb/unix_socket-authentication-plugin/" />.
+  </para>
+
+  <para>
+   Of course, you can use password based authentication as well, e.g. when the
+   database is not on the same host.
+  </para>
+ </section>
+ <section xml:id="module-services-matomo-archive-processing">
+  <title>Archive Processing</title>
+
+  <para>
+   This module comes with the systemd service
+   <literal>matomo-archive-processing.service</literal> and a timer that
+   automatically triggers archive processing every hour. This means that you
+   can safely
+   <link xlink:href="https://matomo.org/docs/setup-auto-archiving/#disable-browser-triggers-for-matomo-archiving-and-limit-matomo-reports-to-updating-every-hour">
+   disable browser triggers for Matomo archiving </link> at
+   <literal>Administration > System > General Settings</literal>.
+  </para>
+
+  <para>
+   With automatic archive processing, you can now also enable to
+   <link xlink:href="https://matomo.org/docs/privacy/#step-2-delete-old-visitors-logs">
+   delete old visitor logs </link> at <literal>Administration > System >
+   Privacy</literal>, but make sure that you run <literal>systemctl start
+   matomo-archive-processing.service</literal> at least once without errors if
+   you have already collected data before, so that the reports get archived
+   before the source data gets deleted.
+  </para>
+ </section>
+ <section xml:id="module-services-matomo-backups">
+  <title>Backup</title>
+
+  <para>
+   You only need to take backups of your MySQL database and the
+   <filename>/var/lib/matomo/config/config.ini.php</filename> file. Use a user
+   in the <literal>matomo</literal> group or root to access the file. For more
+   information, see
+   <link xlink:href="https://matomo.org/faq/how-to-install/faq_138/" />.
+  </para>
+ </section>
+ <section xml:id="module-services-matomo-issues">
+  <title>Issues</title>
+
+  <itemizedlist>
+   <listitem>
+    <para>
+     Matomo will warn you that the JavaScript tracker is not writable. This is
+     because it's located in the read-only nix store. You can safely ignore
+     this, unless you need a plugin that needs JavaScript tracker access.
+    </para>
+   </listitem>
+  </itemizedlist>
+ </section>
+ <section xml:id="module-services-matomo-other-web-servers">
+  <title>Using other Web Servers than nginx</title>
+
+  <para>
+   You can use other web servers by forwarding calls for
+   <filename>index.php</filename> and <filename>piwik.php</filename> to the
+   <literal><link linkend="opt-services.phpfpm.pools._name_.socket">services.phpfpm.pools.&lt;name&gt;.socket</link></literal> fastcgi unix socket. You can use
+   the nginx configuration in the module code as a reference to what else
+   should be configured.
+  </para>
+ </section>
+</chapter>
diff --git a/nixos/modules/services/web-apps/matomo.nix b/nixos/modules/services/web-apps/matomo.nix
new file mode 100644
index 00000000000..c6d4ed6d39d
--- /dev/null
+++ b/nixos/modules/services/web-apps/matomo.nix
@@ -0,0 +1,335 @@
+{ config, lib, options, pkgs, ... }:
+with lib;
+let
+  cfg = config.services.matomo;
+  fpm = config.services.phpfpm.pools.${pool};
+
+  user = "matomo";
+  dataDir = "/var/lib/${user}";
+  deprecatedDataDir = "/var/lib/piwik";
+
+  pool = user;
+  phpExecutionUnit = "phpfpm-${pool}";
+  databaseService = "mysql.service";
+
+  fqdn = if config.networking.domain != null then config.networking.fqdn else config.networking.hostName;
+
+in {
+  imports = [
+    (mkRenamedOptionModule [ "services" "piwik" "enable" ] [ "services" "matomo" "enable" ])
+    (mkRenamedOptionModule [ "services" "piwik" "webServerUser" ] [ "services" "matomo" "webServerUser" ])
+    (mkRemovedOptionModule [ "services" "piwik" "phpfpmProcessManagerConfig" ] "Use services.phpfpm.pools.<name>.settings")
+    (mkRemovedOptionModule [ "services" "matomo" "phpfpmProcessManagerConfig" ] "Use services.phpfpm.pools.<name>.settings")
+    (mkRenamedOptionModule [ "services" "piwik" "nginx" ] [ "services" "matomo" "nginx" ])
+    (mkRenamedOptionModule [ "services" "matomo" "periodicArchiveProcessingUrl" ] [ "services" "matomo" "hostname" ])
+  ];
+
+  options = {
+    services.matomo = {
+      # NixOS PR for database setup: https://github.com/NixOS/nixpkgs/pull/6963
+      # Matomo issue for automatic Matomo setup: https://github.com/matomo-org/matomo/issues/10257
+      # TODO: find a nice way to do this when more NixOS MySQL and / or Matomo automatic setup stuff is implemented.
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enable Matomo web analytics with php-fpm backend.
+          Either the nginx option or the webServerUser option is mandatory.
+        '';
+      };
+
+      package = mkOption {
+        type = types.package;
+        description = ''
+          Matomo package for the service to use.
+          This can be used to point to newer releases from nixos-unstable,
+          as they don't get backported if they are not security-relevant.
+        '';
+        default = pkgs.matomo;
+        defaultText = literalExpression "pkgs.matomo";
+      };
+
+      webServerUser = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        example = "lighttpd";
+        description = ''
+          Name of the web server user that forwards requests to <option>services.phpfpm.pools.&lt;name&gt;.socket</option> the fastcgi socket for Matomo if the nginx
+          option is not used. Either this option or the nginx option is mandatory.
+          If you want to use another webserver than nginx, you need to set this to that server's user
+          and pass fastcgi requests to `index.php`, `matomo.php` and `piwik.php` (legacy name) to this socket.
+        '';
+      };
+
+      periodicArchiveProcessing = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Enable periodic archive processing, which generates aggregated reports from the visits.
+
+          This means that you can safely disable browser triggers for Matomo archiving,
+          and safely enable to delete old visitor logs.
+          Before deleting visitor logs,
+          make sure though that you run <literal>systemctl start matomo-archive-processing.service</literal>
+          at least once without errors if you have already collected data before.
+        '';
+      };
+
+      hostname = mkOption {
+        type = types.str;
+        default = "${user}.${fqdn}";
+        defaultText = literalExpression ''
+          if config.${options.networking.domain} != null
+          then "${user}.''${config.${options.networking.fqdn}}"
+          else "${user}.''${config.${options.networking.hostName}}"
+        '';
+        example = "matomo.yourdomain.org";
+        description = ''
+          URL of the host, without https prefix. You may want to change it if you
+          run Matomo on a different URL than matomo.yourdomain.
+        '';
+      };
+
+      nginx = mkOption {
+        type = types.nullOr (types.submodule (
+          recursiveUpdate
+            (import ../web-servers/nginx/vhost-options.nix { inherit config lib; })
+            {
+              # enable encryption by default,
+              # as sensitive login and Matomo data should not be transmitted in clear text.
+              options.forceSSL.default = true;
+              options.enableACME.default = true;
+            }
+        )
+        );
+        default = null;
+        example = literalExpression ''
+          {
+            serverAliases = [
+              "matomo.''${config.networking.domain}"
+              "stats.''${config.networking.domain}"
+            ];
+            enableACME = false;
+          }
+        '';
+        description = ''
+            With this option, you can customize an nginx virtualHost which already has sensible defaults for Matomo.
+            Either this option or the webServerUser option is mandatory.
+            Set this to {} to just enable the virtualHost if you don't need any customization.
+            If enabled, then by default, the <option>serverName</option> is
+            <literal>''${user}.''${config.networking.hostName}.''${config.networking.domain}</literal>,
+            SSL is active, and certificates are acquired via ACME.
+            If this is set to null (the default), no nginx virtualHost will be configured.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    warnings = mkIf (cfg.nginx != null && cfg.webServerUser != null) [
+      "If services.matomo.nginx is set, services.matomo.nginx.webServerUser is ignored and should be removed."
+    ];
+
+    assertions = [ {
+        assertion = cfg.nginx != null || cfg.webServerUser != null;
+        message = "Either services.matomo.nginx or services.matomo.nginx.webServerUser is mandatory";
+    }];
+
+    users.users.${user} = {
+      isSystemUser = true;
+      createHome = true;
+      home = dataDir;
+      group  = user;
+    };
+    users.groups.${user} = {};
+
+    systemd.services.matomo-setup-update = {
+      # everything needs to set up and up to date before Matomo php files are executed
+      requiredBy = [ "${phpExecutionUnit}.service" ];
+      before = [ "${phpExecutionUnit}.service" ];
+      # the update part of the script can only work if the database is already up and running
+      requires = [ databaseService ];
+      after = [ databaseService ];
+      path = [ cfg.package ];
+      environment.PIWIK_USER_PATH = dataDir;
+      serviceConfig = {
+        Type = "oneshot";
+        User = user;
+        # hide especially config.ini.php from other
+        UMask = "0007";
+        # TODO: might get renamed to MATOMO_USER_PATH in future versions
+        # chown + chmod in preStart needs root
+        PermissionsStartOnly = true;
+      };
+
+      # correct ownership and permissions in case they're not correct anymore,
+      # e.g. after restoring from backup or moving from another system.
+      # Note that ${dataDir}/config/config.ini.php might contain the MySQL password.
+      preStart = ''
+        # migrate data from piwik to Matomo folder
+        if [ -d ${deprecatedDataDir} ]; then
+          echo "Migrating from ${deprecatedDataDir} to ${dataDir}"
+          mv -T ${deprecatedDataDir} ${dataDir}
+        fi
+        chown -R ${user}:${user} ${dataDir}
+        chmod -R ug+rwX,o-rwx ${dataDir}
+
+        if [ -e ${dataDir}/current-package ]; then
+          CURRENT_PACKAGE=$(readlink ${dataDir}/current-package)
+          NEW_PACKAGE=${cfg.package}
+          if [ "$CURRENT_PACKAGE" != "$NEW_PACKAGE" ]; then
+            # keeping tmp arround between upgrades seems to bork stuff, so delete it
+            rm -rf ${dataDir}/tmp
+          fi
+        elif [ -e ${dataDir}/tmp ]; then
+          # upgrade from 4.4.1
+          rm -rf ${dataDir}/tmp
+        fi
+        ln -sfT ${cfg.package} ${dataDir}/current-package
+        '';
+      script = ''
+            # Use User-Private Group scheme to protect Matomo data, but allow administration / backup via 'matomo' group
+            # Copy config folder
+            chmod g+s "${dataDir}"
+            cp -r "${cfg.package}/share/config" "${dataDir}/"
+            mkdir -p "${dataDir}/misc"
+            chmod -R u+rwX,g+rwX,o-rwx "${dataDir}"
+
+            # check whether user setup has already been done
+            if test -f "${dataDir}/config/config.ini.php"; then
+              # then execute possibly pending database upgrade
+              matomo-console core:update --yes
+            fi
+      '';
+    };
+
+    # If this is run regularly via the timer,
+    # 'Browser trigger archiving' can be disabled in Matomo UI > Settings > General Settings.
+    systemd.services.matomo-archive-processing = {
+      description = "Archive Matomo reports";
+      # the archiving can only work if the database is already up and running
+      requires = [ databaseService ];
+      after = [ databaseService ];
+
+      # TODO: might get renamed to MATOMO_USER_PATH in future versions
+      environment.PIWIK_USER_PATH = dataDir;
+      serviceConfig = {
+        Type = "oneshot";
+        User = user;
+        UMask = "0007";
+        CPUSchedulingPolicy = "idle";
+        IOSchedulingClass = "idle";
+        ExecStart = "${cfg.package}/bin/matomo-console core:archive --url=https://${cfg.hostname}";
+      };
+    };
+
+    systemd.timers.matomo-archive-processing = mkIf cfg.periodicArchiveProcessing {
+      description = "Automatically archive Matomo reports every hour";
+
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        OnCalendar = "hourly";
+        Persistent = "yes";
+        AccuracySec = "10m";
+      };
+    };
+
+    systemd.services.${phpExecutionUnit} = {
+      # stop phpfpm on package upgrade, do database upgrade via matomo-setup-update, and then restart
+      restartTriggers = [ cfg.package ];
+      # stop config.ini.php from getting written with read permission for others
+      serviceConfig.UMask = "0007";
+    };
+
+    services.phpfpm.pools = let
+      # workaround for when both are null and need to generate a string,
+      # which is illegal, but as assertions apparently are being triggered *after* config generation,
+      # we have to avoid already throwing errors at this previous stage.
+      socketOwner = if (cfg.nginx != null) then config.services.nginx.user
+      else if (cfg.webServerUser != null) then cfg.webServerUser else "";
+    in {
+      ${pool} = {
+        inherit user;
+        phpOptions = ''
+          error_log = 'stderr'
+          log_errors = on
+        '';
+        settings = mapAttrs (name: mkDefault) {
+          "listen.owner" = socketOwner;
+          "listen.group" = "root";
+          "listen.mode" = "0660";
+          "pm" = "dynamic";
+          "pm.max_children" = 75;
+          "pm.start_servers" = 10;
+          "pm.min_spare_servers" = 5;
+          "pm.max_spare_servers" = 20;
+          "pm.max_requests" = 500;
+          "catch_workers_output" = true;
+        };
+        phpEnv.PIWIK_USER_PATH = dataDir;
+      };
+    };
+
+
+    services.nginx.virtualHosts = mkIf (cfg.nginx != null) {
+      # References:
+      # https://fralef.me/piwik-hardening-with-nginx-and-php-fpm.html
+      # https://github.com/perusio/piwik-nginx
+      "${cfg.hostname}" = mkMerge [ cfg.nginx {
+        # don't allow to override the root easily, as it will almost certainly break Matomo.
+        # disadvantage: not shown as default in docs.
+        root = mkForce "${cfg.package}/share";
+
+        # define locations here instead of as the submodule option's default
+        # so that they can easily be extended with additional locations if required
+        # without needing to redefine the Matomo ones.
+        # disadvantage: not shown as default in docs.
+        locations."/" = {
+          index = "index.php";
+        };
+        # allow index.php for webinterface
+        locations."= /index.php".extraConfig = ''
+          fastcgi_pass unix:${fpm.socket};
+        '';
+        # allow matomo.php for tracking
+        locations."= /matomo.php".extraConfig = ''
+          fastcgi_pass unix:${fpm.socket};
+        '';
+        # allow piwik.php for tracking (deprecated name)
+        locations."= /piwik.php".extraConfig = ''
+          fastcgi_pass unix:${fpm.socket};
+        '';
+        # Any other attempt to access any php files is forbidden
+        locations."~* ^.+\\.php$".extraConfig = ''
+          return 403;
+        '';
+        # Disallow access to unneeded directories
+        # config and tmp are already removed
+        locations."~ ^/(?:core|lang|misc)/".extraConfig = ''
+          return 403;
+        '';
+        # Disallow access to several helper files
+        locations."~* \\.(?:bat|git|ini|sh|txt|tpl|xml|md)$".extraConfig = ''
+          return 403;
+        '';
+        # No crawling of this site for bots that obey robots.txt - no useful information here.
+        locations."= /robots.txt".extraConfig = ''
+          return 200 "User-agent: *\nDisallow: /\n";
+        '';
+        # let browsers cache matomo.js
+        locations."= /matomo.js".extraConfig = ''
+          expires 1M;
+        '';
+        # let browsers cache piwik.js (deprecated name)
+        locations."= /piwik.js".extraConfig = ''
+          expires 1M;
+        '';
+      }];
+    };
+  };
+
+  meta = {
+    doc = ./matomo-doc.xml;
+    maintainers = with lib.maintainers; [ florianjacob ];
+  };
+}
diff --git a/nixos/modules/services/web-apps/mattermost.nix b/nixos/modules/services/web-apps/mattermost.nix
new file mode 100644
index 00000000000..2901f307dc5
--- /dev/null
+++ b/nixos/modules/services/web-apps/mattermost.nix
@@ -0,0 +1,344 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.mattermost;
+
+  database = "postgres://${cfg.localDatabaseUser}:${cfg.localDatabasePassword}@localhost:5432/${cfg.localDatabaseName}?sslmode=disable&connect_timeout=10";
+
+  postgresPackage = config.services.postgresql.package;
+
+  createDb = {
+    statePath ? cfg.statePath,
+    localDatabaseUser ? cfg.localDatabaseUser,
+    localDatabasePassword ? cfg.localDatabasePassword,
+    localDatabaseName ? cfg.localDatabaseName,
+    useSudo ? true
+  }: ''
+    if ! test -e ${escapeShellArg "${statePath}/.db-created"}; then
+      ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"}
+        ${postgresPackage}/bin/psql postgres -c \
+          "CREATE ROLE ${localDatabaseUser} WITH LOGIN NOCREATEDB NOCREATEROLE ENCRYPTED PASSWORD '${localDatabasePassword}'"
+      ${lib.optionalString useSudo "${pkgs.sudo}/bin/sudo -u ${escapeShellArg config.services.postgresql.superUser} \\"}
+        ${postgresPackage}/bin/createdb \
+          --owner ${escapeShellArg localDatabaseUser} ${escapeShellArg localDatabaseName}
+      touch ${escapeShellArg "${statePath}/.db-created"}
+    fi
+  '';
+
+  mattermostPluginDerivations = with pkgs;
+    map (plugin: stdenv.mkDerivation {
+      name = "mattermost-plugin";
+      installPhase = ''
+        mkdir -p $out/share
+        cp ${plugin} $out/share/plugin.tar.gz
+      '';
+      dontUnpack = true;
+      dontPatch = true;
+      dontConfigure = true;
+      dontBuild = true;
+      preferLocalBuild = true;
+    }) cfg.plugins;
+
+  mattermostPlugins = with pkgs;
+    if mattermostPluginDerivations == [] then null
+    else stdenv.mkDerivation {
+      name = "${cfg.package.name}-plugins";
+      nativeBuildInputs = [
+        autoPatchelfHook
+      ] ++ mattermostPluginDerivations;
+      buildInputs = [
+        cfg.package
+      ];
+      installPhase = ''
+        mkdir -p $out/data/plugins
+        plugins=(${escapeShellArgs (map (plugin: "${plugin}/share/plugin.tar.gz") mattermostPluginDerivations)})
+        for plugin in "''${plugins[@]}"; do
+          hash="$(sha256sum "$plugin" | cut -d' ' -f1)"
+          mkdir -p "$hash"
+          tar -C "$hash" -xzf "$plugin"
+          autoPatchelf "$hash"
+          GZIP_OPT=-9 tar -C "$hash" -cvzf "$out/data/plugins/$hash.tar.gz" .
+          rm -rf "$hash"
+        done
+      '';
+
+      dontUnpack = true;
+      dontPatch = true;
+      dontConfigure = true;
+      dontBuild = true;
+      preferLocalBuild = true;
+    };
+
+  mattermostConfWithoutPlugins = recursiveUpdate
+    { ServiceSettings.SiteURL = cfg.siteUrl;
+      ServiceSettings.ListenAddress = cfg.listenAddress;
+      TeamSettings.SiteName = cfg.siteName;
+      SqlSettings.DriverName = "postgres";
+      SqlSettings.DataSource = database;
+      PluginSettings.Directory = "${cfg.statePath}/plugins/server";
+      PluginSettings.ClientDirectory = "${cfg.statePath}/plugins/client";
+    }
+    cfg.extraConfig;
+
+  mattermostConf = recursiveUpdate
+    mattermostConfWithoutPlugins
+    (
+      if mattermostPlugins == null then {}
+      else {
+        PluginSettings = {
+          Enable = true;
+        };
+      }
+    );
+
+  mattermostConfJSON = pkgs.writeText "mattermost-config.json" (builtins.toJSON mattermostConf);
+
+in
+
+{
+  options = {
+    services.mattermost = {
+      enable = mkEnableOption "Mattermost chat server";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.mattermost;
+        defaultText = "pkgs.mattermost";
+        description = "Mattermost derivation to use.";
+      };
+
+      statePath = mkOption {
+        type = types.str;
+        default = "/var/lib/mattermost";
+        description = "Mattermost working directory";
+      };
+
+      siteUrl = mkOption {
+        type = types.str;
+        example = "https://chat.example.com";
+        description = ''
+          URL this Mattermost instance is reachable under, without trailing slash.
+        '';
+      };
+
+      siteName = mkOption {
+        type = types.str;
+        default = "Mattermost";
+        description = "Name of this Mattermost site.";
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = ":8065";
+        example = "[::1]:8065";
+        description = ''
+          Address and port this Mattermost instance listens to.
+        '';
+      };
+
+      mutableConfig = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether the Mattermost config.json is writeable by Mattermost.
+
+          Most of the settings can be edited in the system console of
+          Mattermost if this option is enabled. A template config using
+          the options specified in services.mattermost will be generated
+          but won't be overwritten on changes or rebuilds.
+
+          If this option is disabled, changes in the system console won't
+          be possible (default). If an config.json is present, it will be
+          overwritten!
+        '';
+      };
+
+      preferNixConfig = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          If both mutableConfig and this option are set, the Nix configuration
+          will take precedence over any settings configured in the server
+          console.
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = types.attrs;
+        default = { };
+        description = ''
+          Addtional configuration options as Nix attribute set in config.json schema.
+        '';
+      };
+
+      plugins = mkOption {
+        type = types.listOf (types.oneOf [types.path types.package]);
+        default = [];
+        example = "[ ./com.github.moussetc.mattermost.plugin.giphy-2.0.0.tar.gz ]";
+        description = ''
+          Plugins to add to the configuration. Overrides any installed if non-null.
+          This is a list of paths to .tar.gz files or derivations evaluating to
+          .tar.gz files.
+        '';
+      };
+
+      localDatabaseCreate = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Create a local PostgreSQL database for Mattermost automatically.
+        '';
+      };
+
+      localDatabaseName = mkOption {
+        type = types.str;
+        default = "mattermost";
+        description = ''
+          Local Mattermost database name.
+        '';
+      };
+
+      localDatabaseUser = mkOption {
+        type = types.str;
+        default = "mattermost";
+        description = ''
+          Local Mattermost database username.
+        '';
+      };
+
+      localDatabasePassword = mkOption {
+        type = types.str;
+        default = "mmpgsecret";
+        description = ''
+          Password for local Mattermost database user.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "mattermost";
+        description = ''
+          User which runs the Mattermost service.
+        '';
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "mattermost";
+        description = ''
+          Group which runs the Mattermost service.
+        '';
+      };
+
+      matterircd = {
+        enable = mkEnableOption "Mattermost IRC bridge";
+        package = mkOption {
+          type = types.package;
+          default = pkgs.matterircd;
+          defaultText = "pkgs.matterircd";
+          description = "matterircd derivation to use.";
+        };
+        parameters = mkOption {
+          type = types.listOf types.str;
+          default = [ ];
+          example = [ "-mmserver chat.example.com" "-bind [::]:6667" ];
+          description = ''
+            Set commandline parameters to pass to matterircd. See
+            https://github.com/42wim/matterircd#usage for more information.
+          '';
+        };
+      };
+    };
+  };
+
+  config = mkMerge [
+    (mkIf cfg.enable {
+      users.users = optionalAttrs (cfg.user == "mattermost") {
+        mattermost = {
+          group = cfg.group;
+          uid = config.ids.uids.mattermost;
+          home = cfg.statePath;
+        };
+      };
+
+      users.groups = optionalAttrs (cfg.group == "mattermost") {
+        mattermost.gid = config.ids.gids.mattermost;
+      };
+
+      services.postgresql.enable = cfg.localDatabaseCreate;
+
+      # The systemd service will fail to execute the preStart hook
+      # if the WorkingDirectory does not exist
+      system.activationScripts.mattermost = ''
+        mkdir -p "${cfg.statePath}"
+      '';
+
+      systemd.services.mattermost = {
+        description = "Mattermost chat service";
+        wantedBy = [ "multi-user.target" ];
+        after = [ "network.target" "postgresql.service" ];
+
+        preStart = ''
+          mkdir -p "${cfg.statePath}"/{data,config,logs,plugins}
+          mkdir -p "${cfg.statePath}/plugins"/{client,server}
+          ln -sf ${cfg.package}/{bin,fonts,i18n,templates,client} "${cfg.statePath}"
+        '' + lib.optionalString (mattermostPlugins != null) ''
+          rm -rf "${cfg.statePath}/data/plugins"
+          ln -sf ${mattermostPlugins}/data/plugins "${cfg.statePath}/data"
+        '' + lib.optionalString (!cfg.mutableConfig) ''
+          rm -f "${cfg.statePath}/config/config.json"
+          ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json"
+        '' + lib.optionalString cfg.mutableConfig ''
+          if ! test -e "${cfg.statePath}/config/.initial-created"; then
+            rm -f ${cfg.statePath}/config/config.json
+            ${pkgs.jq}/bin/jq -s '.[0] * .[1]' ${cfg.package}/config/config.json ${mattermostConfJSON} > "${cfg.statePath}/config/config.json"
+            touch "${cfg.statePath}/config/.initial-created"
+          fi
+        '' + lib.optionalString (cfg.mutableConfig && cfg.preferNixConfig) ''
+          new_config="$(${pkgs.jq}/bin/jq -s '.[0] * .[1]' "${cfg.statePath}/config/config.json" ${mattermostConfJSON})"
+
+          rm -f "${cfg.statePath}/config/config.json"
+          echo "$new_config" > "${cfg.statePath}/config/config.json"
+        '' + lib.optionalString cfg.localDatabaseCreate (createDb {}) + ''
+          # Don't change permissions recursively on the data, current, and symlinked directories (see ln -sf command above).
+          # This dramatically decreases startup times for installations with a lot of files.
+          find . -maxdepth 1 -not -name data -not -name client -not -name templates -not -name i18n -not -name fonts -not -name bin -not -name . \
+            -exec chown "${cfg.user}:${cfg.group}" -R {} \; -exec chmod u+rw,g+r,o-rwx -R {} \;
+
+          chown "${cfg.user}:${cfg.group}" "${cfg.statePath}/data" .
+          chmod u+rw,g+r,o-rwx "${cfg.statePath}/data" .
+        '';
+
+        serviceConfig = {
+          PermissionsStartOnly = true;
+          User = cfg.user;
+          Group = cfg.group;
+          ExecStart = "${cfg.package}/bin/mattermost";
+          WorkingDirectory = "${cfg.statePath}";
+          Restart = "always";
+          RestartSec = "10";
+          LimitNOFILE = "49152";
+        };
+        unitConfig.JoinsNamespaceOf = mkIf cfg.localDatabaseCreate "postgresql.service";
+      };
+    })
+    (mkIf cfg.matterircd.enable {
+      systemd.services.matterircd = {
+        description = "Mattermost IRC bridge service";
+        wantedBy = [ "multi-user.target" ];
+        serviceConfig = {
+          User = "nobody";
+          Group = "nogroup";
+          ExecStart = "${cfg.matterircd.package}/bin/matterircd ${escapeShellArgs cfg.matterircd.parameters}";
+          WorkingDirectory = "/tmp";
+          PrivateTmp = true;
+          Restart = "always";
+          RestartSec = "5";
+        };
+      };
+    })
+  ];
+}
diff --git a/nixos/modules/services/web-apps/mediawiki.nix b/nixos/modules/services/web-apps/mediawiki.nix
new file mode 100644
index 00000000000..977b6f60b23
--- /dev/null
+++ b/nixos/modules/services/web-apps/mediawiki.nix
@@ -0,0 +1,475 @@
+{ config, pkgs, lib, ... }:
+
+let
+
+  inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption;
+  inherit (lib) concatStringsSep literalExpression mapAttrsToList optional optionals optionalString types;
+
+  cfg = config.services.mediawiki;
+  fpm = config.services.phpfpm.pools.mediawiki;
+  user = "mediawiki";
+  group = config.services.httpd.group;
+  cacheDir = "/var/cache/mediawiki";
+  stateDir = "/var/lib/mediawiki";
+
+  pkg = pkgs.stdenv.mkDerivation rec {
+    pname = "mediawiki-full";
+    version = src.version;
+    src = cfg.package;
+
+    installPhase = ''
+      mkdir -p $out
+      cp -r * $out/
+
+      rm -rf $out/share/mediawiki/skins/*
+      rm -rf $out/share/mediawiki/extensions/*
+
+      ${concatStringsSep "\n" (mapAttrsToList (k: v: ''
+        ln -s ${v} $out/share/mediawiki/skins/${k}
+      '') cfg.skins)}
+
+      ${concatStringsSep "\n" (mapAttrsToList (k: v: ''
+        ln -s ${if v != null then v else "$src/share/mediawiki/extensions/${k}"} $out/share/mediawiki/extensions/${k}
+      '') cfg.extensions)}
+    '';
+  };
+
+  mediawikiScripts = pkgs.runCommand "mediawiki-scripts" {
+    buildInputs = [ pkgs.makeWrapper ];
+    preferLocalBuild = true;
+  } ''
+    mkdir -p $out/bin
+    for i in changePassword.php createAndPromote.php userOptions.php edit.php nukePage.php update.php; do
+      makeWrapper ${pkgs.php}/bin/php $out/bin/mediawiki-$(basename $i .php) \
+        --set MEDIAWIKI_CONFIG ${mediawikiConfig} \
+        --add-flags ${pkg}/share/mediawiki/maintenance/$i
+    done
+  '';
+
+  mediawikiConfig = pkgs.writeText "LocalSettings.php" ''
+    <?php
+      # Protect against web entry
+      if ( !defined( 'MEDIAWIKI' ) ) {
+        exit;
+      }
+
+      $wgSitename = "${cfg.name}";
+      $wgMetaNamespace = false;
+
+      ## The URL base path to the directory containing the wiki;
+      ## defaults for all runtime URL paths are based off of this.
+      ## For more information on customizing the URLs
+      ## (like /w/index.php/Page_title to /wiki/Page_title) please see:
+      ## https://www.mediawiki.org/wiki/Manual:Short_URL
+      $wgScriptPath = "";
+
+      ## The protocol and server name to use in fully-qualified URLs
+      $wgServer = "${if cfg.virtualHost.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL then "https" else "http"}://${cfg.virtualHost.hostName}";
+
+      ## The URL path to static resources (images, scripts, etc.)
+      $wgResourceBasePath = $wgScriptPath;
+
+      ## The URL path to the logo.  Make sure you change this from the default,
+      ## or else you'll overwrite your logo when you upgrade!
+      $wgLogo = "$wgResourceBasePath/resources/assets/wiki.png";
+
+      ## UPO means: this is also a user preference option
+
+      $wgEnableEmail = true;
+      $wgEnableUserEmail = true; # UPO
+
+      $wgEmergencyContact = "${if cfg.virtualHost.adminAddr != null then cfg.virtualHost.adminAddr else config.services.httpd.adminAddr}";
+      $wgPasswordSender = $wgEmergencyContact;
+
+      $wgEnotifUserTalk = false; # UPO
+      $wgEnotifWatchlist = false; # UPO
+      $wgEmailAuthentication = true;
+
+      ## Database settings
+      $wgDBtype = "${cfg.database.type}";
+      $wgDBserver = "${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}";
+      $wgDBname = "${cfg.database.name}";
+      $wgDBuser = "${cfg.database.user}";
+      ${optionalString (cfg.database.passwordFile != null) "$wgDBpassword = file_get_contents(\"${cfg.database.passwordFile}\");"}
+
+      ${optionalString (cfg.database.type == "mysql" && cfg.database.tablePrefix != null) ''
+        # MySQL specific settings
+        $wgDBprefix = "${cfg.database.tablePrefix}";
+      ''}
+
+      ${optionalString (cfg.database.type == "mysql") ''
+        # MySQL table options to use during installation or update
+        $wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
+      ''}
+
+      ## Shared memory settings
+      $wgMainCacheType = CACHE_NONE;
+      $wgMemCachedServers = [];
+
+      ${optionalString (cfg.uploadsDir != null) ''
+        $wgEnableUploads = true;
+        $wgUploadDirectory = "${cfg.uploadsDir}";
+      ''}
+
+      $wgUseImageMagick = true;
+      $wgImageMagickConvertCommand = "${pkgs.imagemagick}/bin/convert";
+
+      # InstantCommons allows wiki to use images from https://commons.wikimedia.org
+      $wgUseInstantCommons = false;
+
+      # Periodically send a pingback to https://www.mediawiki.org/ with basic data
+      # about this MediaWiki instance. The Wikimedia Foundation shares this data
+      # with MediaWiki developers to help guide future development efforts.
+      $wgPingback = true;
+
+      ## If you use ImageMagick (or any other shell command) on a
+      ## Linux server, this will need to be set to the name of an
+      ## available UTF-8 locale
+      $wgShellLocale = "C.UTF-8";
+
+      ## Set $wgCacheDirectory to a writable directory on the web server
+      ## to make your wiki go slightly faster. The directory should not
+      ## be publically accessible from the web.
+      $wgCacheDirectory = "${cacheDir}";
+
+      # Site language code, should be one of the list in ./languages/data/Names.php
+      $wgLanguageCode = "en";
+
+      $wgSecretKey = file_get_contents("${stateDir}/secret.key");
+
+      # Changing this will log out all existing sessions.
+      $wgAuthenticationTokenVersion = "";
+
+      ## For attaching licensing metadata to pages, and displaying an
+      ## appropriate copyright notice / icon. GNU Free Documentation
+      ## License and Creative Commons licenses are supported so far.
+      $wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
+      $wgRightsUrl = "";
+      $wgRightsText = "";
+      $wgRightsIcon = "";
+
+      # Path to the GNU diff3 utility. Used for conflict resolution.
+      $wgDiff = "${pkgs.diffutils}/bin/diff";
+      $wgDiff3 = "${pkgs.diffutils}/bin/diff3";
+
+      # Enabled skins.
+      ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadSkin('${k}');") cfg.skins)}
+
+      # Enabled extensions.
+      ${concatStringsSep "\n" (mapAttrsToList (k: v: "wfLoadExtension('${k}');") cfg.extensions)}
+
+
+      # End of automatically generated settings.
+      # Add more configuration options below.
+
+      ${cfg.extraConfig}
+  '';
+
+in
+{
+  # interface
+  options = {
+    services.mediawiki = {
+
+      enable = mkEnableOption "MediaWiki";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.mediawiki;
+        defaultText = literalExpression "pkgs.mediawiki";
+        description = "Which MediaWiki package to use.";
+      };
+
+      name = mkOption {
+        type = types.str;
+        default = "MediaWiki";
+        example = "Foobar Wiki";
+        description = "Name of the wiki.";
+      };
+
+      uploadsDir = mkOption {
+        type = types.nullOr types.path;
+        default = "${stateDir}/uploads";
+        description = ''
+          This directory is used for uploads of pictures. The directory passed here is automatically
+          created and permissions adjusted as required.
+        '';
+      };
+
+      passwordFile = mkOption {
+        type = types.path;
+        description = "A file containing the initial password for the admin user.";
+        example = "/run/keys/mediawiki-password";
+      };
+
+      skins = mkOption {
+        default = {};
+        type = types.attrsOf types.path;
+        description = ''
+          Attribute set of paths whose content is copied to the <filename>skins</filename>
+          subdirectory of the MediaWiki installation in addition to the default skins.
+        '';
+      };
+
+      extensions = mkOption {
+        default = {};
+        type = types.attrsOf (types.nullOr types.path);
+        description = ''
+          Attribute set of paths whose content is copied to the <filename>extensions</filename>
+          subdirectory of the MediaWiki installation and enabled in configuration.
+
+          Use <literal>null</literal> instead of path to enable extensions that are part of MediaWiki.
+        '';
+        example = literalExpression ''
+          {
+            Matomo = pkgs.fetchzip {
+              url = "https://github.com/DaSchTour/matomo-mediawiki-extension/archive/v4.0.1.tar.gz";
+              sha256 = "0g5rd3zp0avwlmqagc59cg9bbkn3r7wx7p6yr80s644mj6dlvs1b";
+            };
+            ParserFunctions = null;
+          }
+        '';
+      };
+
+      database = {
+        type = mkOption {
+          type = types.enum [ "mysql" "postgres" "sqlite" "mssql" "oracle" ];
+          default = "mysql";
+          description = "Database engine to use. MySQL/MariaDB is the database of choice by MediaWiki developers.";
+        };
+
+        host = mkOption {
+          type = types.str;
+          default = "localhost";
+          description = "Database host address.";
+        };
+
+        port = mkOption {
+          type = types.port;
+          default = 3306;
+          description = "Database host port.";
+        };
+
+        name = mkOption {
+          type = types.str;
+          default = "mediawiki";
+          description = "Database name.";
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "mediawiki";
+          description = "Database user.";
+        };
+
+        passwordFile = mkOption {
+          type = types.nullOr types.path;
+          default = null;
+          example = "/run/keys/mediawiki-dbpassword";
+          description = ''
+            A file containing the password corresponding to
+            <option>database.user</option>.
+          '';
+        };
+
+        tablePrefix = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            If you only have access to a single database and wish to install more than
+            one version of MediaWiki, or have other applications that also use the
+            database, you can give the table names a unique prefix to stop any naming
+            conflicts or confusion.
+            See <link xlink:href='https://www.mediawiki.org/wiki/Manual:$wgDBprefix'/>.
+          '';
+        };
+
+        socket = mkOption {
+          type = types.nullOr types.path;
+          default = if cfg.database.createLocally then "/run/mysqld/mysqld.sock" else null;
+          defaultText = literalExpression "/run/mysqld/mysqld.sock";
+          description = "Path to the unix socket file to use for authentication.";
+        };
+
+        createLocally = mkOption {
+          type = types.bool;
+          default = cfg.database.type == "mysql";
+          defaultText = literalExpression "true";
+          description = ''
+            Create the database and database user locally.
+            This currently only applies if database type "mysql" is selected.
+          '';
+        };
+      };
+
+      virtualHost = mkOption {
+        type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
+        example = literalExpression ''
+          {
+            hostName = "mediawiki.example.org";
+            adminAddr = "webmaster@example.org";
+            forceSSL = true;
+            enableACME = true;
+          }
+        '';
+        description = ''
+          Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>.
+          See <xref linkend="opt-services.httpd.virtualHosts"/> 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 MediaWiki PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+          for details on configuration directives.
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = types.lines;
+        description = ''
+          Any additional text to be appended to MediaWiki's
+          LocalSettings.php configuration file. For configuration
+          settings, see <link xlink:href="https://www.mediawiki.org/wiki/Manual:Configuration_settings"/>.
+        '';
+        default = "";
+        example = ''
+          $wgEnableEmail = false;
+        '';
+      };
+
+    };
+  };
+
+  # implementation
+  config = mkIf cfg.enable {
+
+    assertions = [
+      { assertion = cfg.database.createLocally -> cfg.database.type == "mysql";
+        message = "services.mediawiki.createLocally is currently only supported for database type 'mysql'";
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.user == user;
+        message = "services.mediawiki.database.user must be set to ${user} if services.mediawiki.database.createLocally is set true";
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.socket != null;
+        message = "services.mediawiki.database.socket must be set if services.mediawiki.database.createLocally is set to true";
+      }
+      { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
+        message = "a password cannot be specified if services.mediawiki.database.createLocally is set to true";
+      }
+    ];
+
+    services.mediawiki.skins = {
+      MonoBook = "${cfg.package}/share/mediawiki/skins/MonoBook";
+      Timeless = "${cfg.package}/share/mediawiki/skins/Timeless";
+      Vector = "${cfg.package}/share/mediawiki/skins/Vector";
+    };
+
+    services.mysql = mkIf cfg.database.createLocally {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        { name = cfg.database.user;
+          ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    services.phpfpm.pools.mediawiki = {
+      inherit user group;
+      phpEnv.MEDIAWIKI_CONFIG = "${mediawikiConfig}";
+      settings = {
+        "listen.owner" = config.services.httpd.user;
+        "listen.group" = config.services.httpd.group;
+      } // cfg.poolConfig;
+    };
+
+    services.httpd = {
+      enable = true;
+      extraModules = [ "proxy_fcgi" ];
+      virtualHosts.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost {
+        documentRoot = mkForce "${pkg}/share/mediawiki";
+        extraConfig = ''
+          <Directory "${pkg}/share/mediawiki">
+            <FilesMatch "\.php$">
+              <If "-f %{REQUEST_FILENAME}">
+                SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
+              </If>
+            </FilesMatch>
+
+            Require all granted
+            DirectoryIndex index.php
+            AllowOverride All
+          </Directory>
+        '' + optionalString (cfg.uploadsDir != null) ''
+          Alias "/images" "${cfg.uploadsDir}"
+          <Directory "${cfg.uploadsDir}">
+            Require all granted
+          </Directory>
+        '';
+      } ];
+    };
+
+    systemd.tmpfiles.rules = [
+      "d '${stateDir}' 0750 ${user} ${group} - -"
+      "d '${cacheDir}' 0750 ${user} ${group} - -"
+    ] ++ optionals (cfg.uploadsDir != null) [
+      "d '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
+      "Z '${cfg.uploadsDir}' 0750 ${user} ${group} - -"
+    ];
+
+    systemd.services.mediawiki-init = {
+      wantedBy = [ "multi-user.target" ];
+      before = [ "phpfpm-mediawiki.service" ];
+      after = optional cfg.database.createLocally "mysql.service";
+      script = ''
+        if ! test -e "${stateDir}/secret.key"; then
+          tr -dc A-Za-z0-9 </dev/urandom 2>/dev/null | head -c 64 > ${stateDir}/secret.key
+        fi
+
+        echo "exit( wfGetDB( DB_MASTER )->tableExists( 'user' ) ? 1 : 0 );" | \
+        ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/eval.php --conf ${mediawikiConfig} && \
+        ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/install.php \
+          --confpath /tmp \
+          --scriptpath / \
+          --dbserver ${cfg.database.host}${optionalString (cfg.database.socket != null) ":${cfg.database.socket}"} \
+          --dbport ${toString cfg.database.port} \
+          --dbname ${cfg.database.name} \
+          ${optionalString (cfg.database.tablePrefix != null) "--dbprefix ${cfg.database.tablePrefix}"} \
+          --dbuser ${cfg.database.user} \
+          ${optionalString (cfg.database.passwordFile != null) "--dbpassfile ${cfg.database.passwordFile}"} \
+          --passfile ${cfg.passwordFile} \
+          ${cfg.name} \
+          admin
+
+        ${pkgs.php}/bin/php ${pkg}/share/mediawiki/maintenance/update.php --conf ${mediawikiConfig} --quick
+      '';
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = user;
+        Group = group;
+        PrivateTmp = true;
+      };
+    };
+
+    systemd.services.httpd.after = optional (cfg.database.createLocally && cfg.database.type == "mysql") "mysql.service";
+
+    users.users.${user} = {
+      group = group;
+      isSystemUser = true;
+    };
+
+    environment.systemPackages = [ mediawikiScripts ];
+  };
+}
diff --git a/nixos/modules/services/web-apps/miniflux.nix b/nixos/modules/services/web-apps/miniflux.nix
new file mode 100644
index 00000000000..641c9be85d8
--- /dev/null
+++ b/nixos/modules/services/web-apps/miniflux.nix
@@ -0,0 +1,127 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.miniflux;
+
+  defaultAddress = "localhost:8080";
+
+  dbUser = "miniflux";
+  dbName = "miniflux";
+
+  pgbin = "${config.services.postgresql.package}/bin";
+  preStart = pkgs.writeScript "miniflux-pre-start" ''
+    #!${pkgs.runtimeShell}
+    ${pgbin}/psql "${dbName}" -c "CREATE EXTENSION IF NOT EXISTS hstore"
+  '';
+in
+
+{
+  options = {
+    services.miniflux = {
+      enable = mkEnableOption "miniflux and creates a local postgres database for it";
+
+      config = mkOption {
+        type = types.attrsOf types.str;
+        example = literalExpression ''
+          {
+            CLEANUP_FREQUENCY = "48";
+            LISTEN_ADDR = "localhost:8080";
+          }
+        '';
+        description = ''
+          Configuration for Miniflux, refer to
+          <link xlink:href="https://miniflux.app/docs/configuration.html"/>
+          for documentation on the supported values.
+
+          Correct configuration for the database is already provided.
+          By default, listens on ${defaultAddress}.
+        '';
+      };
+
+      adminCredentialsFile = mkOption  {
+        type = types.path;
+        description = ''
+          File containing the ADMIN_USERNAME and
+          ADMIN_PASSWORD (length >= 6) in the format of
+          an EnvironmentFile=, as described by systemd.exec(5).
+        '';
+        example = "/etc/nixos/miniflux-admin-credentials";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+
+    services.miniflux.config =  {
+      LISTEN_ADDR = mkDefault defaultAddress;
+      DATABASE_URL = "user=${dbUser} host=/run/postgresql dbname=${dbName}";
+      RUN_MIGRATIONS = "1";
+      CREATE_ADMIN = "1";
+    };
+
+    services.postgresql = {
+      enable = true;
+      ensureUsers = [ {
+        name = dbUser;
+        ensurePermissions = {
+          "DATABASE ${dbName}" = "ALL PRIVILEGES";
+        };
+      } ];
+      ensureDatabases = [ dbName ];
+    };
+
+    systemd.services.miniflux-dbsetup = {
+      description = "Miniflux database setup";
+      requires = [ "postgresql.service" ];
+      after = [ "network.target" "postgresql.service" ];
+      serviceConfig = {
+        Type = "oneshot";
+        User = config.services.postgresql.superUser;
+        ExecStart = preStart;
+      };
+    };
+
+    systemd.services.miniflux = {
+      description = "Miniflux service";
+      wantedBy = [ "multi-user.target" ];
+      requires = [ "miniflux-dbsetup.service" ];
+      after = [ "network.target" "postgresql.service" "miniflux-dbsetup.service" ];
+
+      serviceConfig = {
+        ExecStart = "${pkgs.miniflux}/bin/miniflux";
+        User = dbUser;
+        DynamicUser = true;
+        RuntimeDirectory = "miniflux";
+        RuntimeDirectoryMode = "0700";
+        EnvironmentFile = cfg.adminCredentialsFile;
+        # Hardening
+        CapabilityBoundingSet = [ "" ];
+        DeviceAllow = [ "" ];
+        LockPersonality = true;
+        MemoryDenyWriteExecute = true;
+        PrivateDevices = true;
+        PrivateUsers = true;
+        ProcSubset = "pid";
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+        RestrictNamespaces = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
+        UMask = "0077";
+      };
+
+      environment = cfg.config;
+    };
+    environment.systemPackages = [ pkgs.miniflux ];
+  };
+}
diff --git a/nixos/modules/services/web-apps/moodle.nix b/nixos/modules/services/web-apps/moodle.nix
new file mode 100644
index 00000000000..19f3e754691
--- /dev/null
+++ b/nixos/modules/services/web-apps/moodle.nix
@@ -0,0 +1,315 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types;
+  inherit (lib) concatStringsSep literalExpression 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" ''
+  <?php  // Moodle configuration file
+
+  unset($CFG);
+  global $CFG;
+  $CFG = new stdClass();
+
+  $CFG->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.addSSL || cfg.virtualHost.forceSSL || cfg.virtualHost.onlySSL 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 = '${phpExt}/bin/php';
+  $CFG->pathtodu = '${pkgs.coreutils}/bin/du';
+  $CFG->aspellpath = '${pkgs.aspell}/bin/aspell';
+  $CFG->pathtodot = '${pkgs.graphviz}/bin/dot';
+
+  ${cfg.extraConfig}
+
+  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";
+
+  phpExt = pkgs.php74.withExtensions
+        ({ enabled, all }: with all; [ iconv mbstring curl openssl tokenizer xmlrpc soap ctype zip gd simplexml dom  intl json sqlite3 pgsql pdo_sqlite pdo_pgsql pdo_odbc pdo_mysql pdo mysqli session zlib xmlreader fileinfo filter opcache ]);
+in
+{
+  # interface
+  options.services.moodle = {
+    enable = mkEnableOption "Moodle web application";
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.moodle;
+      defaultText = literalExpression "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 = literalExpression "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
+          <option>database.user</option>.
+        '';
+      };
+
+      socket = mkOption {
+        type = types.nullOr types.path;
+        default =
+          if mysqlLocal then "/run/mysqld/mysqld.sock"
+          else if pgsqlLocal then "/run/postgresql"
+          else null;
+        defaultText = literalExpression "/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 (import ../web-servers/apache-httpd/vhost-options.nix);
+      example = literalExpression ''
+        {
+          hostName = "moodle.example.org";
+          adminAddr = "webmaster@example.org";
+          forceSSL = true;
+          enableACME = true;
+        }
+      '';
+      description = ''
+        Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>.
+        See <xref linkend="opt-services.httpd.virtualHosts"/> 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 <literal>php-fpm.conf</literal>
+        for details on configuration directives.
+      '';
+    };
+
+    extraConfig = mkOption {
+      type = types.lines;
+      default = "";
+      description = ''
+        Any additional text to be appended to the config.php
+        configuration file. This is a PHP script. For configuration
+        details, see <link xlink:href="https://docs.moodle.org/37/en/Configuration_file"/>.
+      '';
+      example = ''
+        $CFG->disableupdatenotifications = true;
+      '';
+    };
+  };
+
+  # 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;
+      phpPackage = phpExt;
+      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.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost {
+        documentRoot = mkForce "${cfg.package}/share/moodle";
+        extraConfig = ''
+          <Directory "${cfg.package}/share/moodle">
+            <FilesMatch "\.php$">
+              <If "-f %{REQUEST_FILENAME}">
+                SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
+              </If>
+            </FilesMatch>
+            Options -Indexes
+            DirectoryIndex index.php
+          </Directory>
+        '';
+      } ];
+    };
+
+    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 = ''
+        ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/check_database_schema.php && rc=$? || rc=$?
+
+        [ "$rc" == 1 ] && ${phpExt}/bin/php ${cfg.package}/share/moodle/admin/cli/upgrade.php \
+          --non-interactive \
+          --allow-unstable
+
+        [ "$rc" == 2 ] && ${phpExt}/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 = "${phpExt}/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;
+      isSystemUser = true;
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/nextcloud.nix b/nixos/modules/services/web-apps/nextcloud.nix
new file mode 100644
index 00000000000..b32220a5e57
--- /dev/null
+++ b/nixos/modules/services/web-apps/nextcloud.nix
@@ -0,0 +1,933 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.nextcloud;
+  fpm = config.services.phpfpm.pools.nextcloud;
+
+  inherit (cfg) datadir;
+
+  phpPackage = cfg.phpPackage.buildEnv {
+    extensions = { enabled, all }:
+      (with all;
+        enabled
+        ++ optional cfg.enableImagemagick imagick
+        # Optionally enabled depending on caching settings
+        ++ optional cfg.caching.apcu apcu
+        ++ optional cfg.caching.redis redis
+        ++ optional cfg.caching.memcached memcached
+      )
+      ++ cfg.phpExtraExtensions all; # Enabled by user
+    extraConfig = toKeyValue phpOptions;
+  };
+
+  toKeyValue = generators.toKeyValue {
+    mkKeyValue = generators.mkKeyValueDefault {} " = ";
+  };
+
+  phpOptions = {
+    upload_max_filesize = cfg.maxUploadSize;
+    post_max_size = cfg.maxUploadSize;
+    memory_limit = cfg.maxUploadSize;
+  } // cfg.phpOptions
+    // optionalAttrs cfg.caching.apcu {
+      "apc.enable_cli" = "1";
+    };
+
+  occ = pkgs.writeScriptBin "nextcloud-occ" ''
+    #! ${pkgs.runtimeShell}
+    cd ${cfg.package}
+    sudo=exec
+    if [[ "$USER" != nextcloud ]]; then
+      sudo='exec /run/wrappers/bin/sudo -u nextcloud --preserve-env=NEXTCLOUD_CONFIG_DIR --preserve-env=OC_PASS'
+    fi
+    export NEXTCLOUD_CONFIG_DIR="${datadir}/config"
+    $sudo \
+      ${phpPackage}/bin/php \
+      occ "$@"
+  '';
+
+  inherit (config.system) stateVersion;
+
+in {
+
+  imports = [
+    (mkRemovedOptionModule [ "services" "nextcloud" "config" "adminpass" ] ''
+      Please use `services.nextcloud.config.adminpassFile' instead!
+    '')
+    (mkRemovedOptionModule [ "services" "nextcloud" "config" "dbpass" ] ''
+      Please use `services.nextcloud.config.dbpassFile' instead!
+    '')
+    (mkRemovedOptionModule [ "services" "nextcloud" "nginx" "enable" ] ''
+      The nextcloud module supports `nginx` as reverse-proxy by default and doesn't
+      support other reverse-proxies officially.
+
+      However it's possible to use an alternative reverse-proxy by
+
+        * disabling nginx
+        * setting `listen.owner` & `listen.group` in the phpfpm-pool to a different value
+
+      Further details about this can be found in the `Nextcloud`-section of the NixOS-manual
+      (which can be openend e.g. by running `nixos-help`).
+    '')
+    (mkRemovedOptionModule [ "services" "nextcloud" "disableImagemagick" ] ''
+      Use services.nextcloud.nginx.enableImagemagick instead.
+    '')
+  ];
+
+  options.services.nextcloud = {
+    enable = mkEnableOption "nextcloud";
+    hostName = mkOption {
+      type = types.str;
+      description = "FQDN for the nextcloud instance.";
+    };
+    home = mkOption {
+      type = types.str;
+      default = "/var/lib/nextcloud";
+      description = "Storage path of nextcloud.";
+    };
+    datadir = mkOption {
+      type = types.str;
+      defaultText = "config.services.nextcloud.home";
+      description = ''
+        Data storage path of nextcloud.  Will be <xref linkend="opt-services.nextcloud.home" /> by default.
+        This folder will be populated with a config.php and data folder which contains the state of the instance (excl the database).";
+      '';
+      example = "/mnt/nextcloud-file";
+    };
+    extraApps = mkOption {
+      type = types.attrsOf types.package;
+      default = { };
+      description = ''
+        Extra apps to install. Should be an attrSet of appid to packages generated by fetchNextcloudApp.
+        The appid must be identical to the "id" value in the apps appinfo/info.xml.
+        Using this will disable the appstore to prevent Nextcloud from updating these apps (see <xref linkend="opt-services.nextcloud.appstoreEnable" />).
+      '';
+      example = literalExpression ''
+        {
+          maps = pkgs.fetchNextcloudApp {
+            name = "maps";
+            sha256 = "007y80idqg6b6zk6kjxg4vgw0z8fsxs9lajnv49vv1zjy6jx2i1i";
+            url = "https://github.com/nextcloud/maps/releases/download/v0.1.9/maps-0.1.9.tar.gz";
+            version = "0.1.9";
+          };
+          phonetrack = pkgs.fetchNextcloudApp {
+            name = "phonetrack";
+            sha256 = "0qf366vbahyl27p9mshfma1as4nvql6w75zy2zk5xwwbp343vsbc";
+            url = "https://gitlab.com/eneiluj/phonetrack-oc/-/wikis/uploads/931aaaf8dca24bf31a7e169a83c17235/phonetrack-0.6.9.tar.gz";
+            version = "0.6.9";
+          };
+        }
+        '';
+    };
+    extraAppsEnable = mkOption {
+      type = types.bool;
+      default = true;
+      description = ''
+        Automatically enable the apps in <xref linkend="opt-services.nextcloud.extraApps" /> every time nextcloud starts.
+        If set to false, apps need to be enabled in the Nextcloud user interface or with nextcloud-occ app:enable.
+      '';
+    };
+    appstoreEnable = mkOption {
+      type = types.nullOr types.bool;
+      default = null;
+      example = true;
+      description = ''
+        Allow the installation of apps and app updates from the store.
+        Enabled by default unless there are packages in <xref linkend="opt-services.nextcloud.extraApps" />.
+        Set to true to force enable the store even if <xref linkend="opt-services.nextcloud.extraApps" /> is used.
+        Set to false to disable the installation of apps from the global appstore. App management is always enabled regardless of this setting.
+      '';
+    };
+    logLevel = mkOption {
+      type = types.ints.between 0 4;
+      default = 2;
+      description = "Log level value between 0 (DEBUG) and 4 (FATAL).";
+    };
+    https = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Use https for generated links.";
+    };
+    package = mkOption {
+      type = types.package;
+      description = "Which package to use for the Nextcloud instance.";
+      relatedPackages = [ "nextcloud22" "nextcloud23" ];
+    };
+    phpPackage = mkOption {
+      type = types.package;
+      relatedPackages = [ "php74" "php80" ];
+      defaultText = "pkgs.php";
+      description = ''
+        PHP package to use for Nextcloud.
+      '';
+    };
+
+    maxUploadSize = mkOption {
+      default = "512M";
+      type = types.str;
+      description = ''
+        Defines the upload limit for files. This changes the relevant options
+        in php.ini and nginx if enabled.
+      '';
+    };
+
+    skeletonDirectory = mkOption {
+      default = "";
+      type = types.str;
+      description = ''
+        The directory where the skeleton files are located. These files will be
+        copied to the data directory of new users. Leave empty to not copy any
+        skeleton files.
+      '';
+    };
+
+    webfinger = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Enable this option if you plan on using the webfinger plugin.
+        The appropriate nginx rewrite rules will be added to your configuration.
+      '';
+    };
+
+    phpExtraExtensions = mkOption {
+      type = with types; functionTo (listOf package);
+      default = all: [];
+      defaultText = literalExpression "all: []";
+      description = ''
+        Additional PHP extensions to use for nextcloud.
+        By default, only extensions necessary for a vanilla nextcloud installation are enabled,
+        but you may choose from the list of available extensions and add further ones.
+        This is sometimes necessary to be able to install a certain nextcloud app that has additional requirements.
+      '';
+      example = literalExpression ''
+        all: [ all.pdlib all.bz2 ]
+      '';
+    };
+
+    phpOptions = mkOption {
+      type = types.attrsOf types.str;
+      default = {
+        short_open_tag = "Off";
+        expose_php = "Off";
+        error_reporting = "E_ALL & ~E_DEPRECATED & ~E_STRICT";
+        display_errors = "stderr";
+        "opcache.enable_cli" = "1";
+        "opcache.interned_strings_buffer" = "8";
+        "opcache.max_accelerated_files" = "10000";
+        "opcache.memory_consumption" = "128";
+        "opcache.revalidate_freq" = "1";
+        "opcache.fast_shutdown" = "1";
+        "openssl.cafile" = "/etc/ssl/certs/ca-certificates.crt";
+        catch_workers_output = "yes";
+      };
+      description = ''
+        Options for PHP's php.ini file for nextcloud.
+      '';
+    };
+
+    poolSettings = 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 nextcloud's PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives.
+      '';
+    };
+
+    poolConfig = mkOption {
+      type = types.nullOr types.lines;
+      default = null;
+      description = ''
+        Options for nextcloud's PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives.
+      '';
+    };
+
+    config = {
+      dbtype = mkOption {
+        type = types.enum [ "sqlite" "pgsql" "mysql" ];
+        default = "sqlite";
+        description = "Database type.";
+      };
+      dbname = mkOption {
+        type = types.nullOr types.str;
+        default = "nextcloud";
+        description = "Database name.";
+      };
+      dbuser = mkOption {
+        type = types.nullOr types.str;
+        default = "nextcloud";
+        description = "Database user.";
+      };
+      dbpassFile = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = ''
+          The full path to a file that contains the database password.
+        '';
+      };
+      dbhost = mkOption {
+        type = types.nullOr types.str;
+        default = "localhost";
+        description = ''
+          Database host.
+
+          Note: for using Unix authentication with PostgreSQL, this should be
+          set to <literal>/run/postgresql</literal>.
+        '';
+      };
+      dbport = mkOption {
+        type = with types; nullOr (either int str);
+        default = null;
+        description = "Database port.";
+      };
+      dbtableprefix = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "Table prefix in Nextcloud database.";
+      };
+      adminuser = mkOption {
+        type = types.str;
+        default = "root";
+        description = "Admin username.";
+      };
+      adminpassFile = mkOption {
+        type = types.str;
+        description = ''
+          The full path to a file that contains the admin's password. Must be
+          readable by user <literal>nextcloud</literal>.
+        '';
+      };
+
+      extraTrustedDomains = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        description = ''
+          Trusted domains, from which the nextcloud installation will be
+          acessible.  You don't need to add
+          <literal>services.nextcloud.hostname</literal> here.
+        '';
+      };
+
+      trustedProxies = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        description = ''
+          Trusted proxies, to provide if the nextcloud installation is being
+          proxied to secure against e.g. spoofing.
+        '';
+      };
+
+      overwriteProtocol = mkOption {
+        type = types.nullOr (types.enum [ "http" "https" ]);
+        default = null;
+        example = "https";
+
+        description = ''
+          Force Nextcloud to always use HTTPS i.e. for link generation. Nextcloud
+          uses the currently used protocol by default, but when behind a reverse-proxy,
+          it may use <literal>http</literal> for everything although Nextcloud
+          may be served via HTTPS.
+        '';
+      };
+
+      defaultPhoneRegion = mkOption {
+        default = null;
+        type = types.nullOr types.str;
+        example = "DE";
+        description = ''
+          <warning>
+           <para>This option exists since Nextcloud 21! If older versions are used,
+            this will throw an eval-error!</para>
+          </warning>
+
+          <link xlink:href="https://www.iso.org/iso-3166-country-codes.html">ISO 3611-1</link>
+          country codes for automatic phone-number detection without a country code.
+
+          With e.g. <literal>DE</literal> set, the <literal>+49</literal> can be omitted for
+          phone-numbers.
+        '';
+      };
+
+      objectstore = {
+        s3 = {
+          enable = mkEnableOption ''
+            S3 object storage as primary storage.
+
+            This mounts a bucket on an Amazon S3 object storage or compatible
+            implementation into the virtual filesystem.
+
+            Further details about this feature can be found in the
+            <link xlink:href="https://docs.nextcloud.com/server/22/admin_manual/configuration_files/primary_storage.html">upstream documentation</link>.
+          '';
+          bucket = mkOption {
+            type = types.str;
+            example = "nextcloud";
+            description = ''
+              The name of the S3 bucket.
+            '';
+          };
+          autocreate = mkOption {
+            type = types.bool;
+            description = ''
+              Create the objectstore if it does not exist.
+            '';
+          };
+          key = mkOption {
+            type = types.str;
+            example = "EJ39ITYZEUH5BGWDRUFY";
+            description = ''
+              The access key for the S3 bucket.
+            '';
+          };
+          secretFile = mkOption {
+            type = types.str;
+            example = "/var/nextcloud-objectstore-s3-secret";
+            description = ''
+              The full path to a file that contains the access secret. Must be
+              readable by user <literal>nextcloud</literal>.
+            '';
+          };
+          hostname = mkOption {
+            type = types.nullOr types.str;
+            default = null;
+            example = "example.com";
+            description = ''
+              Required for some non-Amazon implementations.
+            '';
+          };
+          port = mkOption {
+            type = types.nullOr types.port;
+            default = null;
+            description = ''
+              Required for some non-Amazon implementations.
+            '';
+          };
+          useSsl = mkOption {
+            type = types.bool;
+            default = true;
+            description = ''
+              Use SSL for objectstore access.
+            '';
+          };
+          region = mkOption {
+            type = types.nullOr types.str;
+            default = null;
+            example = "REGION";
+            description = ''
+              Required for some non-Amazon implementations.
+            '';
+          };
+          usePathStyle = mkOption {
+            type = types.bool;
+            default = false;
+            description = ''
+              Required for some non-Amazon S3 implementations.
+
+              Ordinarily, requests will be made with
+              <literal>http://bucket.hostname.domain/</literal>, but with path style
+              enabled requests are made with
+              <literal>http://hostname.domain/bucket</literal> instead.
+            '';
+          };
+        };
+      };
+    };
+
+    enableImagemagick = mkEnableOption ''
+        the ImageMagick module for PHP.
+        This is used by the theming app and for generating previews of certain images (e.g. SVG and HEIF).
+        You may want to disable it for increased security. In that case, previews will still be available
+        for some images (e.g. JPEG and PNG).
+        See <link xlink:href="https://github.com/nextcloud/server/issues/13099" />.
+    '' // {
+      default = true;
+    };
+
+    caching = {
+      apcu = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Whether to load the APCu module into PHP.
+        '';
+      };
+      redis = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether to load the Redis module into PHP.
+          You still need to enable Redis in your config.php.
+          See https://docs.nextcloud.com/server/14/admin_manual/configuration_server/caching_configuration.html
+        '';
+      };
+      memcached = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether to load the Memcached module into PHP.
+          You still need to enable Memcached in your config.php.
+          See https://docs.nextcloud.com/server/14/admin_manual/configuration_server/caching_configuration.html
+        '';
+      };
+    };
+    autoUpdateApps = {
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Run regular auto update of all apps installed from the nextcloud app store.
+        '';
+      };
+      startAt = mkOption {
+        type = with types; either str (listOf str);
+        default = "05:00:00";
+        example = "Sun 14:00:00";
+        description = ''
+          When to run the update. See `systemd.services.&lt;name&gt;.startAt`.
+        '';
+      };
+    };
+    occ = mkOption {
+      type = types.package;
+      default = occ;
+      defaultText = literalDocBook "generated script";
+      internal = true;
+      description = ''
+        The nextcloud-occ program preconfigured to target this Nextcloud instance.
+      '';
+    };
+
+    nginx.recommendedHttpHeaders = mkOption {
+      type = types.bool;
+      default = true;
+      description = "Enable additional recommended HTTP response headers";
+    };
+  };
+
+  config = mkIf cfg.enable (mkMerge [
+    { warnings = let
+        latest = 23;
+        upgradeWarning = major: nixos:
+          ''
+            A legacy Nextcloud install (from before NixOS ${nixos}) may be installed.
+
+            After nextcloud${toString major} is installed successfully, you can safely upgrade
+            to ${toString (major + 1)}. The latest version available is nextcloud${toString latest}.
+
+            Please note that Nextcloud doesn't support upgrades across multiple major versions
+            (i.e. an upgrade from 16 is possible to 17, but not 16 to 18).
+
+            The package can be upgraded by explicitly declaring the service-option
+            `services.nextcloud.package`.
+          '';
+
+        # FIXME(@Ma27) remove as soon as nextcloud properly supports
+        # mariadb >=10.6.
+        isUnsupportedMariadb =
+          # All currently supported Nextcloud versions are affected (https://github.com/nextcloud/server/issues/25436).
+          (versionOlder cfg.package.version "24")
+          # This module uses mysql
+          && (cfg.config.dbtype == "mysql")
+          # MySQL is managed via NixOS
+          && config.services.mysql.enable
+          # We're using MariaDB
+          && (getName config.services.mysql.package) == "mariadb-server"
+          # MariaDB is at least 10.6 and thus not supported
+          && (versionAtLeast (getVersion config.services.mysql.package) "10.6");
+
+      in (optional (cfg.poolConfig != null) ''
+          Using config.services.nextcloud.poolConfig is deprecated and will become unsupported in a future release.
+          Please migrate your configuration to config.services.nextcloud.poolSettings.
+        '')
+        ++ (optional (versionOlder cfg.package.version "21") (upgradeWarning 20 "21.05"))
+        ++ (optional (versionOlder cfg.package.version "22") (upgradeWarning 21 "21.11"))
+        ++ (optional (versionOlder cfg.package.version "23") (upgradeWarning 22 "22.05"))
+        ++ (optional isUnsupportedMariadb ''
+            You seem to be using MariaDB at an unsupported version (i.e. at least 10.6)!
+            Please note that this isn't supported officially by Nextcloud. You can either
+
+            * Switch to `pkgs.mysql`
+            * Downgrade MariaDB to at least 10.5
+            * Work around Nextcloud's problems by specifying `innodb_read_only_compressed=0`
+
+            For further context, please read
+            https://help.nextcloud.com/t/update-to-next-cloud-21-0-2-has-get-an-error/117028/15
+          '');
+
+      services.nextcloud.package = with pkgs;
+        mkDefault (
+          if pkgs ? nextcloud
+            then throw ''
+              The `pkgs.nextcloud`-attribute has been removed. If it's supposed to be the default
+              nextcloud defined in an overlay, please set `services.nextcloud.package` to
+              `pkgs.nextcloud`.
+            ''
+          else if versionOlder stateVersion "21.11" then nextcloud21
+          else if versionOlder stateVersion "22.05" then nextcloud22
+          else nextcloud23
+        );
+
+      services.nextcloud.datadir = mkOptionDefault config.services.nextcloud.home;
+
+      services.nextcloud.phpPackage =
+        if versionOlder cfg.package.version "21" then pkgs.php74
+        else pkgs.php80;
+    }
+
+    { systemd.timers.nextcloud-cron = {
+        wantedBy = [ "timers.target" ];
+        timerConfig.OnBootSec = "5m";
+        timerConfig.OnUnitActiveSec = "5m";
+        timerConfig.Unit = "nextcloud-cron.service";
+      };
+
+      systemd.tmpfiles.rules = ["d ${cfg.home} 0750 nextcloud nextcloud"];
+
+      systemd.services = {
+        # When upgrading the Nextcloud package, Nextcloud can report errors such as
+        # "The files of the app [all apps in /var/lib/nextcloud/apps] were not replaced correctly"
+        # Restarting phpfpm on Nextcloud package update fixes these issues (but this is a workaround).
+        phpfpm-nextcloud.restartTriggers = [ cfg.package ];
+
+        nextcloud-setup = let
+          c = cfg.config;
+          writePhpArrary = a: "[${concatMapStringsSep "," (val: ''"${toString val}"'') a}]";
+          requiresReadSecretFunction = c.dbpassFile != null || c.objectstore.s3.enable;
+          objectstoreConfig = let s3 = c.objectstore.s3; in optionalString s3.enable ''
+            'objectstore' => [
+              'class' => '\\OC\\Files\\ObjectStore\\S3',
+              'arguments' => [
+                'bucket' => '${s3.bucket}',
+                'autocreate' => ${boolToString s3.autocreate},
+                'key' => '${s3.key}',
+                'secret' => nix_read_secret('${s3.secretFile}'),
+                ${optionalString (s3.hostname != null) "'hostname' => '${s3.hostname}',"}
+                ${optionalString (s3.port != null) "'port' => ${toString s3.port},"}
+                'use_ssl' => ${boolToString s3.useSsl},
+                ${optionalString (s3.region != null) "'region' => '${s3.region}',"}
+                'use_path_style' => ${boolToString s3.usePathStyle},
+              ],
+            ]
+          '';
+
+          showAppStoreSetting = cfg.appstoreEnable != null || cfg.extraApps != {};
+          renderedAppStoreSetting =
+            let
+              x = cfg.appstoreEnable;
+            in
+              if x == null then "false"
+              else boolToString x;
+
+          overrideConfig = pkgs.writeText "nextcloud-config.php" ''
+            <?php
+            ${optionalString requiresReadSecretFunction ''
+              function nix_read_secret($file) {
+                if (!file_exists($file)) {
+                  throw new \RuntimeException(sprintf(
+                    "Cannot start Nextcloud, secret file %s set by NixOS doesn't seem to "
+                    . "exist! Please make sure that the file exists and has appropriate "
+                    . "permissions for user & group 'nextcloud'!",
+                    $file
+                  ));
+                }
+
+                return trim(file_get_contents($file));
+              }
+            ''}
+            $CONFIG = [
+              'apps_paths' => [
+                ${optionalString (cfg.extraApps != { }) "[ 'path' => '${cfg.home}/nix-apps', 'url' => '/nix-apps', 'writable' => false ],"}
+                [ 'path' => '${cfg.home}/apps', 'url' => '/apps', 'writable' => false ],
+                [ 'path' => '${cfg.home}/store-apps', 'url' => '/store-apps', 'writable' => true ],
+              ],
+              ${optionalString (showAppStoreSetting) "'appstoreenabled' => ${renderedAppStoreSetting},"}
+              'datadirectory' => '${datadir}/data',
+              'skeletondirectory' => '${cfg.skeletonDirectory}',
+              ${optionalString cfg.caching.apcu "'memcache.local' => '\\OC\\Memcache\\APCu',"}
+              'log_type' => 'syslog',
+              'log_level' => '${builtins.toString cfg.logLevel}',
+              ${optionalString (c.overwriteProtocol != null) "'overwriteprotocol' => '${c.overwriteProtocol}',"}
+              ${optionalString (c.dbname != null) "'dbname' => '${c.dbname}',"}
+              ${optionalString (c.dbhost != null) "'dbhost' => '${c.dbhost}',"}
+              ${optionalString (c.dbport != null) "'dbport' => '${toString c.dbport}',"}
+              ${optionalString (c.dbuser != null) "'dbuser' => '${c.dbuser}',"}
+              ${optionalString (c.dbtableprefix != null) "'dbtableprefix' => '${toString c.dbtableprefix}',"}
+              ${optionalString (c.dbpassFile != null) "'dbpassword' => nix_read_secret('${c.dbpassFile}'),"}
+              'dbtype' => '${c.dbtype}',
+              'trusted_domains' => ${writePhpArrary ([ cfg.hostName ] ++ c.extraTrustedDomains)},
+              'trusted_proxies' => ${writePhpArrary (c.trustedProxies)},
+              ${optionalString (c.defaultPhoneRegion != null) "'default_phone_region' => '${c.defaultPhoneRegion}',"}
+              ${objectstoreConfig}
+            ];
+          '';
+          occInstallCmd = let
+            mkExport = { arg, value }: "export ${arg}=${value}";
+            dbpass = {
+              arg = "DBPASS";
+              value = if c.dbpassFile != null
+                then ''"$(<"${toString c.dbpassFile}")"''
+                else ''""'';
+            };
+            adminpass = {
+              arg = "ADMINPASS";
+              value = ''"$(<"${toString c.adminpassFile}")"'';
+            };
+            installFlags = concatStringsSep " \\\n    "
+              (mapAttrsToList (k: v: "${k} ${toString v}") {
+              "--database" = ''"${c.dbtype}"'';
+              # The following attributes are optional depending on the type of
+              # database.  Those that evaluate to null on the left hand side
+              # will be omitted.
+              ${if c.dbname != null then "--database-name" else null} = ''"${c.dbname}"'';
+              ${if c.dbhost != null then "--database-host" else null} = ''"${c.dbhost}"'';
+              ${if c.dbport != null then "--database-port" else null} = ''"${toString c.dbport}"'';
+              ${if c.dbuser != null then "--database-user" else null} = ''"${c.dbuser}"'';
+              "--database-pass" = "\$${dbpass.arg}";
+              "--admin-user" = ''"${c.adminuser}"'';
+              "--admin-pass" = "\$${adminpass.arg}";
+              "--data-dir" = ''"${datadir}/data"'';
+            });
+          in ''
+            ${mkExport dbpass}
+            ${mkExport adminpass}
+            ${occ}/bin/nextcloud-occ maintenance:install \
+                ${installFlags}
+          '';
+          occSetTrustedDomainsCmd = concatStringsSep "\n" (imap0
+            (i: v: ''
+              ${occ}/bin/nextcloud-occ config:system:set trusted_domains \
+                ${toString i} --value="${toString v}"
+            '') ([ cfg.hostName ] ++ cfg.config.extraTrustedDomains));
+
+        in {
+          wantedBy = [ "multi-user.target" ];
+          before = [ "phpfpm-nextcloud.service" ];
+          path = [ occ ];
+          script = ''
+            ${optionalString (c.dbpassFile != null) ''
+              if [ ! -r "${c.dbpassFile}" ]; then
+                echo "dbpassFile ${c.dbpassFile} is not readable by nextcloud:nextcloud! Aborting..."
+                exit 1
+              fi
+              if [ -z "$(<${c.dbpassFile})" ]; then
+                echo "dbpassFile ${c.dbpassFile} is empty!"
+                exit 1
+              fi
+            ''}
+            if [ ! -r "${c.adminpassFile}" ]; then
+              echo "adminpassFile ${c.adminpassFile} is not readable by nextcloud:nextcloud! Aborting..."
+              exit 1
+            fi
+            if [ -z "$(<${c.adminpassFile})" ]; then
+              echo "adminpassFile ${c.adminpassFile} is empty!"
+              exit 1
+            fi
+
+            ln -sf ${cfg.package}/apps ${cfg.home}/
+
+            # Install extra apps
+            ln -sfT \
+              ${pkgs.linkFarm "nix-apps"
+                (mapAttrsToList (name: path: { inherit name path; }) cfg.extraApps)} \
+              ${cfg.home}/nix-apps
+
+            # create nextcloud directories.
+            # if the directories exist already with wrong permissions, we fix that
+            for dir in ${datadir}/config ${datadir}/data ${cfg.home}/store-apps ${cfg.home}/nix-apps; do
+              if [ ! -e $dir ]; then
+                install -o nextcloud -g nextcloud -d $dir
+              elif [ $(stat -c "%G" $dir) != "nextcloud" ]; then
+                chgrp -R nextcloud $dir
+              fi
+            done
+
+            ln -sf ${overrideConfig} ${datadir}/config/override.config.php
+
+            # Do not install if already installed
+            if [[ ! -e ${datadir}/config/config.php ]]; then
+              ${occInstallCmd}
+            fi
+
+            ${occ}/bin/nextcloud-occ upgrade
+
+            ${occ}/bin/nextcloud-occ config:system:delete trusted_domains
+
+            ${optionalString (cfg.extraAppsEnable && cfg.extraApps != { }) ''
+                # Try to enable apps (don't fail when one of them cannot be enabled , eg. due to incompatible version)
+                ${occ}/bin/nextcloud-occ app:enable ${concatStringsSep " " (attrNames cfg.extraApps)}
+            ''}
+
+            ${occSetTrustedDomainsCmd}
+          '';
+          serviceConfig.Type = "oneshot";
+          serviceConfig.User = "nextcloud";
+        };
+        nextcloud-cron = {
+          environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
+          serviceConfig.Type = "oneshot";
+          serviceConfig.User = "nextcloud";
+          serviceConfig.ExecStart = "${phpPackage}/bin/php -f ${cfg.package}/cron.php";
+        };
+        nextcloud-update-plugins = mkIf cfg.autoUpdateApps.enable {
+          serviceConfig.Type = "oneshot";
+          serviceConfig.ExecStart = "${occ}/bin/nextcloud-occ app:update --all";
+          serviceConfig.User = "nextcloud";
+          startAt = cfg.autoUpdateApps.startAt;
+        };
+      };
+
+      services.phpfpm = {
+        pools.nextcloud = {
+          user = "nextcloud";
+          group = "nextcloud";
+          phpPackage = phpPackage;
+          phpEnv = {
+            NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
+            PATH = "/run/wrappers/bin:/nix/var/nix/profiles/default/bin:/run/current-system/sw/bin:/usr/bin:/bin";
+          };
+          settings = mapAttrs (name: mkDefault) {
+            "listen.owner" = config.services.nginx.user;
+            "listen.group" = config.services.nginx.group;
+          } // cfg.poolSettings;
+          extraConfig = cfg.poolConfig;
+        };
+      };
+
+      users.users.nextcloud = {
+        home = "${cfg.home}";
+        group = "nextcloud";
+        isSystemUser = true;
+      };
+      users.groups.nextcloud.members = [ "nextcloud" config.services.nginx.user ];
+
+      environment.systemPackages = [ occ ];
+
+      services.nginx.enable = mkDefault true;
+
+      services.nginx.virtualHosts.${cfg.hostName} = {
+        root = cfg.package;
+        locations = {
+          "= /robots.txt" = {
+            priority = 100;
+            extraConfig = ''
+              allow all;
+              log_not_found off;
+              access_log off;
+            '';
+          };
+          "= /" = {
+            priority = 100;
+            extraConfig = ''
+              if ( $http_user_agent ~ ^DavClnt ) {
+                return 302 /remote.php/webdav/$is_args$args;
+              }
+            '';
+          };
+          "/" = {
+            priority = 900;
+            extraConfig = "rewrite ^ /index.php;";
+          };
+          "~ ^/store-apps" = {
+            priority = 201;
+            extraConfig = "root ${cfg.home};";
+          };
+          "~ ^/nix-apps" = {
+            priority = 201;
+            extraConfig = "root ${cfg.home};";
+          };
+          "^~ /.well-known" = {
+            priority = 210;
+            extraConfig = ''
+              absolute_redirect off;
+              location = /.well-known/carddav {
+                return 301 /remote.php/dav;
+              }
+              location = /.well-known/caldav {
+                return 301 /remote.php/dav;
+              }
+              location ~ ^/\.well-known/(?!acme-challenge|pki-validation) {
+                return 301 /index.php$request_uri;
+              }
+              try_files $uri $uri/ =404;
+            '';
+          };
+          "~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/)".extraConfig = ''
+            return 404;
+          '';
+          "~ ^/(?:\\.(?!well-known)|autotest|occ|issue|indie|db_|console)".extraConfig = ''
+            return 404;
+          '';
+          "~ ^\\/(?:index|remote|public|cron|core\\/ajax\\/update|status|ocs\\/v[12]|updater\\/.+|oc[ms]-provider\\/.+|.+\\/richdocumentscode\\/proxy)\\.php(?:$|\\/)" = {
+            priority = 500;
+            extraConfig = ''
+              include ${config.services.nginx.package}/conf/fastcgi.conf;
+              fastcgi_split_path_info ^(.+?\.php)(\\/.*)$;
+              set $path_info $fastcgi_path_info;
+              try_files $fastcgi_script_name =404;
+              fastcgi_param PATH_INFO $path_info;
+              fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+              fastcgi_param HTTPS ${if cfg.https then "on" else "off"};
+              fastcgi_param modHeadersAvailable true;
+              fastcgi_param front_controller_active true;
+              fastcgi_pass unix:${fpm.socket};
+              fastcgi_intercept_errors on;
+              fastcgi_request_buffering off;
+              fastcgi_read_timeout 120s;
+            '';
+          };
+          "~ \\.(?:css|js|woff2?|svg|gif|map)$".extraConfig = ''
+            try_files $uri /index.php$request_uri;
+            expires 6M;
+            access_log off;
+          '';
+          "~ ^\\/(?:updater|ocs-provider|ocm-provider)(?:$|\\/)".extraConfig = ''
+            try_files $uri/ =404;
+            index index.php;
+          '';
+          "~ \\.(?:png|html|ttf|ico|jpg|jpeg|bcmap|mp4|webm)$".extraConfig = ''
+            try_files $uri /index.php$request_uri;
+            access_log off;
+          '';
+        };
+        extraConfig = ''
+          index index.php index.html /index.php$request_uri;
+          ${optionalString (cfg.nginx.recommendedHttpHeaders) ''
+            add_header X-Content-Type-Options nosniff;
+            add_header X-XSS-Protection "1; mode=block";
+            add_header X-Robots-Tag none;
+            add_header X-Download-Options noopen;
+            add_header X-Permitted-Cross-Domain-Policies none;
+            add_header X-Frame-Options sameorigin;
+            add_header Referrer-Policy no-referrer;
+            add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
+          ''}
+          client_max_body_size ${cfg.maxUploadSize};
+          fastcgi_buffers 64 4K;
+          fastcgi_hide_header X-Powered-By;
+          gzip on;
+          gzip_vary on;
+          gzip_comp_level 4;
+          gzip_min_length 256;
+          gzip_proxied expired no-cache no-store private no_last_modified no_etag auth;
+          gzip_types application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
+
+          ${optionalString cfg.webfinger ''
+            rewrite ^/.well-known/host-meta /public.php?service=host-meta last;
+            rewrite ^/.well-known/host-meta.json /public.php?service=host-meta-json last;
+          ''}
+        '';
+      };
+    }
+  ]);
+
+  meta.doc = ./nextcloud.xml;
+}
diff --git a/nixos/modules/services/web-apps/nextcloud.xml b/nixos/modules/services/web-apps/nextcloud.xml
new file mode 100644
index 00000000000..8f55086a2bd
--- /dev/null
+++ b/nixos/modules/services/web-apps/nextcloud.xml
@@ -0,0 +1,291 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-nextcloud">
+ <title>Nextcloud</title>
+ <para>
+  <link xlink:href="https://nextcloud.com/">Nextcloud</link> is an open-source,
+  self-hostable cloud platform. The server setup can be automated using
+  <link linkend="opt-services.nextcloud.enable">services.nextcloud</link>. A
+  desktop client is packaged at <literal>pkgs.nextcloud-client</literal>.
+ </para>
+ <para>
+  The current default by NixOS is <package>nextcloud23</package> which is also the latest
+  major version available.
+ </para>
+ <section xml:id="module-services-nextcloud-basic-usage">
+  <title>Basic usage</title>
+
+  <para>
+   Nextcloud is a PHP-based application which requires an HTTP server
+   (<literal><link linkend="opt-services.nextcloud.enable">services.nextcloud</link></literal>
+   optionally supports
+   <literal><link linkend="opt-services.nginx.enable">services.nginx</link></literal>)
+   and a database (it's recommended to use
+   <literal><link linkend="opt-services.postgresql.enable">services.postgresql</link></literal>).
+  </para>
+
+  <para>
+   A very basic configuration may look like this:
+<programlisting>{ pkgs, ... }:
+{
+  services.nextcloud = {
+    <link linkend="opt-services.nextcloud.enable">enable</link> = true;
+    <link linkend="opt-services.nextcloud.hostName">hostName</link> = "nextcloud.tld";
+    config = {
+      <link linkend="opt-services.nextcloud.config.dbtype">dbtype</link> = "pgsql";
+      <link linkend="opt-services.nextcloud.config.dbuser">dbuser</link> = "nextcloud";
+      <link linkend="opt-services.nextcloud.config.dbhost">dbhost</link> = "/run/postgresql"; # nextcloud will add /.s.PGSQL.5432 by itself
+      <link linkend="opt-services.nextcloud.config.dbname">dbname</link> = "nextcloud";
+      <link linkend="opt-services.nextcloud.config.adminpassFile">adminpassFile</link> = "/path/to/admin-pass-file";
+      <link linkend="opt-services.nextcloud.config.adminuser">adminuser</link> = "root";
+    };
+  };
+
+  services.postgresql = {
+    <link linkend="opt-services.postgresql.enable">enable</link> = true;
+    <link linkend="opt-services.postgresql.ensureDatabases">ensureDatabases</link> = [ "nextcloud" ];
+    <link linkend="opt-services.postgresql.ensureUsers">ensureUsers</link> = [
+     { name = "nextcloud";
+       ensurePermissions."DATABASE nextcloud" = "ALL PRIVILEGES";
+     }
+    ];
+  };
+
+  # ensure that postgres is running *before* running the setup
+  systemd.services."nextcloud-setup" = {
+    requires = ["postgresql.service"];
+    after = ["postgresql.service"];
+  };
+
+  <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ];
+}</programlisting>
+  </para>
+
+  <para>
+   The <literal>hostName</literal> option is used internally to configure an HTTP
+   server using <literal><link xlink:href="https://php-fpm.org/">PHP-FPM</link></literal>
+   and <literal>nginx</literal>. The <literal>config</literal> attribute set is
+   used by the imperative installer and all values are written to an additional file
+   to ensure that changes can be applied by changing the module's options.
+  </para>
+
+  <para>
+   In case the application serves multiple domains (those are checked with
+   <literal><link xlink:href="http://php.net/manual/en/reserved.variables.server.php">$_SERVER['HTTP_HOST']</link></literal>)
+   it's needed to add them to
+   <literal><link linkend="opt-services.nextcloud.config.extraTrustedDomains">services.nextcloud.config.extraTrustedDomains</link></literal>.
+  </para>
+
+  <para>
+   Auto updates for Nextcloud apps can be enabled using
+   <literal><link linkend="opt-services.nextcloud.autoUpdateApps.enable">services.nextcloud.autoUpdateApps</link></literal>.
+</para>
+
+ </section>
+
+ <section xml:id="module-services-nextcloud-pitfalls-during-upgrade">
+  <title>Common problems</title>
+  <itemizedlist>
+   <listitem>
+    <formalpara>
+     <title>General notes</title>
+     <para>
+      Unfortunately Nextcloud appears to be very stateful when it comes to
+      managing its own configuration. The config file lives in the home directory
+      of the <literal>nextcloud</literal> user (by default
+      <literal>/var/lib/nextcloud/config/config.php</literal>) and is also used to
+      track several states of the application (e.g., whether installed or not).
+     </para>
+    </formalpara>
+    <para>
+     All configuration parameters are also stored in
+     <filename>/var/lib/nextcloud/config/override.config.php</filename> which is generated by
+     the module and linked from the store to ensure that all values from
+     <filename>config.php</filename> can be modified by the module.
+     However <filename>config.php</filename> manages the application's state and shouldn't be
+     touched manually because of that.
+    </para>
+    <warning>
+     <para>Don't delete <filename>config.php</filename>! This file
+     tracks the application's state and a deletion can cause unwanted
+     side-effects!</para>
+    </warning>
+
+    <warning>
+     <para>Don't rerun <literal>nextcloud-occ
+     maintenance:install</literal>! This command tries to install the application
+     and can cause unwanted side-effects!</para>
+    </warning>
+   </listitem>
+   <listitem>
+    <formalpara>
+     <title>Multiple version upgrades</title>
+     <para>
+      Nextcloud doesn't allow to move more than one major-version forward. E.g., if you're on
+      <literal>v16</literal>, you cannot upgrade to <literal>v18</literal>, you need to upgrade to
+      <literal>v17</literal> first. This is ensured automatically as long as the
+      <link linkend="opt-system.stateVersion">stateVersion</link> is declared properly. In that case
+      the oldest version available (one major behind the one from the previous NixOS
+      release) will be selected by default and the module will generate a warning that reminds
+      the user to upgrade to latest Nextcloud <emphasis>after</emphasis> that deploy.
+     </para>
+    </formalpara>
+   </listitem>
+   <listitem>
+    <formalpara>
+     <title><literal>Error: Command "upgrade" is not defined.</literal></title>
+     <para>
+      This error usually occurs if the initial installation
+      (<command>nextcloud-occ maintenance:install</command>) has failed. After that, the application
+      is not installed, but the upgrade is attempted to be executed. Further context can
+      be found in <link xlink:href="https://github.com/NixOS/nixpkgs/issues/111175">NixOS/nixpkgs#111175</link>.
+     </para>
+    </formalpara>
+    <para>
+     First of all, it makes sense to find out what went wrong by looking at the logs
+     of the installation via <command>journalctl -u nextcloud-setup</command> and try to fix
+     the underlying issue.
+    </para>
+    <itemizedlist>
+     <listitem>
+      <para>
+       If this occurs on an <emphasis>existing</emphasis> setup, this is most likely because
+       the maintenance mode is active. It can be deactivated by running
+       <command>nextcloud-occ maintenance:mode --off</command>. It's advisable though to
+       check the logs first on why the maintenance mode was activated.
+      </para>
+     </listitem>
+     <listitem>
+      <warning><para>Only perform the following measures on
+      <emphasis>freshly installed instances!</emphasis></para></warning>
+      <para>
+       A re-run of the installer can be forced by <emphasis>deleting</emphasis>
+       <filename>/var/lib/nextcloud/config/config.php</filename>. This is the only time
+       advisable because the fresh install doesn't have any state that can be lost.
+       In case that doesn't help, an entire re-creation can be forced via
+       <command>rm -rf ~nextcloud/</command>.
+      </para>
+     </listitem>
+    </itemizedlist>
+   </listitem>
+  </itemizedlist>
+ </section>
+
+ <section xml:id="module-services-nextcloud-httpd">
+  <title>Using an alternative webserver as reverse-proxy (e.g. <literal>httpd</literal>)</title>
+  <para>
+   By default, <package>nginx</package> is used as reverse-proxy for <package>nextcloud</package>.
+   However, it's possible to use e.g. <package>httpd</package> by explicitly disabling
+   <package>nginx</package> using <xref linkend="opt-services.nginx.enable" /> and fixing the
+   settings <literal>listen.owner</literal> &amp; <literal>listen.group</literal> in the
+   <link linkend="opt-services.phpfpm.pools">corresponding <literal>phpfpm</literal> pool</link>.
+  </para>
+  <para>
+   An exemplary configuration may look like this:
+<programlisting>{ config, lib, pkgs, ... }: {
+  <link linkend="opt-services.nginx.enable">services.nginx.enable</link> = false;
+  services.nextcloud = {
+    <link linkend="opt-services.nextcloud.enable">enable</link> = true;
+    <link linkend="opt-services.nextcloud.hostName">hostName</link> = "localhost";
+
+    /* further, required options */
+  };
+  <link linkend="opt-services.phpfpm.pools._name_.settings">services.phpfpm.pools.nextcloud.settings</link> = {
+    "listen.owner" = config.services.httpd.user;
+    "listen.group" = config.services.httpd.group;
+  };
+  services.httpd = {
+    <link linkend="opt-services.httpd.enable">enable</link> = true;
+    <link linkend="opt-services.httpd.adminAddr">adminAddr</link> = "webmaster@localhost";
+    <link linkend="opt-services.httpd.extraModules">extraModules</link> = [ "proxy_fcgi" ];
+    virtualHosts."localhost" = {
+      <link linkend="opt-services.httpd.virtualHosts._name_.documentRoot">documentRoot</link> = config.services.nextcloud.package;
+      <link linkend="opt-services.httpd.virtualHosts._name_.extraConfig">extraConfig</link> = ''
+        &lt;Directory "${config.services.nextcloud.package}"&gt;
+          &lt;FilesMatch "\.php$"&gt;
+            &lt;If "-f %{REQUEST_FILENAME}"&gt;
+              SetHandler "proxy:unix:${config.services.phpfpm.pools.nextcloud.socket}|fcgi://localhost/"
+            &lt;/If&gt;
+          &lt;/FilesMatch&gt;
+          &lt;IfModule mod_rewrite.c&gt;
+            RewriteEngine On
+            RewriteBase /
+            RewriteRule ^index\.php$ - [L]
+            RewriteCond %{REQUEST_FILENAME} !-f
+            RewriteCond %{REQUEST_FILENAME} !-d
+            RewriteRule . /index.php [L]
+          &lt;/IfModule&gt;
+          DirectoryIndex index.php
+          Require all granted
+          Options +FollowSymLinks
+        &lt;/Directory&gt;
+      '';
+    };
+  };
+}</programlisting>
+  </para>
+ </section>
+
+ <section xml:id="installing-apps-php-extensions-nextcloud">
+  <title>Installing Apps and PHP extensions</title>
+
+  <para>
+   Nextcloud apps are installed statefully through the web interface.
+
+   Some apps may require extra PHP extensions to be installed.
+   This can be configured with the <xref linkend="opt-services.nextcloud.phpExtraExtensions" /> setting.
+  </para>
+
+  <para>
+   Alternatively, extra apps can also be declared with the <xref linkend="opt-services.nextcloud.extraApps" /> setting.
+   When using this setting, apps can no longer be managed statefully because this can lead to Nextcloud updating apps
+   that are managed by Nix. If you want automatic updates it is recommended that you use web interface to install apps.
+  </para>
+ </section>
+
+ <section xml:id="module-services-nextcloud-maintainer-info">
+  <title>Maintainer information</title>
+
+  <para>
+   As stated in the previous paragraph, we must provide a clean upgrade-path for Nextcloud
+   since it cannot move more than one major version forward on a single upgrade. This chapter
+   adds some notes how Nextcloud updates should be rolled out in the future.
+  </para>
+
+  <para>
+   While minor and patch-level updates are no problem and can be done directly in the
+   package-expression (and should be backported to supported stable branches after that),
+   major-releases should be added in a new attribute (e.g. Nextcloud <literal>v19.0.0</literal>
+   should be available in <literal>nixpkgs</literal> as <literal>pkgs.nextcloud19</literal>).
+   To provide simple upgrade paths it's generally useful to backport those as well to stable
+   branches. As long as the package-default isn't altered, this won't break existing setups.
+   After that, the versioning-warning in the <literal>nextcloud</literal>-module should be
+   updated to make sure that the
+   <link linkend="opt-services.nextcloud.package">package</link>-option selects the latest version
+   on fresh setups.
+  </para>
+
+  <para>
+   If major-releases will be abandoned by upstream, we should check first if those are needed
+   in NixOS for a safe upgrade-path before removing those. In that case we shold keep those
+   packages, but mark them as insecure in an expression like this (in
+   <literal>&lt;nixpkgs/pkgs/servers/nextcloud/default.nix&gt;</literal>):
+<programlisting>/* ... */
+{
+  nextcloud17 = generic {
+    version = "17.0.x";
+    sha256 = "0000000000000000000000000000000000000000000000000000";
+    eol = true;
+  };
+}</programlisting>
+  </para>
+
+  <para>
+   Ideally we should make sure that it's possible to jump two NixOS versions forward:
+   i.e. the warnings and the logic in the module should guard a user to upgrade from a
+   Nextcloud on e.g. 19.09 to a Nextcloud on 20.09.
+  </para>
+ </section>
+</chapter>
diff --git a/nixos/modules/services/web-apps/nexus.nix b/nixos/modules/services/web-apps/nexus.nix
new file mode 100644
index 00000000000..dc50a06705f
--- /dev/null
+++ b/nixos/modules/services/web-apps/nexus.nix
@@ -0,0 +1,156 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.nexus;
+
+in
+
+{
+  options = {
+    services.nexus = {
+      enable = mkEnableOption "Sonatype Nexus3 OSS service";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.nexus;
+        defaultText = literalExpression "pkgs.nexus";
+        description = "Package which runs Nexus3";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "nexus";
+        description = "User which runs Nexus3.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "nexus";
+        description = "Group which runs Nexus3.";
+      };
+
+      home = mkOption {
+        type = types.str;
+        default = "/var/lib/sonatype-work";
+        description = "Home directory of the Nexus3 instance.";
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Address to listen on.";
+      };
+
+      listenPort = mkOption {
+        type = types.int;
+        default = 8081;
+        description = "Port to listen on.";
+      };
+
+      jvmOpts = mkOption {
+        type = types.lines;
+        default = ''
+          -Xms1200M
+          -Xmx1200M
+          -XX:MaxDirectMemorySize=2G
+          -XX:+UnlockDiagnosticVMOptions
+          -XX:+UnsyncloadClass
+          -XX:+LogVMOutput
+          -XX:LogFile=${cfg.home}/nexus3/log/jvm.log
+          -XX:-OmitStackTraceInFastThrow
+          -Djava.net.preferIPv4Stack=true
+          -Dkaraf.home=${cfg.package}
+          -Dkaraf.base=${cfg.package}
+          -Dkaraf.etc=${cfg.package}/etc/karaf
+          -Djava.util.logging.config.file=${cfg.package}/etc/karaf/java.util.logging.properties
+          -Dkaraf.data=${cfg.home}/nexus3
+          -Djava.io.tmpdir=${cfg.home}/nexus3/tmp
+          -Dkaraf.startLocalConsole=false
+          -Djava.endorsed.dirs=${cfg.package}/lib/endorsed
+        '';
+        defaultText = literalExpression ''
+          '''
+            -Xms1200M
+            -Xmx1200M
+            -XX:MaxDirectMemorySize=2G
+            -XX:+UnlockDiagnosticVMOptions
+            -XX:+UnsyncloadClass
+            -XX:+LogVMOutput
+            -XX:LogFile=''${home}/nexus3/log/jvm.log
+            -XX:-OmitStackTraceInFastThrow
+            -Djava.net.preferIPv4Stack=true
+            -Dkaraf.home=''${package}
+            -Dkaraf.base=''${package}
+            -Dkaraf.etc=''${package}/etc/karaf
+            -Djava.util.logging.config.file=''${package}/etc/karaf/java.util.logging.properties
+            -Dkaraf.data=''${home}/nexus3
+            -Djava.io.tmpdir=''${home}/nexus3/tmp
+            -Dkaraf.startLocalConsole=false
+            -Djava.endorsed.dirs=''${package}/lib/endorsed
+          '''
+        '';
+
+        description = ''
+          Options for the JVM written to `nexus.jvmopts`.
+          Please refer to the docs (https://help.sonatype.com/repomanager3/installation/configuring-the-runtime-environment)
+          for further information.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+      home = cfg.home;
+      createHome = true;
+    };
+
+    users.groups.${cfg.group} = {};
+
+    systemd.services.nexus = {
+      description = "Sonatype Nexus3";
+
+      wantedBy = [ "multi-user.target" ];
+
+      path = [ cfg.home ];
+
+      environment = {
+        NEXUS_USER = cfg.user;
+        NEXUS_HOME = cfg.home;
+
+        VM_OPTS_FILE = pkgs.writeText "nexus.vmoptions" cfg.jvmOpts;
+      };
+
+      preStart = ''
+        mkdir -p ${cfg.home}/nexus3/etc
+
+        if [ ! -f ${cfg.home}/nexus3/etc/nexus.properties ]; then
+          echo "# Jetty section" > ${cfg.home}/nexus3/etc/nexus.properties
+          echo "application-port=${toString cfg.listenPort}" >> ${cfg.home}/nexus3/etc/nexus.properties
+          echo "application-host=${toString cfg.listenAddress}" >> ${cfg.home}/nexus3/etc/nexus.properties
+        else
+          sed 's/^application-port=.*/application-port=${toString cfg.listenPort}/' -i ${cfg.home}/nexus3/etc/nexus.properties
+          sed 's/^# application-port=.*/application-port=${toString cfg.listenPort}/' -i ${cfg.home}/nexus3/etc/nexus.properties
+          sed 's/^application-host=.*/application-host=${toString cfg.listenAddress}/' -i ${cfg.home}/nexus3/etc/nexus.properties
+          sed 's/^# application-host=.*/application-host=${toString cfg.listenAddress}/' -i ${cfg.home}/nexus3/etc/nexus.properties
+        fi
+      '';
+
+      script = "${cfg.package}/bin/nexus run";
+
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        PrivateTmp = true;
+        LimitNOFILE = 102642;
+      };
+    };
+  };
+
+  meta.maintainers = with lib.maintainers; [ ironpinguin ];
+}
diff --git a/nixos/modules/services/web-apps/node-red.nix b/nixos/modules/services/web-apps/node-red.nix
new file mode 100644
index 00000000000..4512907f027
--- /dev/null
+++ b/nixos/modules/services/web-apps/node-red.nix
@@ -0,0 +1,149 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.node-red;
+  defaultUser = "node-red";
+  finalPackage = if cfg.withNpmAndGcc then node-red_withNpmAndGcc else cfg.package;
+  node-red_withNpmAndGcc = pkgs.runCommand "node-red" {
+    nativeBuildInputs = [ pkgs.makeWrapper ];
+  }
+  ''
+    mkdir -p $out/bin
+    makeWrapper ${pkgs.nodePackages.node-red}/bin/node-red $out/bin/node-red \
+      --set PATH '${lib.makeBinPath [ pkgs.nodePackages.npm pkgs.gcc ]}:$PATH' \
+  '';
+in
+{
+  options.services.node-red = {
+    enable = mkEnableOption "the Node-RED service";
+
+    package = mkOption {
+      default = pkgs.nodePackages.node-red;
+      defaultText = literalExpression "pkgs.nodePackages.node-red";
+      type = types.package;
+      description = "Node-RED package to use.";
+    };
+
+    openFirewall = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Open ports in the firewall for the server.
+      '';
+    };
+
+    withNpmAndGcc = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Give Node-RED access to NPM and GCC at runtime, so 'Nodes' can be
+        downloaded and managed imperatively via the 'Palette Manager'.
+      '';
+    };
+
+    configFile = mkOption {
+      type = types.path;
+      default = "${cfg.package}/lib/node_modules/node-red/settings.js";
+      defaultText = literalExpression ''"''${package}/lib/node_modules/node-red/settings.js"'';
+      description = ''
+        Path to the JavaScript configuration file.
+        See <link
+        xlink:href="https://github.com/node-red/node-red/blob/master/packages/node_modules/node-red/settings.js"/>
+        for a configuration example.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 1880;
+      description = "Listening port.";
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = defaultUser;
+      description = ''
+        User under which Node-RED runs.If left as the default value this user
+        will automatically be created on system activation, otherwise the
+        sysadmin is responsible for ensuring the user exists.
+      '';
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = defaultUser;
+      description = ''
+        Group under which Node-RED runs.If left as the default value this group
+        will automatically be created on system activation, otherwise the
+        sysadmin is responsible for ensuring the group exists.
+      '';
+    };
+
+    userDir = mkOption {
+      type = types.path;
+      default = "/var/lib/node-red";
+      description = ''
+        The directory to store all user data, such as flow and credential files and all library data. If left
+        as the default value this directory will automatically be created before the node-red service starts,
+        otherwise the sysadmin is responsible for ensuring the directory exists with appropriate ownership
+        and permissions.
+      '';
+    };
+
+    safe = mkOption {
+      type = types.bool;
+      default = false;
+      description = "Whether to launch Node-RED in --safe mode.";
+    };
+
+    define = mkOption {
+      type = types.attrs;
+      default = {};
+      description = "List of settings.js overrides to pass via -D to Node-RED.";
+      example = literalExpression ''
+        {
+          "logging.console.level" = "trace";
+        }
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users = optionalAttrs (cfg.user == defaultUser) {
+      ${defaultUser} = {
+        isSystemUser = true;
+        group = defaultUser;
+      };
+    };
+
+    users.groups = optionalAttrs (cfg.group == defaultUser) {
+      ${defaultUser} = { };
+    };
+
+    networking.firewall = mkIf cfg.openFirewall {
+      allowedTCPPorts = [ cfg.port ];
+    };
+
+    systemd.services.node-red = {
+      description = "Node-RED Service";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ];
+      environment = {
+        HOME = cfg.userDir;
+      };
+      serviceConfig = mkMerge [
+        {
+          User = cfg.user;
+          Group = cfg.group;
+          ExecStart = "${finalPackage}/bin/node-red ${pkgs.lib.optionalString cfg.safe "--safe"} --settings ${cfg.configFile} --port ${toString cfg.port} --userDir ${cfg.userDir} ${concatStringsSep " " (mapAttrsToList (name: value: "-D ${name}=${value}") cfg.define)}";
+          PrivateTmp = true;
+          Restart = "always";
+          WorkingDirectory = cfg.userDir;
+        }
+        (mkIf (cfg.userDir == "/var/lib/node-red") { StateDirectory = "node-red"; })
+      ];
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/openwebrx.nix b/nixos/modules/services/web-apps/openwebrx.nix
new file mode 100644
index 00000000000..9e90c01e0bb
--- /dev/null
+++ b/nixos/modules/services/web-apps/openwebrx.nix
@@ -0,0 +1,34 @@
+{ config, lib, pkgs, ... }:
+let
+  cfg = config.services.openwebrx;
+in
+{
+  options.services.openwebrx = with lib; {
+    enable = mkEnableOption "OpenWebRX Web interface for Software-Defined Radios on http://localhost:8073";
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.openwebrx;
+      defaultText = literalExpression "pkgs.openwebrx";
+      description = "OpenWebRX package to use for the service";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.openwebrx = {
+      wantedBy = [ "multi-user.target" ];
+      path = with pkgs; [
+        csdr
+        alsaUtils
+        netcat
+      ];
+      serviceConfig = {
+        ExecStart = "${cfg.package}/bin/openwebrx";
+        Restart = "always";
+        DynamicUser = true;
+        # openwebrx uses /var/lib/openwebrx by default
+        StateDirectory = [ "openwebrx" ];
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/peertube.nix b/nixos/modules/services/web-apps/peertube.nix
new file mode 100644
index 00000000000..e195e6e6e82
--- /dev/null
+++ b/nixos/modules/services/web-apps/peertube.nix
@@ -0,0 +1,475 @@
+{ lib, pkgs, config, options, ... }:
+
+let
+  cfg = config.services.peertube;
+  opt = options.services.peertube;
+
+  settingsFormat = pkgs.formats.json {};
+  configFile = settingsFormat.generate "production.json" cfg.settings;
+
+  env = {
+    NODE_CONFIG_DIR = "/var/lib/peertube/config";
+    NODE_ENV = "production";
+    NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/ca-certificates.crt";
+    NPM_CONFIG_PREFIX = cfg.package;
+    HOME = cfg.package;
+  };
+
+  systemCallsList = [ "@cpu-emulation" "@debug" "@keyring" "@ipc" "@memlock" "@mount" "@obsolete" "@privileged" "@setuid" ];
+
+  cfgService = {
+    # Proc filesystem
+    ProcSubset = "pid";
+    ProtectProc = "invisible";
+    # Access write directories
+    UMask = "0027";
+    # Capabilities
+    CapabilityBoundingSet = "";
+    # Security
+    NoNewPrivileges = true;
+    # Sandboxing
+    ProtectSystem = "strict";
+    ProtectHome = true;
+    PrivateTmp = true;
+    PrivateDevices = true;
+    PrivateUsers = true;
+    ProtectClock = true;
+    ProtectHostname = true;
+    ProtectKernelLogs = true;
+    ProtectKernelModules = true;
+    ProtectKernelTunables = true;
+    ProtectControlGroups = true;
+    RestrictNamespaces = true;
+    LockPersonality = true;
+    RestrictRealtime = true;
+    RestrictSUIDSGID = true;
+    RemoveIPC = true;
+    PrivateMounts = true;
+    # System Call Filtering
+    SystemCallArchitectures = "native";
+  };
+
+  envFile = pkgs.writeText "peertube.env" (lib.concatMapStrings (s: s + "\n") (
+    (lib.concatLists (lib.mapAttrsToList (name: value:
+      if value != null then [
+        "${name}=\"${toString value}\""
+      ] else []
+    ) env))));
+
+  peertubeEnv = pkgs.writeShellScriptBin "peertube-env" ''
+    set -a
+    source "${envFile}"
+    eval -- "\$@"
+  '';
+
+  peertubeCli = pkgs.writeShellScriptBin "peertube" ''
+    node ~/dist/server/tools/peertube.js $@
+  '';
+
+in {
+  options.services.peertube = {
+    enable = lib.mkEnableOption "Enable Peertube’s service";
+
+    user = lib.mkOption {
+      type = lib.types.str;
+      default = "peertube";
+      description = "User account under which Peertube runs.";
+    };
+
+    group = lib.mkOption {
+      type = lib.types.str;
+      default = "peertube";
+      description = "Group under which Peertube runs.";
+    };
+
+    localDomain = lib.mkOption {
+      type = lib.types.str;
+      example = "peertube.example.com";
+      description = "The domain serving your PeerTube instance.";
+    };
+
+    listenHttp = lib.mkOption {
+      type = lib.types.int;
+      default = 9000;
+      description = "listen port for HTTP server.";
+    };
+
+    listenWeb = lib.mkOption {
+      type = lib.types.int;
+      default = 9000;
+      description = "listen port for WEB server.";
+    };
+
+    enableWebHttps = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = "Enable or disable HTTPS protocol.";
+    };
+
+    dataDirs = lib.mkOption {
+      type = lib.types.listOf lib.types.path;
+      default = [ ];
+      example = [ "/opt/peertube/storage" "/var/cache/peertube" ];
+      description = "Allow access to custom data locations.";
+    };
+
+    serviceEnvironmentFile = lib.mkOption {
+      type = lib.types.nullOr lib.types.path;
+      default = null;
+      example = "/run/keys/peertube/password-init-root";
+      description = ''
+        Set environment variables for the service. Mainly useful for setting the initial root password.
+        For example write to file:
+        PT_INITIAL_ROOT_PASSWORD=changeme
+      '';
+    };
+
+    settings = lib.mkOption {
+      type = settingsFormat.type;
+      example = lib.literalExpression ''
+        {
+          listen = {
+            hostname = "0.0.0.0";
+          };
+          log = {
+            level = "debug";
+          };
+          storage = {
+            tmp = "/opt/data/peertube/storage/tmp/";
+            logs = "/opt/data/peertube/storage/logs/";
+            cache = "/opt/data/peertube/storage/cache/";
+          };
+        }
+      '';
+      description = "Configuration for peertube.";
+    };
+
+    database = {
+      createLocally = lib.mkOption {
+        type = lib.types.bool;
+        default = false;
+        description = "Configure local PostgreSQL database server for PeerTube.";
+      };
+
+      host = lib.mkOption {
+        type = lib.types.str;
+        default = if cfg.database.createLocally then "/run/postgresql" else null;
+        defaultText = lib.literalExpression ''
+          if config.${opt.database.createLocally}
+          then "/run/postgresql"
+          else null
+        '';
+        example = "192.168.15.47";
+        description = "Database host address or unix socket.";
+      };
+
+      port = lib.mkOption {
+        type = lib.types.int;
+        default = 5432;
+        description = "Database host port.";
+      };
+
+      name = lib.mkOption {
+        type = lib.types.str;
+        default = "peertube";
+        description = "Database name.";
+      };
+
+      user = lib.mkOption {
+        type = lib.types.str;
+        default = "peertube";
+        description = "Database user.";
+      };
+
+      passwordFile = lib.mkOption {
+        type = lib.types.nullOr lib.types.path;
+        default = null;
+        example = "/run/keys/peertube/password-posgressql-db";
+        description = "Password for PostgreSQL database.";
+      };
+    };
+
+    redis = {
+      createLocally = lib.mkOption {
+        type = lib.types.bool;
+        default = false;
+        description = "Configure local Redis server for PeerTube.";
+      };
+
+      host = lib.mkOption {
+        type = lib.types.nullOr lib.types.str;
+        default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then "127.0.0.1" else null;
+        defaultText = lib.literalExpression ''
+          if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket}
+          then "127.0.0.1"
+          else null
+        '';
+        description = "Redis host.";
+      };
+
+      port = lib.mkOption {
+        type = lib.types.nullOr lib.types.port;
+        default = if cfg.redis.createLocally && cfg.redis.enableUnixSocket then null else 6379;
+        defaultText = lib.literalExpression ''
+          if config.${opt.redis.createLocally} && config.${opt.redis.enableUnixSocket}
+          then null
+          else 6379
+        '';
+        description = "Redis port.";
+      };
+
+      passwordFile = lib.mkOption {
+        type = lib.types.nullOr lib.types.path;
+        default = null;
+        example = "/run/keys/peertube/password-redis-db";
+        description = "Password for redis database.";
+      };
+
+      enableUnixSocket = lib.mkOption {
+        type = lib.types.bool;
+        default = cfg.redis.createLocally;
+        defaultText = lib.literalExpression "config.${opt.redis.createLocally}";
+        description = "Use Unix socket.";
+      };
+    };
+
+    smtp = {
+      createLocally = lib.mkOption {
+        type = lib.types.bool;
+        default = false;
+        description = "Configure local Postfix SMTP server for PeerTube.";
+      };
+
+      passwordFile = lib.mkOption {
+        type = lib.types.nullOr lib.types.path;
+        default = null;
+        example = "/run/keys/peertube/password-smtp";
+        description = "Password for smtp server.";
+      };
+    };
+
+    package = lib.mkOption {
+      type = lib.types.package;
+      default = pkgs.peertube;
+      defaultText = lib.literalExpression "pkgs.peertube";
+      description = "Peertube package to use.";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    assertions = [
+      { assertion = cfg.serviceEnvironmentFile == null || !lib.hasPrefix builtins.storeDir cfg.serviceEnvironmentFile;
+          message = ''
+            <option>services.peertube.serviceEnvironmentFile</option> points to
+            a file in the Nix store. You should use a quoted absolute path to
+            prevent this.
+          '';
+      }
+      { assertion = !(cfg.redis.enableUnixSocket && (cfg.redis.host != null || cfg.redis.port != null));
+          message = ''
+            <option>services.peertube.redis.createLocally</option> and redis network connection (<option>services.peertube.redis.host</option> or <option>services.peertube.redis.port</option>) enabled. Disable either of them.
+        '';
+      }
+      { assertion = cfg.redis.enableUnixSocket || (cfg.redis.host != null && cfg.redis.port != null);
+          message = ''
+            <option>services.peertube.redis.host</option> and <option>services.peertube.redis.port</option> needs to be set if <option>services.peertube.redis.enableUnixSocket</option> is not enabled.
+        '';
+      }
+      { assertion = cfg.redis.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.redis.passwordFile;
+          message = ''
+            <option>services.peertube.redis.passwordFile</option> points to
+            a file in the Nix store. You should use a quoted absolute path to
+            prevent this.
+          '';
+      }
+      { assertion = cfg.database.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.database.passwordFile;
+          message = ''
+            <option>services.peertube.database.passwordFile</option> points to
+            a file in the Nix store. You should use a quoted absolute path to
+            prevent this.
+          '';
+      }
+      { assertion = cfg.smtp.passwordFile == null || !lib.hasPrefix builtins.storeDir cfg.smtp.passwordFile;
+          message = ''
+            <option>services.peertube.smtp.passwordFile</option> points to
+            a file in the Nix store. You should use a quoted absolute path to
+            prevent this.
+          '';
+      }
+    ];
+
+    services.peertube.settings = lib.mkMerge [
+      {
+        listen = {
+          port = cfg.listenHttp;
+        };
+        webserver = {
+          https = (if cfg.enableWebHttps then true else false);
+          hostname = "${cfg.localDomain}";
+          port = cfg.listenWeb;
+        };
+        database = {
+          hostname = "${cfg.database.host}";
+          port = cfg.database.port;
+          name = "${cfg.database.name}";
+          username = "${cfg.database.user}";
+        };
+        redis = {
+          hostname = "${toString cfg.redis.host}";
+          port = (if cfg.redis.port == null then "" else cfg.redis.port);
+        };
+        storage = {
+          tmp = lib.mkDefault "/var/lib/peertube/storage/tmp/";
+          bin = lib.mkDefault "/var/lib/peertube/storage/bin/";
+          avatars = lib.mkDefault "/var/lib/peertube/storage/avatars/";
+          videos = lib.mkDefault "/var/lib/peertube/storage/videos/";
+          streaming_playlists = lib.mkDefault "/var/lib/peertube/storage/streaming-playlists/";
+          redundancy = lib.mkDefault "/var/lib/peertube/storage/redundancy/";
+          logs = lib.mkDefault "/var/lib/peertube/storage/logs/";
+          previews = lib.mkDefault "/var/lib/peertube/storage/previews/";
+          thumbnails = lib.mkDefault "/var/lib/peertube/storage/thumbnails/";
+          torrents = lib.mkDefault "/var/lib/peertube/storage/torrents/";
+          captions = lib.mkDefault "/var/lib/peertube/storage/captions/";
+          cache = lib.mkDefault "/var/lib/peertube/storage/cache/";
+          plugins = lib.mkDefault "/var/lib/peertube/storage/plugins/";
+          client_overrides = lib.mkDefault "/var/lib/peertube/storage/client-overrides/";
+        };
+        import = {
+          videos = {
+            http = {
+              youtube_dl_release = {
+                python_path = "${pkgs.python3}/bin/python";
+              };
+            };
+          };
+        };
+      }
+      (lib.mkIf cfg.redis.enableUnixSocket { redis = { socket = "/run/redis/redis.sock"; }; })
+    ];
+
+    systemd.tmpfiles.rules = [
+      "d '/var/lib/peertube/config' 0700 ${cfg.user} ${cfg.group} - -"
+      "z '/var/lib/peertube/config' 0700 ${cfg.user} ${cfg.group} - -"
+    ];
+
+    systemd.services.peertube-init-db = lib.mkIf cfg.database.createLocally {
+      description = "Initialization database for PeerTube daemon";
+      after = [ "network.target" "postgresql.service" ];
+      wantedBy = [ "multi-user.target" ];
+
+      script = let
+        psqlSetupCommands = pkgs.writeText "peertube-init.sql" ''
+          SELECT 'CREATE USER "${cfg.database.user}"' WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${cfg.database.user}')\gexec
+          SELECT 'CREATE DATABASE "${cfg.database.name}" OWNER "${cfg.database.user}" TEMPLATE template0 ENCODING UTF8' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${cfg.database.name}')\gexec
+          \c '${cfg.database.name}'
+          CREATE EXTENSION IF NOT EXISTS pg_trgm;
+          CREATE EXTENSION IF NOT EXISTS unaccent;
+        '';
+      in "${config.services.postgresql.package}/bin/psql -f ${psqlSetupCommands}";
+
+      serviceConfig = {
+        Type = "oneshot";
+        WorkingDirectory = cfg.package;
+        # User and group
+        User = "postgres";
+        Group = "postgres";
+        # Sandboxing
+        RestrictAddressFamilies = [ "AF_UNIX" ];
+        MemoryDenyWriteExecute = true;
+        # System Call Filtering
+        SystemCallFilter = "~" + lib.concatStringsSep " " (systemCallsList ++ [ "@resources" ]);
+      } // cfgService;
+    };
+
+    systemd.services.peertube = {
+      description = "PeerTube daemon";
+      after = [ "network.target" ]
+        ++ lib.optionals cfg.redis.createLocally [ "redis.service" ]
+        ++ lib.optionals cfg.database.createLocally [ "postgresql.service" "peertube-init-db.service" ];
+      wantedBy = [ "multi-user.target" ];
+
+      environment = env;
+
+      path = with pkgs; [ bashInteractive ffmpeg nodejs-16_x openssl yarn python3 ];
+
+      script = ''
+        #!/bin/sh
+        umask 077
+        cat > /var/lib/peertube/config/local.yaml <<EOF
+        ${lib.optionalString ((!cfg.database.createLocally) && (cfg.database.passwordFile != null)) ''
+        database:
+          password: '$(cat ${cfg.database.passwordFile})'
+        ''}
+        ${lib.optionalString (cfg.redis.passwordFile != null) ''
+        redis:
+          auth: '$(cat ${cfg.redis.passwordFile})'
+        ''}
+        ${lib.optionalString (cfg.smtp.passwordFile != null) ''
+        smtp:
+          password: '$(cat ${cfg.smtp.passwordFile})'
+        ''}
+        EOF
+        ln -sf ${cfg.package}/config/default.yaml /var/lib/peertube/config/default.yaml
+        ln -sf ${configFile} /var/lib/peertube/config/production.json
+        npm start
+      '';
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+        RestartSec = 20;
+        TimeoutSec = 60;
+        WorkingDirectory = cfg.package;
+        # User and group
+        User = cfg.user;
+        Group = cfg.group;
+        # State directory and mode
+        StateDirectory = "peertube";
+        StateDirectoryMode = "0750";
+        # Access write directories
+        ReadWritePaths = cfg.dataDirs;
+        # Environment
+        EnvironmentFile = cfg.serviceEnvironmentFile;
+        # Sandboxing
+        RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" "AF_NETLINK" ];
+        MemoryDenyWriteExecute = false;
+        # System Call Filtering
+        SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "pipe" "pipe2" ];
+      } // cfgService;
+    };
+
+    services.postgresql = lib.mkIf cfg.database.createLocally {
+      enable = true;
+    };
+
+    services.redis = lib.mkMerge [
+      (lib.mkIf cfg.redis.createLocally {
+        enable = true;
+      })
+      (lib.mkIf (cfg.redis.createLocally && cfg.redis.enableUnixSocket) {
+        unixSocket = "/run/redis/redis.sock";
+        unixSocketPerm = 770;
+      })
+    ];
+
+    services.postfix = lib.mkIf cfg.smtp.createLocally {
+      enable = true;
+      hostname = lib.mkDefault "${cfg.localDomain}";
+    };
+
+    users.users = lib.mkMerge [
+      (lib.mkIf (cfg.user == "peertube") {
+        peertube = {
+          isSystemUser = true;
+          group = cfg.group;
+          home = cfg.package;
+        };
+      })
+      (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package peertubeEnv peertubeCli pkgs.ffmpeg pkgs.nodejs-16_x pkgs.yarn ])
+      (lib.mkIf cfg.redis.enableUnixSocket {${config.services.peertube.user}.extraGroups = [ "redis" ];})
+    ];
+
+    users.groups = lib.optionalAttrs (cfg.group == "peertube") {
+      peertube = { };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/pgpkeyserver-lite.nix b/nixos/modules/services/web-apps/pgpkeyserver-lite.nix
new file mode 100644
index 00000000000..faf0ce13238
--- /dev/null
+++ b/nixos/modules/services/web-apps/pgpkeyserver-lite.nix
@@ -0,0 +1,78 @@
+{ config, lib, options, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.pgpkeyserver-lite;
+  sksCfg = config.services.sks;
+  sksOpt = options.services.sks;
+
+  webPkg = cfg.package;
+
+in
+
+{
+
+  options = {
+
+    services.pgpkeyserver-lite = {
+
+      enable = mkEnableOption "pgpkeyserver-lite on a nginx vHost proxying to a gpg keyserver";
+
+      package = mkOption {
+        default = pkgs.pgpkeyserver-lite;
+        defaultText = literalExpression "pkgs.pgpkeyserver-lite";
+        type = types.package;
+        description = "
+          Which webgui derivation to use.
+        ";
+      };
+
+      hostname = mkOption {
+        type = types.str;
+        description = "
+          Which hostname to set the vHost to that is proxying to sks.
+        ";
+      };
+
+      hkpAddress = mkOption {
+        default = builtins.head sksCfg.hkpAddress;
+        defaultText = literalExpression "head config.${sksOpt.hkpAddress}";
+        type = types.str;
+        description = "
+          Wich ip address the sks-keyserver is listening on.
+        ";
+      };
+
+      hkpPort = mkOption {
+        default = sksCfg.hkpPort;
+        defaultText = literalExpression "config.${sksOpt.hkpPort}";
+        type = types.int;
+        description = "
+          Which port the sks-keyserver is listening on.
+        ";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+
+    services.nginx.enable = true;
+
+    services.nginx.virtualHosts = let
+      hkpPort = builtins.toString cfg.hkpPort;
+    in {
+      ${cfg.hostname} = {
+        root = webPkg;
+        locations = {
+          "/pks".extraConfig = ''
+            proxy_pass         http://${cfg.hkpAddress}:${hkpPort};
+            proxy_pass_header  Server;
+            add_header         Via "1.1 ${cfg.hostname}";
+          '';
+        };
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/pict-rs.md b/nixos/modules/services/web-apps/pict-rs.md
new file mode 100644
index 00000000000..4b622049909
--- /dev/null
+++ b/nixos/modules/services/web-apps/pict-rs.md
@@ -0,0 +1,88 @@
+# Pict-rs {#module-services-pict-rs}
+
+pict-rs is a  a simple image hosting service.
+
+## Quickstart {#module-services-pict-rs-quickstart}
+
+the minimum to start pict-rs is
+
+```nix
+services.pict-rs.enable = true;
+```
+
+this will start the http server on port 8080 by default.
+
+## Usage {#module-services-pict-rs-usage}
+
+pict-rs offers the following endpoints:
+- `POST /image` for uploading an image. Uploaded content must be valid multipart/form-data with an
+    image array located within the `images[]` key
+
+    This endpoint returns the following JSON structure on success with a 201 Created status
+    ```json
+    {
+        "files": [
+            {
+                "delete_token": "JFvFhqJA98",
+                "file": "lkWZDRvugm.jpg"
+            },
+            {
+                "delete_token": "kAYy9nk2WK",
+                "file": "8qFS0QooAn.jpg"
+            },
+            {
+                "delete_token": "OxRpM3sf0Y",
+                "file": "1hJaYfGE01.jpg"
+            }
+        ],
+        "msg": "ok"
+    }
+    ```
+- `GET /image/download?url=...` Download an image from a remote server, returning the same JSON
+    payload as the `POST` endpoint
+- `GET /image/original/{file}` for getting a full-resolution image. `file` here is the `file` key from the
+    `/image` endpoint's JSON
+- `GET /image/details/original/{file}` for getting the details of a full-resolution image.
+    The returned JSON is structured like so:
+    ```json
+    {
+        "width": 800,
+        "height": 537,
+        "content_type": "image/webp",
+        "created_at": [
+            2020,
+            345,
+            67376,
+            394363487
+        ]
+    }
+    ```
+- `GET /image/process.{ext}?src={file}&...` get a file with transformations applied.
+    existing transformations include
+    - `identity=true`: apply no changes
+    - `blur={float}`: apply a gaussian blur to the file
+    - `thumbnail={int}`: produce a thumbnail of the image fitting inside an `{int}` by `{int}`
+        square using raw pixel sampling
+    - `resize={int}`: produce a thumbnail of the image fitting inside an `{int}` by `{int}` square
+        using a Lanczos2 filter. This is slower than sampling but looks a bit better in some cases
+    - `crop={int-w}x{int-h}`: produce a cropped version of the image with an `{int-w}` by `{int-h}`
+        aspect ratio. The resulting crop will be centered on the image. Either the width or height
+        of the image will remain full-size, depending on the image's aspect ratio and the requested
+        aspect ratio. For example, a 1600x900 image cropped with a 1x1 aspect ratio will become 900x900. A
+        1600x1100 image cropped with a 16x9 aspect ratio will become 1600x900.
+
+    Supported `ext` file extensions include `png`, `jpg`, and `webp`
+
+    An example of usage could be
+    ```
+    GET /image/process.jpg?src=asdf.png&thumbnail=256&blur=3.0
+    ```
+    which would create a 256x256px JPEG thumbnail and blur it
+- `GET /image/details/process.{ext}?src={file}&...` for getting the details of a processed image.
+    The returned JSON is the same format as listed for the full-resolution details endpoint.
+- `DELETE /image/delete/{delete_token}/{file}` or `GET /image/delete/{delete_token}/{file}` to
+    delete a file, where `delete_token` and `file` are from the `/image` endpoint's JSON
+
+## Missing {#module-services-pict-rs-missing}
+
+- Configuring the secure-api-key is not included yet. The envisioned basic use case is consumption on localhost by other services without exposing the service to the internet.
diff --git a/nixos/modules/services/web-apps/pict-rs.nix b/nixos/modules/services/web-apps/pict-rs.nix
new file mode 100644
index 00000000000..e1847fbd531
--- /dev/null
+++ b/nixos/modules/services/web-apps/pict-rs.nix
@@ -0,0 +1,50 @@
+{ lib, pkgs, config, ... }:
+with lib;
+let
+  cfg = config.services.pict-rs;
+in
+{
+  meta.maintainers = with maintainers; [ happysalada ];
+  # Don't edit the docbook xml directly, edit the md and generate it:
+  # `pandoc pict-rs.md -t docbook --top-level-division=chapter --extract-media=media -f markdown+smart > pict-rs.xml`
+  meta.doc = ./pict-rs.xml;
+
+  options.services.pict-rs = {
+    enable = mkEnableOption "pict-rs server";
+    dataDir = mkOption {
+      type = types.path;
+      default = "/var/lib/pict-rs";
+      description = ''
+        The directory where to store the uploaded images.
+      '';
+    };
+    address = mkOption {
+      type = types.str;
+      default = "127.0.0.1";
+      description = ''
+        The IPv4 address to deploy the service to.
+      '';
+    };
+    port = mkOption {
+      type = types.port;
+      default = 8080;
+      description = ''
+        The port which to bind the service to.
+      '';
+    };
+  };
+  config = lib.mkIf cfg.enable {
+    systemd.services.pict-rs = {
+      environment = {
+        PICTRS_PATH = cfg.dataDir;
+        PICTRS_ADDR = "${cfg.address}:${toString cfg.port}";
+      };
+      wantedBy = [ "multi-user.target" ];
+      serviceConfig = {
+        DynamicUser = true;
+        StateDirectory = "pict-rs";
+        ExecStart = "${pkgs.pict-rs}/bin/pict-rs";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/pict-rs.xml b/nixos/modules/services/web-apps/pict-rs.xml
new file mode 100644
index 00000000000..bf129f5cc2a
--- /dev/null
+++ b/nixos/modules/services/web-apps/pict-rs.xml
@@ -0,0 +1,162 @@
+<chapter xmlns="http://docbook.org/ns/docbook" xmlns:xlink="http://www.w3.org/1999/xlink" xml:id="module-services-pict-rs">
+  <title>Pict-rs</title>
+  <para>
+    pict-rs is a a simple image hosting service.
+  </para>
+  <section xml:id="module-services-pict-rs-quickstart">
+    <title>Quickstart</title>
+    <para>
+      the minimum to start pict-rs is
+    </para>
+    <programlisting language="bash">
+services.pict-rs.enable = true;
+</programlisting>
+    <para>
+      this will start the http server on port 8080 by default.
+    </para>
+  </section>
+  <section xml:id="module-services-pict-rs-usage">
+    <title>Usage</title>
+    <para>
+      pict-rs offers the following endpoints: -
+      <literal>POST /image</literal> for uploading an image. Uploaded
+      content must be valid multipart/form-data with an image array
+      located within the <literal>images[]</literal> key
+    </para>
+    <programlisting>
+This endpoint returns the following JSON structure on success with a 201 Created status
+```json
+{
+    &quot;files&quot;: [
+        {
+            &quot;delete_token&quot;: &quot;JFvFhqJA98&quot;,
+            &quot;file&quot;: &quot;lkWZDRvugm.jpg&quot;
+        },
+        {
+            &quot;delete_token&quot;: &quot;kAYy9nk2WK&quot;,
+            &quot;file&quot;: &quot;8qFS0QooAn.jpg&quot;
+        },
+        {
+            &quot;delete_token&quot;: &quot;OxRpM3sf0Y&quot;,
+            &quot;file&quot;: &quot;1hJaYfGE01.jpg&quot;
+        }
+    ],
+    &quot;msg&quot;: &quot;ok&quot;
+}
+```
+</programlisting>
+    <itemizedlist>
+      <listitem>
+        <para>
+          <literal>GET /image/download?url=...</literal> Download an
+          image from a remote server, returning the same JSON payload as
+          the <literal>POST</literal> endpoint
+        </para>
+      </listitem>
+      <listitem>
+        <para>
+          <literal>GET /image/original/{file}</literal> for getting a
+          full-resolution image. <literal>file</literal> here is the
+          <literal>file</literal> key from the <literal>/image</literal>
+          endpoint’s JSON
+        </para>
+      </listitem>
+      <listitem>
+        <para>
+          <literal>GET /image/details/original/{file}</literal> for
+          getting the details of a full-resolution image. The returned
+          JSON is structured like so:
+          <literal>json     {         &quot;width&quot;: 800,         &quot;height&quot;: 537,         &quot;content_type&quot;: &quot;image/webp&quot;,         &quot;created_at&quot;: [             2020,             345,             67376,             394363487         ]     }</literal>
+        </para>
+      </listitem>
+      <listitem>
+        <para>
+          <literal>GET /image/process.{ext}?src={file}&amp;...</literal>
+          get a file with transformations applied. existing
+          transformations include
+        </para>
+        <itemizedlist spacing="compact">
+          <listitem>
+            <para>
+              <literal>identity=true</literal>: apply no changes
+            </para>
+          </listitem>
+          <listitem>
+            <para>
+              <literal>blur={float}</literal>: apply a gaussian blur to
+              the file
+            </para>
+          </listitem>
+          <listitem>
+            <para>
+              <literal>thumbnail={int}</literal>: produce a thumbnail of
+              the image fitting inside an <literal>{int}</literal> by
+              <literal>{int}</literal> square using raw pixel sampling
+            </para>
+          </listitem>
+          <listitem>
+            <para>
+              <literal>resize={int}</literal>: produce a thumbnail of
+              the image fitting inside an <literal>{int}</literal> by
+              <literal>{int}</literal> square using a Lanczos2 filter.
+              This is slower than sampling but looks a bit better in
+              some cases
+            </para>
+          </listitem>
+          <listitem>
+            <para>
+              <literal>crop={int-w}x{int-h}</literal>: produce a cropped
+              version of the image with an <literal>{int-w}</literal> by
+              <literal>{int-h}</literal> aspect ratio. The resulting
+              crop will be centered on the image. Either the width or
+              height of the image will remain full-size, depending on
+              the image’s aspect ratio and the requested aspect ratio.
+              For example, a 1600x900 image cropped with a 1x1 aspect
+              ratio will become 900x900. A 1600x1100 image cropped with
+              a 16x9 aspect ratio will become 1600x900.
+            </para>
+          </listitem>
+        </itemizedlist>
+        <para>
+          Supported <literal>ext</literal> file extensions include
+          <literal>png</literal>, <literal>jpg</literal>, and
+          <literal>webp</literal>
+        </para>
+        <para>
+          An example of usage could be
+          <literal>GET /image/process.jpg?src=asdf.png&amp;thumbnail=256&amp;blur=3.0</literal>
+          which would create a 256x256px JPEG thumbnail and blur it
+        </para>
+      </listitem>
+      <listitem>
+        <para>
+          <literal>GET /image/details/process.{ext}?src={file}&amp;...</literal>
+          for getting the details of a processed image. The returned
+          JSON is the same format as listed for the full-resolution
+          details endpoint.
+        </para>
+      </listitem>
+      <listitem>
+        <para>
+          <literal>DELETE /image/delete/{delete_token}/{file}</literal>
+          or <literal>GET /image/delete/{delete_token}/{file}</literal>
+          to delete a file, where <literal>delete_token</literal> and
+          <literal>file</literal> are from the <literal>/image</literal>
+          endpoint’s JSON
+        </para>
+      </listitem>
+    </itemizedlist>
+  </section>
+  <section xml:id="module-services-pict-rs-missing">
+    <title>Missing</title>
+    <itemizedlist spacing="compact">
+      <listitem>
+        <para>
+          Configuring the secure-api-key is not included yet. The
+          envisioned basic use case is consumption on localhost by other
+          services without exposing the service to the internet.
+        </para>
+      </listitem>
+    </itemizedlist>
+  </section>
+</chapter>
diff --git a/nixos/modules/services/web-apps/plantuml-server.nix b/nixos/modules/services/web-apps/plantuml-server.nix
new file mode 100644
index 00000000000..9ea37b8a4ca
--- /dev/null
+++ b/nixos/modules/services/web-apps/plantuml-server.nix
@@ -0,0 +1,140 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.plantuml-server;
+
+in
+
+{
+  options = {
+    services.plantuml-server = {
+      enable = mkEnableOption "PlantUML server";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.plantuml-server;
+        defaultText = literalExpression "pkgs.plantuml-server";
+        description = "PlantUML server package to use";
+      };
+
+      packages = {
+        jdk = mkOption {
+          type = types.package;
+          default = pkgs.jdk;
+          defaultText = literalExpression "pkgs.jdk";
+          description = "JDK package to use for the server";
+        };
+        jetty = mkOption {
+          type = types.package;
+          default = pkgs.jetty;
+          defaultText = literalExpression "pkgs.jetty";
+          description = "Jetty package to use for the server";
+        };
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "plantuml";
+        description = "User which runs PlantUML server.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "plantuml";
+        description = "Group which runs PlantUML server.";
+      };
+
+      home = mkOption {
+        type = types.str;
+        default = "/var/lib/plantuml";
+        description = "Home directory of the PlantUML server instance.";
+      };
+
+      listenHost = mkOption {
+        type = types.str;
+        default = "127.0.0.1";
+        description = "Host to listen on.";
+      };
+
+      listenPort = mkOption {
+        type = types.int;
+        default = 8080;
+        description = "Port to listen on.";
+      };
+
+      plantumlLimitSize = mkOption {
+        type = types.int;
+        default = 4096;
+        description = "Limits image width and height.";
+      };
+
+      graphvizPackage = mkOption {
+        type = types.package;
+        default = pkgs.graphviz;
+        defaultText = literalExpression "pkgs.graphviz";
+        description = "Package containing the dot executable.";
+      };
+
+      plantumlStats = mkOption {
+        type = types.bool;
+        default = false;
+        description = "Set it to on to enable statistics report (https://plantuml.com/statistics-report).";
+      };
+
+      httpAuthorization = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "When calling the proxy endpoint, the value of HTTP_AUTHORIZATION will be used to set the HTTP Authorization header.";
+      };
+
+      allowPlantumlInclude = mkOption {
+        type = types.bool;
+        default = false;
+        description = "Enables !include processing which can read files from the server into diagrams. Files are read relative to the current working directory.";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      group = cfg.group;
+      home = cfg.home;
+      createHome = true;
+    };
+
+    users.groups.${cfg.group} = {};
+
+    systemd.services.plantuml-server = {
+      description = "PlantUML server";
+      wantedBy = [ "multi-user.target" ];
+      path = [ cfg.home ];
+      environment = {
+        PLANTUML_LIMIT_SIZE = builtins.toString cfg.plantumlLimitSize;
+        GRAPHVIZ_DOT = "${cfg.graphvizPackage}/bin/dot";
+        PLANTUML_STATS = if cfg.plantumlStats then "on" else "off";
+        HTTP_AUTHORIZATION = cfg.httpAuthorization;
+        ALLOW_PLANTUML_INCLUDE = if cfg.allowPlantumlInclude then "true" else "false";
+      };
+      script = ''
+      ${cfg.packages.jdk}/bin/java \
+        -jar ${cfg.packages.jetty}/start.jar \
+          --module=deploy,http,jsp \
+          jetty.home=${cfg.packages.jetty} \
+          jetty.base=${cfg.package} \
+          jetty.http.host=${cfg.listenHost} \
+          jetty.http.port=${builtins.toString cfg.listenPort}
+      '';
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        PrivateTmp = true;
+      };
+    };
+  };
+
+  meta.maintainers = with lib.maintainers; [ truh ];
+}
diff --git a/nixos/modules/services/web-apps/plausible.nix b/nixos/modules/services/web-apps/plausible.nix
new file mode 100644
index 00000000000..5d550ae5ca8
--- /dev/null
+++ b/nixos/modules/services/web-apps/plausible.nix
@@ -0,0 +1,292 @@
+{ lib, pkgs, config, ... }:
+
+with lib;
+
+let
+  cfg = config.services.plausible;
+
+in {
+  options.services.plausible = {
+    enable = mkEnableOption "plausible";
+
+    releaseCookiePath = mkOption {
+      type = with types; either str path;
+      description = ''
+        The path to the file with release cookie. (used for remote connection to the running node).
+      '';
+    };
+
+    adminUser = {
+      name = mkOption {
+        default = "admin";
+        type = types.str;
+        description = ''
+          Name of the admin user that plausible will created on initial startup.
+        '';
+      };
+
+      email = mkOption {
+        type = types.str;
+        example = "admin@localhost";
+        description = ''
+          Email-address of the admin-user.
+        '';
+      };
+
+      passwordFile = mkOption {
+        type = types.either types.str types.path;
+        description = ''
+          Path to the file which contains the password of the admin user.
+        '';
+      };
+
+      activate = mkEnableOption "activating the freshly created admin-user";
+    };
+
+    database = {
+      clickhouse = {
+        setup = mkEnableOption "creating a clickhouse instance" // { default = true; };
+        url = mkOption {
+          default = "http://localhost:8123/default";
+          type = types.str;
+          description = ''
+            The URL to be used to connect to <package>clickhouse</package>.
+          '';
+        };
+      };
+      postgres = {
+        setup = mkEnableOption "creating a postgresql instance" // { default = true; };
+        dbname = mkOption {
+          default = "plausible";
+          type = types.str;
+          description = ''
+            Name of the database to use.
+          '';
+        };
+        socket = mkOption {
+          default = "/run/postgresql";
+          type = types.str;
+          description = ''
+            Path to the UNIX domain-socket to communicate with <package>postgres</package>.
+          '';
+        };
+      };
+    };
+
+    server = {
+      disableRegistration = mkOption {
+        default = true;
+        type = types.bool;
+        description = ''
+          Whether to prohibit creating an account in plausible's UI.
+        '';
+      };
+      secretKeybaseFile = mkOption {
+        type = types.either types.path types.str;
+        description = ''
+          Path to the secret used by the <literal>phoenix</literal>-framework. Instructions
+          how to generate one are documented in the
+          <link xlink:href="https://hexdocs.pm/phoenix/Mix.Tasks.Phx.Gen.Secret.html#content">
+          framework docs</link>.
+        '';
+      };
+      port = mkOption {
+        default = 8000;
+        type = types.port;
+        description = ''
+          Port where the service should be available.
+        '';
+      };
+      baseUrl = mkOption {
+        type = types.str;
+        description = ''
+          Public URL where plausible is available.
+
+          Note that <literal>/path</literal> components are currently ignored:
+          <link xlink:href="https://github.com/plausible/analytics/issues/1182">
+            https://github.com/plausible/analytics/issues/1182
+          </link>.
+        '';
+      };
+    };
+
+    mail = {
+      email = mkOption {
+        default = "hello@plausible.local";
+        type = types.str;
+        description = ''
+          The email id to use for as <emphasis>from</emphasis> address of all communications
+          from Plausible.
+        '';
+      };
+      smtp = {
+        hostAddr = mkOption {
+          default = "localhost";
+          type = types.str;
+          description = ''
+            The host address of your smtp server.
+          '';
+        };
+        hostPort = mkOption {
+          default = 25;
+          type = types.port;
+          description = ''
+            The port of your smtp server.
+          '';
+        };
+        user = mkOption {
+          default = null;
+          type = types.nullOr types.str;
+          description = ''
+            The username/email in case SMTP auth is enabled.
+          '';
+        };
+        passwordFile = mkOption {
+          default = null;
+          type = with types; nullOr (either str path);
+          description = ''
+            The path to the file with the password in case SMTP auth is enabled.
+          '';
+        };
+        enableSSL = mkEnableOption "SSL when connecting to the SMTP server";
+        retries = mkOption {
+          type = types.ints.unsigned;
+          default = 2;
+          description = ''
+            Number of retries to make until mailer gives up.
+          '';
+        };
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = [
+      { assertion = cfg.adminUser.activate -> cfg.database.postgres.setup;
+        message = ''
+          Unable to automatically activate the admin-user if no locally managed DB for
+          postgres (`services.plausible.database.postgres.setup') is enabled!
+        '';
+      }
+    ];
+
+    services.postgresql = mkIf cfg.database.postgres.setup {
+      enable = true;
+    };
+
+    services.clickhouse = mkIf cfg.database.clickhouse.setup {
+      enable = true;
+    };
+
+    services.epmd.enable = true;
+
+    environment.systemPackages = [ pkgs.plausible ];
+
+    systemd.services = mkMerge [
+      {
+        plausible = {
+          inherit (pkgs.plausible.meta) description;
+          documentation = [ "https://plausible.io/docs/self-hosting" ];
+          wantedBy = [ "multi-user.target" ];
+          after = optionals cfg.database.postgres.setup [ "postgresql.service" "plausible-postgres.service" ];
+          requires = optional cfg.database.clickhouse.setup "clickhouse.service"
+            ++ optionals cfg.database.postgres.setup [
+              "postgresql.service"
+              "plausible-postgres.service"
+            ];
+
+          environment = {
+            # NixOS specific option to avoid that it's trying to write into its store-path.
+            # See also https://github.com/lau/tzdata#data-directory-and-releases
+            STORAGE_DIR = "/var/lib/plausible/elixir_tzdata";
+
+            # Configuration options from
+            # https://plausible.io/docs/self-hosting-configuration
+            PORT = toString cfg.server.port;
+            DISABLE_REGISTRATION = boolToString cfg.server.disableRegistration;
+
+            RELEASE_TMP = "/var/lib/plausible/tmp";
+            # Home is needed to connect to the node with iex
+            HOME = "/var/lib/plausible";
+
+            ADMIN_USER_NAME = cfg.adminUser.name;
+            ADMIN_USER_EMAIL = cfg.adminUser.email;
+
+            DATABASE_SOCKET_DIR = cfg.database.postgres.socket;
+            DATABASE_NAME = cfg.database.postgres.dbname;
+            CLICKHOUSE_DATABASE_URL = cfg.database.clickhouse.url;
+
+            BASE_URL = cfg.server.baseUrl;
+
+            MAILER_EMAIL = cfg.mail.email;
+            SMTP_HOST_ADDR = cfg.mail.smtp.hostAddr;
+            SMTP_HOST_PORT = toString cfg.mail.smtp.hostPort;
+            SMTP_RETRIES = toString cfg.mail.smtp.retries;
+            SMTP_HOST_SSL_ENABLED = boolToString cfg.mail.smtp.enableSSL;
+
+            SELFHOST = "true";
+          } // (optionalAttrs (cfg.mail.smtp.user != null) {
+            SMTP_USER_NAME = cfg.mail.smtp.user;
+          });
+
+          path = [ pkgs.plausible ]
+            ++ optional cfg.database.postgres.setup config.services.postgresql.package;
+          script = ''
+            export CONFIG_DIR=$CREDENTIALS_DIRECTORY
+
+            export RELEASE_COOKIE="$(< $CREDENTIALS_DIRECTORY/RELEASE_COOKIE )"
+
+            # setup
+            ${pkgs.plausible}/createdb.sh
+            ${pkgs.plausible}/migrate.sh
+            ${optionalString cfg.adminUser.activate ''
+              if ! ${pkgs.plausible}/init-admin.sh | grep 'already exists'; then
+                psql -d plausible <<< "UPDATE users SET email_verified=true;"
+              fi
+            ''}
+
+            exec plausible start
+          '';
+
+          serviceConfig = {
+            DynamicUser = true;
+            PrivateTmp = true;
+            WorkingDirectory = "/var/lib/plausible";
+            StateDirectory = "plausible";
+            LoadCredential = [
+              "ADMIN_USER_PWD:${cfg.adminUser.passwordFile}"
+              "SECRET_KEY_BASE:${cfg.server.secretKeybaseFile}"
+              "RELEASE_COOKIE:${cfg.releaseCookiePath}"
+            ] ++ lib.optionals (cfg.mail.smtp.passwordFile != null) [ "SMTP_USER_PWD:${cfg.mail.smtp.passwordFile}"];
+          };
+        };
+      }
+      (mkIf cfg.database.postgres.setup {
+        # `plausible' requires the `citext'-extension.
+        plausible-postgres = {
+          after = [ "postgresql.service" ];
+          partOf = [ "plausible.service" ];
+          serviceConfig = {
+            Type = "oneshot";
+            User = config.services.postgresql.superUser;
+            RemainAfterExit = true;
+          };
+          script = with cfg.database.postgres; ''
+            PSQL() {
+              ${config.services.postgresql.package}/bin/psql --port=5432 "$@"
+            }
+            # check if the database already exists
+            if ! PSQL -lqt | ${pkgs.coreutils}/bin/cut -d \| -f 1 | ${pkgs.gnugrep}/bin/grep -qw ${dbname} ; then
+              PSQL -tAc "CREATE ROLE plausible WITH LOGIN;"
+              PSQL -tAc "CREATE DATABASE ${dbname} WITH OWNER plausible;"
+              PSQL -d ${dbname} -tAc "CREATE EXTENSION IF NOT EXISTS citext;"
+            fi
+          '';
+        };
+      })
+    ];
+  };
+
+  meta.maintainers = with maintainers; [ ma27 ];
+  meta.doc = ./plausible.xml;
+}
diff --git a/nixos/modules/services/web-apps/plausible.xml b/nixos/modules/services/web-apps/plausible.xml
new file mode 100644
index 00000000000..92a571b9fbd
--- /dev/null
+++ b/nixos/modules/services/web-apps/plausible.xml
@@ -0,0 +1,51 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-plausible">
+ <title>Plausible</title>
+ <para>
+  <link xlink:href="https://plausible.io/">Plausible</link> is a privacy-friendly alternative to
+  Google analytics.
+ </para>
+ <section xml:id="module-services-plausible-basic-usage">
+  <title>Basic Usage</title>
+  <para>
+   At first, a secret key is needed to be generated. This can be done with e.g.
+   <screen><prompt>$ </prompt>openssl rand -base64 64</screen>
+  </para>
+  <para>
+   After that, <package>plausible</package> can be deployed like this:
+<programlisting>{
+  services.plausible = {
+    <link linkend="opt-services.plausible.enable">enable</link> = true;
+    adminUser = {
+      <link linkend="opt-services.plausible.adminUser.activate">activate</link> = true; <co xml:id='ex-plausible-cfg-activate' />
+      <link linkend="opt-services.plausible.adminUser.email">email</link> = "admin@localhost";
+      <link linkend="opt-services.plausible.adminUser.passwordFile">passwordFile</link> = "/run/secrets/plausible-admin-pwd";
+    };
+    server = {
+      <link linkend="opt-services.plausible.server.baseUrl">baseUrl</link> = "http://analytics.example.org";
+      <link linkend="opt-services.plausible.server.secretKeybaseFile">secretKeybaseFile</link> = "/run/secrets/plausible-secret-key-base"; <co xml:id='ex-plausible-cfg-secretbase' />
+    };
+  };
+}</programlisting>
+   <calloutlist>
+    <callout arearefs='ex-plausible-cfg-activate'>
+     <para>
+      <varname>activate</varname> is used to skip the email verification of the admin-user that's
+      automatically created by <package>plausible</package>. This is only supported if
+      <package>postgresql</package> is configured by the module. This is done by default, but
+      can be turned off with <xref linkend="opt-services.plausible.database.postgres.setup" />.
+     </para>
+    </callout>
+    <callout arearefs='ex-plausible-cfg-secretbase'>
+     <para>
+      <varname>secretKeybaseFile</varname> is a path to the file which contains the secret generated
+      with <package>openssl</package> as described above.
+     </para>
+    </callout>
+   </calloutlist>
+  </para>
+ </section>
+</chapter>
diff --git a/nixos/modules/services/web-apps/powerdns-admin.nix b/nixos/modules/services/web-apps/powerdns-admin.nix
new file mode 100644
index 00000000000..4661ba80c5d
--- /dev/null
+++ b/nixos/modules/services/web-apps/powerdns-admin.nix
@@ -0,0 +1,152 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.powerdns-admin;
+
+  configText = ''
+    ${cfg.config}
+  ''
+  + optionalString (cfg.secretKeyFile != null) ''
+    with open('${cfg.secretKeyFile}') as file:
+      SECRET_KEY = file.read()
+  ''
+  + optionalString (cfg.saltFile != null) ''
+    with open('${cfg.saltFile}') as file:
+      SALT = file.read()
+  '';
+in
+{
+  options.services.powerdns-admin = {
+    enable = mkEnableOption "the PowerDNS web interface";
+
+    extraArgs = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      example = literalExpression ''
+        [ "-b" "127.0.0.1:8000" ]
+      '';
+      description = ''
+        Extra arguments passed to powerdns-admin.
+      '';
+    };
+
+    config = mkOption {
+      type = types.str;
+      default = "";
+      example = ''
+        BIND_ADDRESS = '127.0.0.1'
+        PORT = 8000
+        SQLALCHEMY_DATABASE_URI = 'postgresql://powerdnsadmin@/powerdnsadmin?host=/run/postgresql'
+      '';
+      description = ''
+        Configuration python file.
+        See <link xlink:href="https://github.com/ngoduykhanh/PowerDNS-Admin/blob/v${pkgs.powerdns-admin.version}/configs/development.py">the example configuration</link>
+        for options.
+      '';
+    };
+
+    secretKeyFile = mkOption {
+      type = types.nullOr types.path;
+      example = "/etc/powerdns-admin/secret";
+      description = ''
+        The secret used to create cookies.
+        This needs to be set, otherwise the default is used and everyone can forge valid login cookies.
+        Set this to null to ignore this setting and configure it through another way.
+      '';
+    };
+
+    saltFile = mkOption {
+      type = types.nullOr types.path;
+      example = "/etc/powerdns-admin/salt";
+      description = ''
+        The salt used for serialization.
+        This should be set, otherwise the default is used.
+        Set this to null to ignore this setting and configure it through another way.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.powerdns-admin = {
+      description = "PowerDNS web interface";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "networking.target" ];
+
+      environment.FLASK_CONF = builtins.toFile "powerdns-admin-config.py" configText;
+      environment.PYTHONPATH = pkgs.powerdns-admin.pythonPath;
+      serviceConfig = {
+        ExecStart = "${pkgs.powerdns-admin}/bin/powerdns-admin --pid /run/powerdns-admin/pid ${escapeShellArgs cfg.extraArgs}";
+        ExecStartPre = "${pkgs.coreutils}/bin/env FLASK_APP=${pkgs.powerdns-admin}/share/powerdnsadmin/__init__.py ${pkgs.python3Packages.flask}/bin/flask db upgrade -d ${pkgs.powerdns-admin}/share/migrations";
+        ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
+        ExecStop = "${pkgs.coreutils}/bin/kill -TERM $MAINPID";
+        PIDFile = "/run/powerdns-admin/pid";
+        RuntimeDirectory = "powerdns-admin";
+        User = "powerdnsadmin";
+        Group = "powerdnsadmin";
+
+        AmbientCapabilities = "CAP_NET_BIND_SERVICE";
+        BindReadOnlyPaths = [
+          "/nix/store"
+          "-/etc/resolv.conf"
+          "-/etc/nsswitch.conf"
+          "-/etc/hosts"
+          "-/etc/localtime"
+        ]
+        ++ (optional (cfg.secretKeyFile != null) cfg.secretKeyFile)
+        ++ (optional (cfg.saltFile != null) cfg.saltFile);
+        CapabilityBoundingSet = "CAP_NET_BIND_SERVICE";
+        # ProtectClock= adds DeviceAllow=char-rtc r
+        DeviceAllow = "";
+        # Implies ProtectSystem=strict, which re-mounts all paths
+        #DynamicUser = true;
+        LockPersonality = true;
+        MemoryDenyWriteExecute = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        PrivateMounts = true;
+        # Needs to start a server
+        #PrivateNetwork = true;
+        PrivateTmp = true;
+        PrivateUsers = true;
+        ProcSubset = "pid";
+        ProtectClock = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        # Would re-mount paths ignored by temporary root
+        #ProtectSystem = "strict";
+        ProtectControlGroups = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
+        RestrictNamespaces = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        SystemCallArchitectures = "native";
+        # gunicorn needs setuid
+        SystemCallFilter = [
+          "@system-service"
+          "~@privileged @resources @keyring"
+          # These got removed by the line above but are needed
+          "@setuid @chown"
+        ];
+        TemporaryFileSystem = "/:ro";
+        # Does not work well with the temporary root
+        #UMask = "0066";
+      };
+    };
+
+    users.groups.powerdnsadmin = { };
+    users.users.powerdnsadmin = {
+      description = "PowerDNS web interface user";
+      isSystemUser = true;
+      group = "powerdnsadmin";
+    };
+  };
+
+  # uses attributes of the linked package
+  meta.buildDocsInSandbox = false;
+}
diff --git a/nixos/modules/services/web-apps/prosody-filer.nix b/nixos/modules/services/web-apps/prosody-filer.nix
new file mode 100644
index 00000000000..a901a95fd5f
--- /dev/null
+++ b/nixos/modules/services/web-apps/prosody-filer.nix
@@ -0,0 +1,86 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+
+  cfg = config.services.prosody-filer;
+
+  settingsFormat = pkgs.formats.toml { };
+  configFile = settingsFormat.generate "prosody-filer.toml" cfg.settings;
+in {
+
+  options = {
+    services.prosody-filer = {
+      enable = mkEnableOption "Prosody Filer XMPP upload file server";
+
+      settings = mkOption {
+        description = ''
+          Configuration for Prosody Filer.
+          Refer to <link xlink:href="https://github.com/ThomasLeister/prosody-filer#configure-prosody-filer"/> for details on supported values.
+        '';
+
+        type = settingsFormat.type;
+
+        example = {
+          secret = "mysecret";
+          storeDir = "/srv/http/nginx/prosody-upload";
+        };
+
+        defaultText = literalExpression ''
+          {
+            listenport = mkDefault "127.0.0.1:5050";
+            uploadSubDir = mkDefault "upload/";
+          }
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.prosody-filer.settings = {
+      listenport = mkDefault "127.0.0.1:5050";
+      uploadSubDir = mkDefault "upload/";
+    };
+
+    users.users.prosody-filer = {
+      group = "prosody-filer";
+      isSystemUser = true;
+    };
+
+    users.groups.prosody-filer = { };
+
+    systemd.services.prosody-filer = {
+      description = "Prosody file upload server";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network.target" ];
+
+      serviceConfig = {
+        User = "prosody-filer";
+        Group = "prosody-filer";
+        ExecStart = "${pkgs.prosody-filer}/bin/prosody-filer -config ${configFile}";
+        Restart = "on-failure";
+        CapabilityBoundingSet = "";
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        PrivateTmp = true;
+        PrivateMounts = true;
+        ProtectHome = true;
+        ProtectClock = true;
+        ProtectProc = "noaccess";
+        ProcSubset = "pid";
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectControlGroups = true;
+        ProtectHostname = true;
+        RestrictSUIDSGID = true;
+        RestrictRealtime = true;
+        RestrictNamespaces = true;
+        LockPersonality = true;
+        RemoveIPC = true;
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
+        SystemCallFilter = [ "@system-service" "~@privileged" "~@resources" ];
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/restya-board.nix b/nixos/modules/services/web-apps/restya-board.nix
new file mode 100644
index 00000000000..4b36cc8754c
--- /dev/null
+++ b/nixos/modules/services/web-apps/restya-board.nix
@@ -0,0 +1,380 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+# TODO: are these php-packages needed?
+#imagick
+#php-geoip -> php.ini: extension = geoip.so
+#expat
+
+let
+  cfg = config.services.restya-board;
+  fpm = config.services.phpfpm.pools.${poolName};
+
+  runDir = "/run/restya-board";
+
+  poolName = "restya-board";
+
+in
+
+{
+
+  ###### interface
+
+  options = {
+
+    services.restya-board = {
+
+      enable = mkEnableOption "restya-board";
+
+      dataDir = mkOption {
+        type = types.path;
+        default = "/var/lib/restya-board";
+        description = ''
+          Data of the application.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "restya-board";
+        description = ''
+          User account under which the web-application runs.
+        '';
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "nginx";
+        description = ''
+          Group account under which the web-application runs.
+        '';
+      };
+
+      virtualHost = {
+        serverName = mkOption {
+          type = types.str;
+          default = "restya.board";
+          description = ''
+            Name of the nginx virtualhost to use.
+          '';
+        };
+
+        listenHost = mkOption {
+          type = types.str;
+          default = "localhost";
+          description = ''
+            Listen address for the virtualhost to use.
+          '';
+        };
+
+        listenPort = mkOption {
+          type = types.int;
+          default = 3000;
+          description = ''
+            Listen port for the virtualhost to use.
+          '';
+        };
+      };
+
+      database = {
+        host = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            Host of the database. Leave 'null' to use a local PostgreSQL database.
+            A local PostgreSQL database is initialized automatically.
+          '';
+        };
+
+        port = mkOption {
+          type = types.nullOr types.int;
+          default = 5432;
+          description = ''
+            The database's port.
+          '';
+        };
+
+        name = mkOption {
+          type = types.str;
+          default = "restya_board";
+          description = ''
+            Name of the database. The database must exist.
+          '';
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "restya_board";
+          description = ''
+            The database user. The user must exist and have access to
+            the specified database.
+          '';
+        };
+
+        passwordFile = mkOption {
+          type = types.nullOr types.path;
+          default = null;
+          description = ''
+            The database user's password. 'null' if no password is set.
+          '';
+        };
+      };
+
+      email = {
+        server = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          example = "localhost";
+          description = ''
+            Hostname to send outgoing mail. Null to use the system MTA.
+          '';
+        };
+
+        port = mkOption {
+          type = types.int;
+          default = 25;
+          description = ''
+            Port used to connect to SMTP server.
+          '';
+        };
+
+        login = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            SMTP authentication login used when sending outgoing mail.
+          '';
+        };
+
+        password = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            SMTP authentication password used when sending outgoing mail.
+
+            ATTENTION: The password is stored world-readable in the nix-store!
+          '';
+        };
+      };
+
+      timezone = mkOption {
+        type = types.lines;
+        default = "GMT";
+        description = ''
+          Timezone the web-app runs in.
+        '';
+      };
+
+    };
+
+  };
+
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+
+    services.phpfpm.pools = {
+      ${poolName} = {
+        inherit (cfg) user group;
+
+        phpOptions = ''
+          date.timezone = "CET"
+
+          ${optionalString (cfg.email.server != null) ''
+            SMTP = ${cfg.email.server}
+            smtp_port = ${toString cfg.email.port}
+            auth_username = ${cfg.email.login}
+            auth_password = ${cfg.email.password}
+          ''}
+        '';
+        settings = mapAttrs (name: mkDefault) {
+          "listen.owner" = "nginx";
+          "listen.group" = "nginx";
+          "listen.mode" = "0600";
+          "pm" = "dynamic";
+          "pm.max_children" = 75;
+          "pm.start_servers" = 10;
+          "pm.min_spare_servers" = 5;
+          "pm.max_spare_servers" = 20;
+          "pm.max_requests" = 500;
+          "catch_workers_output" = 1;
+        };
+      };
+    };
+
+    services.nginx.enable = true;
+    services.nginx.virtualHosts.${cfg.virtualHost.serverName} = {
+      listen = [ { addr = cfg.virtualHost.listenHost; port = cfg.virtualHost.listenPort; } ];
+      serverName = cfg.virtualHost.serverName;
+      root = runDir;
+      extraConfig = ''
+        index index.html index.php;
+
+        gzip on;
+
+        gzip_comp_level 6;
+        gzip_min_length  1100;
+        gzip_buffers 16 8k;
+        gzip_proxied any;
+        gzip_types text/plain application/xml text/css text/js text/xml application/x-javascript text/javascript application/json application/xml+rss;
+
+        client_max_body_size 300M;
+
+        rewrite ^/oauth/authorize$ /server/php/authorize.php last;
+        rewrite ^/oauth_callback/([a-zA-Z0-9_\.]*)/([a-zA-Z0-9_\.]*)$ /server/php/oauth_callback.php?plugin=$1&code=$2 last;
+        rewrite ^/download/([0-9]*)/([a-zA-Z0-9_\.]*)$ /server/php/download.php?id=$1&hash=$2 last;
+        rewrite ^/ical/([0-9]*)/([0-9]*)/([a-z0-9]*).ics$ /server/php/ical.php?board_id=$1&user_id=$2&hash=$3 last;
+        rewrite ^/api/(.*)$ /server/php/R/r.php?_url=$1&$args last;
+        rewrite ^/api_explorer/api-docs/$ /client/api_explorer/api-docs/index.php last;
+      '';
+
+      locations."/".root = "${runDir}/client";
+
+      locations."~ \\.php$" = {
+        tryFiles = "$uri =404";
+        extraConfig = ''
+          include ${config.services.nginx.package}/conf/fastcgi_params;
+          fastcgi_pass    unix:${fpm.socket};
+          fastcgi_index   index.php;
+          fastcgi_param   SCRIPT_FILENAME $document_root$fastcgi_script_name;
+          fastcgi_param   PHP_VALUE "upload_max_filesize=9G \n post_max_size=9G \n max_execution_time=200 \n max_input_time=200 \n memory_limit=256M";
+        '';
+      };
+
+      locations."~* \\.(css|js|less|html|ttf|woff|jpg|jpeg|gif|png|bmp|ico)" = {
+        root = "${runDir}/client";
+        extraConfig = ''
+          if (-f $request_filename) {
+                  break;
+          }
+          rewrite ^/img/([a-zA-Z_]*)/([a-zA-Z_]*)/([a-zA-Z0-9_\.]*)$ /server/php/image.php?size=$1&model=$2&filename=$3 last;
+          add_header        Cache-Control public;
+          add_header        Cache-Control must-revalidate;
+          expires           7d;
+        '';
+      };
+    };
+
+    systemd.services.restya-board-init = {
+      description = "Restya board initialization";
+      serviceConfig.Type = "oneshot";
+      serviceConfig.RemainAfterExit = true;
+
+      wantedBy = [ "multi-user.target" ];
+      requires = [ "postgresql.service" ];
+      after = [ "network.target" "postgresql.service" ];
+
+      script = ''
+        rm -rf "${runDir}"
+        mkdir -m 750 -p "${runDir}"
+        cp -r "${pkgs.restya-board}/"* "${runDir}"
+        sed -i "s/@restya.com/@${cfg.virtualHost.serverName}/g" "${runDir}/sql/restyaboard_with_empty_data.sql"
+        rm -rf "${runDir}/media"
+        rm -rf "${runDir}/client/img"
+        chmod -R 0750 "${runDir}"
+
+        sed -i "s@^php@${config.services.phpfpm.phpPackage}/bin/php@" "${runDir}/server/php/shell/"*.sh
+
+        ${if (cfg.database.host == null) then ''
+          sed -i "s/^.*'R_DB_HOST'.*$/define('R_DB_HOST', 'localhost');/g" "${runDir}/server/php/config.inc.php"
+          sed -i "s/^.*'R_DB_PASSWORD'.*$/define('R_DB_PASSWORD', 'restya');/g" "${runDir}/server/php/config.inc.php"
+        '' else ''
+          sed -i "s/^.*'R_DB_HOST'.*$/define('R_DB_HOST', '${cfg.database.host}');/g" "${runDir}/server/php/config.inc.php"
+          sed -i "s/^.*'R_DB_PASSWORD'.*$/define('R_DB_PASSWORD', ${if cfg.database.passwordFile == null then "''" else "'file_get_contents(${cfg.database.passwordFile})'"});/g" "${runDir}/server/php/config.inc.php
+        ''}
+        sed -i "s/^.*'R_DB_PORT'.*$/define('R_DB_PORT', '${toString cfg.database.port}');/g" "${runDir}/server/php/config.inc.php"
+        sed -i "s/^.*'R_DB_NAME'.*$/define('R_DB_NAME', '${cfg.database.name}');/g" "${runDir}/server/php/config.inc.php"
+        sed -i "s/^.*'R_DB_USER'.*$/define('R_DB_USER', '${cfg.database.user}');/g" "${runDir}/server/php/config.inc.php"
+
+        chmod 0400 "${runDir}/server/php/config.inc.php"
+
+        ln -sf "${cfg.dataDir}/media" "${runDir}/media"
+        ln -sf "${cfg.dataDir}/client/img" "${runDir}/client/img"
+
+        chmod g+w "${runDir}/tmp/cache"
+        chown -R "${cfg.user}"."${cfg.group}" "${runDir}"
+
+
+        mkdir -m 0750 -p "${cfg.dataDir}"
+        mkdir -m 0750 -p "${cfg.dataDir}/media"
+        mkdir -m 0750 -p "${cfg.dataDir}/client/img"
+        cp -r "${pkgs.restya-board}/media/"* "${cfg.dataDir}/media"
+        cp -r "${pkgs.restya-board}/client/img/"* "${cfg.dataDir}/client/img"
+        chown "${cfg.user}"."${cfg.group}" "${cfg.dataDir}"
+        chown -R "${cfg.user}"."${cfg.group}" "${cfg.dataDir}/media"
+        chown -R "${cfg.user}"."${cfg.group}" "${cfg.dataDir}/client/img"
+
+        ${optionalString (cfg.database.host == null) ''
+          if ! [ -e "${cfg.dataDir}/.db-initialized" ]; then
+            ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} \
+              ${config.services.postgresql.package}/bin/psql -U ${config.services.postgresql.superUser} \
+              -c "CREATE USER ${cfg.database.user} WITH ENCRYPTED PASSWORD 'restya'"
+
+            ${pkgs.sudo}/bin/sudo -u ${config.services.postgresql.superUser} \
+              ${config.services.postgresql.package}/bin/psql -U ${config.services.postgresql.superUser} \
+              -c "CREATE DATABASE ${cfg.database.name} OWNER ${cfg.database.user} ENCODING 'UTF8' TEMPLATE template0"
+
+            ${pkgs.sudo}/bin/sudo -u ${cfg.user} \
+              ${config.services.postgresql.package}/bin/psql -U ${cfg.database.user} \
+              -d ${cfg.database.name} -f "${runDir}/sql/restyaboard_with_empty_data.sql"
+
+            touch "${cfg.dataDir}/.db-initialized"
+          fi
+        ''}
+      '';
+    };
+
+    systemd.timers.restya-board = {
+      description = "restya-board scripts for e.g. email notification";
+      wantedBy = [ "timers.target" ];
+      after = [ "restya-board-init.service" ];
+      requires = [ "restya-board-init.service" ];
+      timerConfig = {
+        OnUnitInactiveSec = "60s";
+        Unit = "restya-board-timers.service";
+      };
+    };
+
+    systemd.services.restya-board-timers = {
+      description = "restya-board scripts for e.g. email notification";
+      serviceConfig.Type = "oneshot";
+      serviceConfig.User = cfg.user;
+
+      after = [ "restya-board-init.service" ];
+      requires = [ "restya-board-init.service" ];
+
+      script = ''
+        /bin/sh ${runDir}/server/php/shell/instant_email_notification.sh 2> /dev/null || true
+        /bin/sh ${runDir}/server/php/shell/periodic_email_notification.sh 2> /dev/null || true
+        /bin/sh ${runDir}/server/php/shell/imap.sh 2> /dev/null || true
+        /bin/sh ${runDir}/server/php/shell/webhook.sh 2> /dev/null || true
+        /bin/sh ${runDir}/server/php/shell/card_due_notification.sh 2> /dev/null || true
+      '';
+    };
+
+    users.users.restya-board = {
+      isSystemUser = true;
+      createHome = false;
+      home = runDir;
+      group  = "restya-board";
+    };
+    users.groups.restya-board = {};
+
+    services.postgresql.enable = mkIf (cfg.database.host == null) true;
+
+    services.postgresql.identMap = optionalString (cfg.database.host == null)
+      ''
+        restya-board-users restya-board restya_board
+      '';
+
+    services.postgresql.authentication = optionalString (cfg.database.host == null)
+      ''
+        local restya_board all ident map=restya-board-users
+      '';
+
+  };
+
+}
+
diff --git a/nixos/modules/services/web-apps/rss-bridge.nix b/nixos/modules/services/web-apps/rss-bridge.nix
new file mode 100644
index 00000000000..f2b6d955982
--- /dev/null
+++ b/nixos/modules/services/web-apps/rss-bridge.nix
@@ -0,0 +1,125 @@
+{ config, lib, pkgs, ... }:
+with lib;
+let
+  cfg = config.services.rss-bridge;
+
+  poolName = "rss-bridge";
+
+  whitelist = pkgs.writeText "rss-bridge_whitelist.txt"
+    (concatStringsSep "\n" cfg.whitelist);
+in
+{
+  options = {
+    services.rss-bridge = {
+      enable = mkEnableOption "rss-bridge";
+
+      user = mkOption {
+        type = types.str;
+        default = "nginx";
+        description = ''
+          User account under which both the service and the web-application run.
+        '';
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "nginx";
+        description = ''
+          Group under which the web-application run.
+        '';
+      };
+
+      pool = mkOption {
+        type = types.str;
+        default = poolName;
+        description = ''
+          Name of existing phpfpm pool that is used to run web-application.
+          If not specified a pool will be created automatically with
+          default values.
+        '';
+      };
+
+      dataDir = mkOption {
+        type = types.str;
+        default = "/var/lib/rss-bridge";
+        description = ''
+          Location in which cache directory will be created.
+          You can put <literal>config.ini.php</literal> in here.
+        '';
+      };
+
+      virtualHost = mkOption {
+        type = types.nullOr types.str;
+        default = "rss-bridge";
+        description = ''
+          Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost.
+        '';
+      };
+
+      whitelist = mkOption {
+        type = types.listOf types.str;
+        default = [];
+        example = options.literalExpression ''
+          [
+            "Facebook"
+            "Instagram"
+            "Twitter"
+          ]
+        '';
+        description = ''
+          List of bridges to be whitelisted.
+          If the list is empty, rss-bridge will use whitelist.default.txt.
+          Use <literal>[ "*" ]</literal> to whitelist all.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.phpfpm.pools = mkIf (cfg.pool == poolName) {
+      ${poolName} = {
+        user = cfg.user;
+        settings = mapAttrs (name: mkDefault) {
+          "listen.owner" = cfg.user;
+          "listen.group" = cfg.user;
+          "listen.mode" = "0600";
+          "pm" = "dynamic";
+          "pm.max_children" = 75;
+          "pm.start_servers" = 10;
+          "pm.min_spare_servers" = 5;
+          "pm.max_spare_servers" = 20;
+          "pm.max_requests" = 500;
+          "catch_workers_output" = 1;
+        };
+      };
+    };
+    systemd.tmpfiles.rules = [
+      "d '${cfg.dataDir}/cache' 0750 ${cfg.user} ${cfg.group} - -"
+      (mkIf (cfg.whitelist != []) "L+ ${cfg.dataDir}/whitelist.txt - - - - ${whitelist}")
+      "z '${cfg.dataDir}/config.ini.php' 0750 ${cfg.user} ${cfg.group} - -"
+    ];
+
+    services.nginx = mkIf (cfg.virtualHost != null) {
+      enable = true;
+      virtualHosts = {
+        ${cfg.virtualHost} = {
+          root = "${pkgs.rss-bridge}";
+
+          locations."/" = {
+            tryFiles = "$uri /index.php$is_args$args";
+          };
+
+          locations."~ ^/index.php(/|$)" = {
+            extraConfig = ''
+              include ${config.services.nginx.package}/conf/fastcgi_params;
+              fastcgi_split_path_info ^(.+\.php)(/.+)$;
+              fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
+              fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+              fastcgi_param RSSBRIDGE_DATA ${cfg.dataDir};
+            '';
+          };
+        };
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/selfoss.nix b/nixos/modules/services/web-apps/selfoss.nix
new file mode 100644
index 00000000000..899976ac696
--- /dev/null
+++ b/nixos/modules/services/web-apps/selfoss.nix
@@ -0,0 +1,164 @@
+{ config, lib, pkgs, ... }:
+with lib;
+let
+  cfg = config.services.selfoss;
+
+  poolName = "selfoss_pool";
+
+  dataDir = "/var/lib/selfoss";
+
+  selfoss-config =
+  let
+    db_type = cfg.database.type;
+    default_port = if (db_type == "mysql") then 3306 else 5342;
+  in
+  pkgs.writeText "selfoss-config.ini" ''
+    [globals]
+    ${lib.optionalString (db_type != "sqlite") ''
+      db_type=${db_type}
+      db_host=${cfg.database.host}
+      db_database=${cfg.database.name}
+      db_username=${cfg.database.user}
+      db_password=${cfg.database.password}
+      db_port=${toString (if (cfg.database.port != null) then cfg.database.port
+                    else default_port)}
+    ''
+    }
+    ${cfg.extraConfig}
+  '';
+in
+  {
+    options = {
+      services.selfoss = {
+        enable = mkEnableOption "selfoss";
+
+        user = mkOption {
+          type = types.str;
+          default = "nginx";
+          description = ''
+            User account under which both the service and the web-application run.
+          '';
+        };
+
+        pool = mkOption {
+          type = types.str;
+          default = "${poolName}";
+          description = ''
+            Name of existing phpfpm pool that is used to run web-application.
+            If not specified a pool will be created automatically with
+            default values.
+          '';
+        };
+
+      database = {
+        type = mkOption {
+          type = types.enum ["pgsql" "mysql" "sqlite"];
+          default = "sqlite";
+          description = ''
+            Database to store feeds. Supported are sqlite, pgsql and mysql.
+          '';
+        };
+
+        host = mkOption {
+          type = types.str;
+          default = "localhost";
+          description = ''
+            Host of the database (has no effect if type is "sqlite").
+          '';
+        };
+
+        name = mkOption {
+          type = types.str;
+          default = "tt_rss";
+          description = ''
+            Name of the existing database (has no effect if type is "sqlite").
+          '';
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "tt_rss";
+          description = ''
+            The database user. The user must exist and has access to
+            the specified database (has no effect if type is "sqlite").
+          '';
+        };
+
+        password = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            The database user's password (has no effect if type is "sqlite").
+          '';
+        };
+
+        port = mkOption {
+          type = types.nullOr types.int;
+          default = null;
+          description = ''
+            The database's port. If not set, the default ports will be
+            provided (5432 and 3306 for pgsql and mysql respectively)
+            (has no effect if type is "sqlite").
+          '';
+        };
+      };
+      extraConfig = mkOption {
+        type = types.lines;
+        default = "";
+        description = ''
+          Extra configuration added to config.ini
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
+      ${poolName} = {
+        user = "nginx";
+        settings = mapAttrs (name: mkDefault) {
+          "listen.owner" = "nginx";
+          "listen.group" = "nginx";
+          "listen.mode" = "0600";
+          "pm" = "dynamic";
+          "pm.max_children" = 75;
+          "pm.start_servers" = 10;
+          "pm.min_spare_servers" = 5;
+          "pm.max_spare_servers" = 20;
+          "pm.max_requests" = 500;
+          "catch_workers_output" = 1;
+        };
+      };
+    };
+
+    systemd.services.selfoss-config = {
+      serviceConfig.Type = "oneshot";
+      script = ''
+        mkdir -m 755 -p ${dataDir}
+        cd ${dataDir}
+
+        # Delete all but the "data" folder
+        ls | grep -v data | while read line; do rm -rf $line; done || true
+
+        # Create the files
+        cp -r "${pkgs.selfoss}/"* "${dataDir}"
+        ln -sf "${selfoss-config}" "${dataDir}/config.ini"
+        chown -R "${cfg.user}" "${dataDir}"
+        chmod -R 755 "${dataDir}"
+      '';
+      wantedBy = [ "multi-user.target" ];
+    };
+
+    systemd.services.selfoss-update = {
+      serviceConfig = {
+        ExecStart = "${pkgs.php}/bin/php ${dataDir}/cliupdate.php";
+        User = "${cfg.user}";
+      };
+      startAt = "hourly";
+      after = [ "selfoss-config.service" ];
+      wantedBy = [ "multi-user.target" ];
+
+    };
+
+  };
+}
diff --git a/nixos/modules/services/web-apps/shiori.nix b/nixos/modules/services/web-apps/shiori.nix
new file mode 100644
index 00000000000..bb2fc684e83
--- /dev/null
+++ b/nixos/modules/services/web-apps/shiori.nix
@@ -0,0 +1,96 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.shiori;
+in {
+  options = {
+    services.shiori = {
+      enable = mkEnableOption "Shiori simple bookmarks manager";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.shiori;
+        defaultText = literalExpression "pkgs.shiori";
+        description = "The Shiori package to use.";
+      };
+
+      address = mkOption {
+        type = types.str;
+        default = "";
+        description = ''
+          The IP address on which Shiori will listen.
+          If empty, listens on all interfaces.
+        '';
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 8080;
+        description = "The port of the Shiori web application";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.shiori = with cfg; {
+      description = "Shiori simple bookmarks manager";
+      wantedBy = [ "multi-user.target" ];
+
+      environment.SHIORI_DIR = "/var/lib/shiori";
+
+      serviceConfig = {
+        ExecStart = "${package}/bin/shiori serve --address '${address}' --port '${toString port}'";
+
+        DynamicUser = true;
+        StateDirectory = "shiori";
+        # As the RootDirectory
+        RuntimeDirectory = "shiori";
+
+        # Security options
+
+        BindReadOnlyPaths = [
+          "/nix/store"
+
+          # For SSL certificates, and the resolv.conf
+          "/etc"
+        ];
+
+        CapabilityBoundingSet = "";
+
+        DeviceAllow = "";
+
+        LockPersonality = true;
+
+        MemoryDenyWriteExecute = true;
+
+        PrivateDevices = true;
+        PrivateUsers = true;
+
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+
+        RestrictNamespaces = true;
+        RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+
+        RootDirectory = "/run/shiori";
+
+        SystemCallArchitectures = "native";
+        SystemCallErrorNumber = "EPERM";
+        SystemCallFilter = [
+          "@system-service"
+          "~@cpu-emulation" "~@debug" "~@keyring" "~@memlock" "~@obsolete" "~@privileged" "~@resources" "~@setuid"
+        ];
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ minijackson ];
+}
diff --git a/nixos/modules/services/web-apps/sogo.nix b/nixos/modules/services/web-apps/sogo.nix
new file mode 100644
index 00000000000..4610bb96cb5
--- /dev/null
+++ b/nixos/modules/services/web-apps/sogo.nix
@@ -0,0 +1,271 @@
+{ config, pkgs, lib, ... }: with lib; let
+  cfg = config.services.sogo;
+
+  preStart = pkgs.writeShellScriptBin "sogo-prestart" ''
+    touch /etc/sogo/sogo.conf
+    chown sogo:sogo /etc/sogo/sogo.conf
+    chmod 640 /etc/sogo/sogo.conf
+
+    ${if (cfg.configReplaces != {}) then ''
+      # Insert secrets
+      ${concatStringsSep "\n" (mapAttrsToList (k: v: ''export ${k}="$(cat "${v}" | tr -d '\n')"'') cfg.configReplaces)}
+
+      ${pkgs.perl}/bin/perl -p ${concatStringsSep " " (mapAttrsToList (k: v: '' -e 's/${k}/''${ENV{"${k}"}}/g;' '') cfg.configReplaces)} /etc/sogo/sogo.conf.raw > /etc/sogo/sogo.conf
+    '' else ''
+      cp /etc/sogo/sogo.conf.raw /etc/sogo/sogo.conf
+    ''}
+  '';
+
+in {
+  options.services.sogo = with types; {
+    enable = mkEnableOption "SOGo groupware";
+
+    vhostName = mkOption {
+      description = "Name of the nginx vhost";
+      type = str;
+      default = "sogo";
+    };
+
+    timezone = mkOption {
+      description = "Timezone of your SOGo instance";
+      type = str;
+      example = "America/Montreal";
+    };
+
+    language = mkOption {
+      description = "Language of SOGo";
+      type = str;
+      default = "English";
+    };
+
+    ealarmsCredFile = mkOption {
+      description = "Optional path to a credentials file for email alarms";
+      type = nullOr str;
+      default = null;
+    };
+
+    configReplaces = mkOption {
+      description = ''
+        Replacement-filepath mapping for sogo.conf.
+        Every key is replaced with the contents of the file specified as value.
+
+        In the example, every occurence of LDAP_BINDPW will be replaced with the text of the
+        specified file.
+      '';
+      type = attrsOf str;
+      default = {};
+      example = {
+        LDAP_BINDPW = "/var/lib/secrets/sogo/ldappw";
+      };
+    };
+
+    extraConfig = mkOption {
+      description = "Extra sogo.conf configuration lines";
+      type = lines;
+      default = "";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    environment.systemPackages = [ pkgs.sogo ];
+
+    environment.etc."sogo/sogo.conf.raw".text = ''
+      {
+        // Mandatory parameters
+        SOGoTimeZone = "${cfg.timezone}";
+        SOGoLanguage = "${cfg.language}";
+        // Paths
+        WOSendMail = "/run/wrappers/bin/sendmail";
+        SOGoMailSpoolPath = "/var/lib/sogo/spool";
+        // Enable CSRF protection
+        SOGoXSRFValidationEnabled = YES;
+        // Remove dates from log (jornald does that)
+        NGLogDefaultLogEventFormatterClass = "NGLogEventFormatter";
+        // Extra config
+        ${cfg.extraConfig}
+      }
+    '';
+
+    systemd.services.sogo = {
+      description = "SOGo groupware";
+      after = [ "postgresql.service" "mysql.service" "memcached.service" "openldap.service" "dovecot2.service" ];
+      wantedBy = [ "multi-user.target" ];
+      restartTriggers = [ config.environment.etc."sogo/sogo.conf.raw".source ];
+
+      environment.LDAPTLS_CACERT = "/etc/ssl/certs/ca-certificates.crt";
+
+      serviceConfig = {
+        Type = "forking";
+        ExecStartPre = "+" + preStart + "/bin/sogo-prestart";
+        ExecStart = "${pkgs.sogo}/bin/sogod -WOLogFile - -WOPidFile /run/sogo/sogo.pid";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        PrivateDevices = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+        RuntimeDirectory = "sogo";
+        StateDirectory = "sogo/spool";
+
+        User = "sogo";
+        Group = "sogo";
+
+        CapabilityBoundingSet = "";
+        NoNewPrivileges = true;
+
+        LockPersonality = true;
+        RestrictRealtime = true;
+        PrivateMounts = true;
+        PrivateUsers = true;
+        MemoryDenyWriteExecute = true;
+        SystemCallFilter = "@basic-io @file-system @network-io @system-service @timer";
+        SystemCallArchitectures = "native";
+        RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
+      };
+    };
+
+    systemd.services.sogo-tmpwatch = {
+      description = "SOGo tmpwatch";
+
+      startAt = [ "hourly" ];
+      script = ''
+        SOGOSPOOL=/var/lib/sogo/spool
+
+        find "$SOGOSPOOL" -type f -user sogo -atime +23 -delete > /dev/null
+        find "$SOGOSPOOL" -mindepth 1 -type d -user sogo -empty -delete > /dev/null
+      '';
+
+      serviceConfig = {
+        Type = "oneshot";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        PrivateDevices = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+        StateDirectory = "sogo/spool";
+
+        User = "sogo";
+        Group = "sogo";
+
+        CapabilityBoundingSet = "";
+        NoNewPrivileges = true;
+
+        LockPersonality = true;
+        RestrictRealtime = true;
+        PrivateMounts = true;
+        PrivateUsers = true;
+        PrivateNetwork = true;
+        SystemCallFilter = "@basic-io @file-system @system-service";
+        SystemCallArchitectures = "native";
+        RestrictAddressFamilies = "";
+      };
+    };
+
+    systemd.services.sogo-ealarms = {
+      description = "SOGo email alarms";
+
+      after = [ "postgresql.service" "mysqld.service" "memcached.service" "openldap.service" "dovecot2.service" "sogo.service" ];
+      restartTriggers = [ config.environment.etc."sogo/sogo.conf.raw".source ];
+
+      startAt = [ "minutely" ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        ExecStart = "${pkgs.sogo}/bin/sogo-ealarms-notify${optionalString (cfg.ealarmsCredFile != null) " -p ${cfg.ealarmsCredFile}"}";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        PrivateDevices = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+        StateDirectory = "sogo/spool";
+
+        User = "sogo";
+        Group = "sogo";
+
+        CapabilityBoundingSet = "";
+        NoNewPrivileges = true;
+
+        LockPersonality = true;
+        RestrictRealtime = true;
+        PrivateMounts = true;
+        PrivateUsers = true;
+        MemoryDenyWriteExecute = true;
+        SystemCallFilter = "@basic-io @file-system @network-io @system-service";
+        SystemCallArchitectures = "native";
+        RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6";
+      };
+    };
+
+    # nginx vhost
+    services.nginx.virtualHosts."${cfg.vhostName}" = {
+      locations."/".extraConfig = ''
+        rewrite ^ https://$server_name/SOGo;
+        allow all;
+      '';
+
+      # For iOS 7
+      locations."/principals/".extraConfig = ''
+        rewrite ^ https://$server_name/SOGo/dav;
+        allow all;
+      '';
+
+      locations."^~/SOGo".extraConfig = ''
+        proxy_pass http://127.0.0.1:20000;
+        proxy_redirect http://127.0.0.1:20000 default;
+
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header Host $host;
+        proxy_set_header x-webobjects-server-protocol HTTP/1.0;
+        proxy_set_header x-webobjects-remote-host 127.0.0.1;
+        proxy_set_header x-webobjects-server-port $server_port;
+        proxy_set_header x-webobjects-server-name $server_name;
+        proxy_set_header x-webobjects-server-url $scheme://$host;
+        proxy_connect_timeout 90;
+        proxy_send_timeout 90;
+        proxy_read_timeout 90;
+        proxy_buffer_size 4k;
+        proxy_buffers 4 32k;
+        proxy_busy_buffers_size 64k;
+        proxy_temp_file_write_size 64k;
+        client_max_body_size 50m;
+        client_body_buffer_size 128k;
+        break;
+      '';
+
+      locations."/SOGo.woa/WebServerResources/".extraConfig = ''
+        alias ${pkgs.sogo}/lib/GNUstep/SOGo/WebServerResources/;
+        allow all;
+      '';
+
+      locations."/SOGo/WebServerResources/".extraConfig = ''
+        alias ${pkgs.sogo}/lib/GNUstep/SOGo/WebServerResources/;
+        allow all;
+      '';
+
+      locations."~ ^/SOGo/so/ControlPanel/Products/([^/]*)/Resources/(.*)$".extraConfig = ''
+        alias ${pkgs.sogo}/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
+      '';
+
+      locations."~ ^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\\.(jpg|png|gif|css|js)$".extraConfig = ''
+        alias ${pkgs.sogo}/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
+      '';
+    };
+
+    # User and group
+    users.groups.sogo = {};
+    users.users.sogo = {
+      group = "sogo";
+      isSystemUser = true;
+      description = "SOGo service user";
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/timetagger.nix b/nixos/modules/services/web-apps/timetagger.nix
new file mode 100644
index 00000000000..373f4fcd52f
--- /dev/null
+++ b/nixos/modules/services/web-apps/timetagger.nix
@@ -0,0 +1,80 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (lib) mkEnableOption mkIf mkOption types literalExpression;
+
+  cfg = config.services.timetagger;
+in {
+
+  options = {
+    services.timetagger = {
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Tag your time, get the insight
+
+          <note><para>
+            This app does not do authentication.
+            You must setup authentication yourself or run it in an environment where
+            only allowed users have access.
+          </para></note>
+        '';
+      };
+
+      bindAddr = mkOption {
+        description = "Address to bind to.";
+        type = types.str;
+        default = "127.0.0.1";
+      };
+
+      port = mkOption {
+        description = "Port to bind to.";
+        type = types.port;
+        default = 8080;
+      };
+
+      package = mkOption {
+        description = ''
+          Use own package for starting timetagger web application.
+
+          The ${literalExpression ''pkgs.timetagger''} package only provides a
+          "run.py" script for the actual package
+          ${literalExpression ''pkgs.python3Packages.timetagger''}.
+
+          If you want to provide a "run.py" script for starting timetagger
+          yourself, you can do so with this option.
+          If you do so, the 'bindAddr' and 'port' options are ignored.
+        '';
+
+        default = pkgs.timetagger.override { addr = cfg.bindAddr; port = cfg.port; };
+        defaultText = literalExpression ''
+          pkgs.timetagger.override {
+            addr = ${cfg.bindAddr};
+            port = ${cfg.port};
+          };
+        '';
+        type = types.package;
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.timetagger = {
+      description = "Timetagger service";
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        User = "timetagger";
+        Group = "timetagger";
+        StateDirectory = "timetagger";
+
+        ExecStart = "${cfg.package}/bin/timetagger";
+
+        Restart = "on-failure";
+        RestartSec = 1;
+      };
+    };
+  };
+}
+
diff --git a/nixos/modules/services/web-apps/trilium.nix b/nixos/modules/services/web-apps/trilium.nix
new file mode 100644
index 00000000000..35383c992fe
--- /dev/null
+++ b/nixos/modules/services/web-apps/trilium.nix
@@ -0,0 +1,146 @@
+{ config, lib, pkgs, ... }:
+
+let
+  cfg = config.services.trilium-server;
+  configIni = pkgs.writeText "trilium-config.ini" ''
+    [General]
+    # Instance name can be used to distinguish between different instances
+    instanceName=${cfg.instanceName}
+
+    # Disable automatically generating desktop icon
+    noDesktopIcon=true
+    noBackup=${lib.boolToString cfg.noBackup}
+
+    [Network]
+    # host setting is relevant only for web deployments - set the host on which the server will listen
+    host=${cfg.host}
+    # port setting is relevant only for web deployments, desktop builds run on random free port
+    port=${toString cfg.port}
+    # true for TLS/SSL/HTTPS (secure), false for HTTP (unsecure).
+    https=false
+  '';
+in
+{
+
+  options.services.trilium-server = with lib; {
+    enable = mkEnableOption "trilium-server";
+
+    dataDir = mkOption {
+      type = types.str;
+      default = "/var/lib/trilium";
+      description = ''
+        The directory storing the notes database and the configuration.
+      '';
+    };
+
+    instanceName = mkOption {
+      type = types.str;
+      default = "Trilium";
+      description = ''
+        Instance name used to distinguish between different instances
+      '';
+    };
+
+    noBackup = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Disable periodic database backups.
+      '';
+    };
+
+    host = mkOption {
+      type = types.str;
+      default = "127.0.0.1";
+      description = ''
+        The host address to bind to (defaults to localhost).
+      '';
+    };
+
+    port = mkOption {
+      type = types.int;
+      default = 8080;
+      description = ''
+        The port number to bind to.
+      '';
+    };
+
+    nginx = mkOption {
+      default = {};
+      description = ''
+        Configuration for nginx reverse proxy.
+      '';
+
+      type = types.submodule {
+        options = {
+          enable = mkOption {
+            type = types.bool;
+            default = false;
+            description = ''
+              Configure the nginx reverse proxy settings.
+            '';
+          };
+
+          hostName = mkOption {
+            type = types.str;
+            description = ''
+              The hostname use to setup the virtualhost configuration
+            '';
+          };
+        };
+      };
+    };
+  };
+
+  config = lib.mkIf cfg.enable (lib.mkMerge [
+  {
+    meta.maintainers = with lib.maintainers; [ fliegendewurst ];
+
+    users.groups.trilium = {};
+    users.users.trilium = {
+      description = "Trilium User";
+      group = "trilium";
+      home = cfg.dataDir;
+      isSystemUser = true;
+    };
+
+    systemd.services.trilium-server = {
+      wantedBy = [ "multi-user.target" ];
+      environment.TRILIUM_DATA_DIR = cfg.dataDir;
+      serviceConfig = {
+        ExecStart = "${pkgs.trilium-server}/bin/trilium-server";
+        User = "trilium";
+        Group = "trilium";
+        PrivateTmp = "true";
+      };
+    };
+
+    systemd.tmpfiles.rules = [
+      "d  ${cfg.dataDir}            0750 trilium trilium - -"
+      "L+ ${cfg.dataDir}/config.ini -    -       -       - ${configIni}"
+    ];
+
+  }
+
+  (lib.mkIf cfg.nginx.enable {
+    services.nginx = {
+      enable = true;
+      virtualHosts."${cfg.nginx.hostName}" = {
+        locations."/" = {
+          proxyPass = "http://${cfg.host}:${toString cfg.port}/";
+          extraConfig = ''
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection 'upgrade';
+            proxy_set_header Host $host;
+            proxy_cache_bypass $http_upgrade;
+          '';
+        };
+        extraConfig = ''
+          client_max_body_size 0;
+        '';
+      };
+    };
+  })
+  ]);
+}
diff --git a/nixos/modules/services/web-apps/tt-rss.nix b/nixos/modules/services/web-apps/tt-rss.nix
new file mode 100644
index 00000000000..9aa38ab25c9
--- /dev/null
+++ b/nixos/modules/services/web-apps/tt-rss.nix
@@ -0,0 +1,686 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.tt-rss;
+
+  configVersion = 26;
+
+  dbPort = if cfg.database.port == null
+    then (if cfg.database.type == "pgsql" then 5432 else 3306)
+    else cfg.database.port;
+
+  poolName = "tt-rss";
+
+  mysqlLocal = cfg.database.createLocally && cfg.database.type == "mysql";
+  pgsqlLocal = cfg.database.createLocally && cfg.database.type == "pgsql";
+
+  tt-rss-config = let
+    password =
+      if (cfg.database.password != null) then
+        "'${(escape ["'" "\\"] cfg.database.password)}'"
+      else if (cfg.database.passwordFile != null) then
+        "file_get_contents('${cfg.database.passwordFile}')"
+      else
+        null
+      ;
+  in pkgs.writeText "config.php" ''
+    <?php
+      putenv('TTRSS_PHP_EXECUTABLE=${pkgs.php}/bin/php');
+
+      putenv('TTRSS_LOCK_DIRECTORY=${cfg.root}/lock');
+      putenv('TTRSS_CACHE_DIR=${cfg.root}/cache');
+      putenv('TTRSS_ICONS_DIR=${cfg.root}/feed-icons');
+      putenv('TTRSS_ICONS_URL=feed-icons');
+      putenv('TTRSS_SELF_URL_PATH=${cfg.selfUrlPath}');
+
+      putenv('TTRSS_MYSQL_CHARSET=UTF8');
+
+      putenv('TTRSS_DB_TYPE=${cfg.database.type}');
+      putenv('TTRSS_DB_HOST=${optionalString (cfg.database.host != null) cfg.database.host}');
+      putenv('TTRSS_DB_USER=${cfg.database.user}');
+      putenv('TTRSS_DB_NAME=${cfg.database.name}');
+      putenv('TTRSS_DB_PASS=' ${optionalString (password != null) ". ${password}"});
+      putenv('TTRSS_DB_PORT=${toString dbPort}');
+
+      putenv('TTRSS_AUTH_AUTO_CREATE=${boolToString cfg.auth.autoCreate}');
+      putenv('TTRSS_AUTH_AUTO_LOGIN=${boolToString cfg.auth.autoLogin}');
+
+      putenv('TTRSS_FEED_CRYPT_KEY=${escape ["'" "\\"] cfg.feedCryptKey}');
+
+
+      putenv('TTRSS_SINGLE_USER_MODE=${boolToString cfg.singleUserMode}');
+
+      putenv('TTRSS_SIMPLE_UPDATE_MODE=${boolToString cfg.simpleUpdateMode}');
+
+      # Never check for updates - the running version of the code should
+      # be controlled entirely by the version of TT-RSS active in the
+      # current Nix profile. If TT-RSS updates itself to a version
+      # requiring a database schema upgrade, and then the SystemD
+      # tt-rss.service is restarted, the old code copied from the Nix
+      # store will overwrite the updated version, causing the code to
+      # detect the need for a schema "upgrade" (since the schema version
+      # in the database is different than in the code), but the update
+      # schema operation in TT-RSS will do nothing because the schema
+      # version in the database is newer than that in the code.
+      putenv('TTRSS_CHECK_FOR_UPDATES=false');
+
+      putenv('TTRSS_FORCE_ARTICLE_PURGE=${toString cfg.forceArticlePurge}');
+      putenv('TTRSS_SESSION_COOKIE_LIFETIME=${toString cfg.sessionCookieLifetime}');
+      putenv('TTRSS_ENABLE_GZIP_OUTPUT=${boolToString cfg.enableGZipOutput}');
+
+      putenv('TTRSS_PLUGINS=${builtins.concatStringsSep "," cfg.plugins}');
+
+      putenv('TTRSS_LOG_DESTINATION=${cfg.logDestination}');
+      putenv('TTRSS_CONFIG_VERSION=${toString configVersion}');
+
+
+      putenv('TTRSS_PUBSUBHUBBUB_ENABLED=${boolToString cfg.pubSubHubbub.enable}');
+      putenv('TTRSS_PUBSUBHUBBUB_HUB=${cfg.pubSubHubbub.hub}');
+
+      putenv('TTRSS_SPHINX_SERVER=${cfg.sphinx.server}');
+      putenv('TTRSS_SPHINX_INDEX=${builtins.concatStringsSep "," cfg.sphinx.index}');
+
+      putenv('TTRSS_ENABLE_REGISTRATION=${boolToString cfg.registration.enable}');
+      putenv('TTRSS_REG_NOTIFY_ADDRESS=${cfg.registration.notifyAddress}');
+      putenv('TTRSS_REG_MAX_USERS=${toString cfg.registration.maxUsers}');
+
+      putenv('TTRSS_SMTP_SERVER=${cfg.email.server}');
+      putenv('TTRSS_SMTP_LOGIN=${cfg.email.login}');
+      putenv('TTRSS_SMTP_PASSWORD=${escape ["'" "\\"] cfg.email.password}');
+      putenv('TTRSS_SMTP_SECURE=${cfg.email.security}');
+
+      putenv('TTRSS_SMTP_FROM_NAME=${escape ["'" "\\"] cfg.email.fromName}');
+      putenv('TTRSS_SMTP_FROM_ADDRESS=${escape ["'" "\\"] cfg.email.fromAddress}');
+      putenv('TTRSS_DIGEST_SUBJECT=${escape ["'" "\\"] cfg.email.digestSubject}');
+
+      ${cfg.extraConfig}
+  '';
+
+  # tt-rss and plugins and themes and config.php
+  servedRoot = pkgs.runCommand "tt-rss-served-root" {} ''
+    cp --no-preserve=mode -r ${pkgs.tt-rss} $out
+    cp ${tt-rss-config} $out/config.php
+    ${optionalString (cfg.pluginPackages != []) ''
+    for plugin in ${concatStringsSep " " cfg.pluginPackages}; do
+    cp -r "$plugin"/* "$out/plugins.local/"
+    done
+    ''}
+    ${optionalString (cfg.themePackages != []) ''
+    for theme in ${concatStringsSep " " cfg.themePackages}; do
+    cp -r "$theme"/* "$out/themes.local/"
+    done
+    ''}
+  '';
+
+ in {
+
+  ###### interface
+
+  options = {
+
+    services.tt-rss = {
+
+      enable = mkEnableOption "tt-rss";
+
+      root = mkOption {
+        type = types.path;
+        default = "/var/lib/tt-rss";
+        description = ''
+          Root of the application.
+        '';
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "tt_rss";
+        description = ''
+          User account under which both the update daemon and the web-application run.
+        '';
+      };
+
+      pool = mkOption {
+        type = types.str;
+        default = "${poolName}";
+        description = ''
+          Name of existing phpfpm pool that is used to run web-application.
+          If not specified a pool will be created automatically with
+          default values.
+        '';
+      };
+
+      virtualHost = mkOption {
+        type = types.nullOr types.str;
+        default = "tt-rss";
+        description = ''
+          Name of the nginx virtualhost to use and setup. If null, do not setup any virtualhost.
+        '';
+      };
+
+      database = {
+        type = mkOption {
+          type = types.enum ["pgsql" "mysql"];
+          default = "pgsql";
+          description = ''
+            Database to store feeds. Supported are pgsql and mysql.
+          '';
+        };
+
+        host = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            Host of the database. Leave null to use Unix domain socket.
+          '';
+        };
+
+        name = mkOption {
+          type = types.str;
+          default = "tt_rss";
+          description = ''
+            Name of the existing database.
+          '';
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "tt_rss";
+          description = ''
+            The database user. The user must exist and has access to
+            the specified database.
+          '';
+        };
+
+        password = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            The database user's password.
+          '';
+        };
+
+        passwordFile = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            The database user's password.
+          '';
+        };
+
+        port = mkOption {
+          type = types.nullOr types.int;
+          default = null;
+          description = ''
+            The database's port. If not set, the default ports will be provided (5432
+            and 3306 for pgsql and mysql respectively).
+          '';
+        };
+
+        createLocally = mkOption {
+          type = types.bool;
+          default = true;
+          description = "Create the database and database user locally.";
+        };
+      };
+
+      auth = {
+        autoCreate = mkOption {
+          type = types.bool;
+          default = true;
+          description = ''
+            Allow authentication modules to auto-create users in tt-rss internal
+            database when authenticated successfully.
+          '';
+        };
+
+        autoLogin = mkOption {
+          type = types.bool;
+          default = true;
+          description = ''
+            Automatically login user on remote or other kind of externally supplied
+            authentication, otherwise redirect to login form as normal.
+            If set to true, users won't be able to set application language
+            and settings profile.
+          '';
+        };
+      };
+
+      pubSubHubbub = {
+        hub = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            URL to a PubSubHubbub-compatible hub server. If defined, "Published
+            articles" generated feed would automatically become PUSH-enabled.
+          '';
+        };
+
+        enable = mkOption {
+          type = types.bool;
+          default = false;
+          description = ''
+            Enable client PubSubHubbub support in tt-rss. When disabled, tt-rss
+            won't try to subscribe to PUSH feed updates.
+          '';
+        };
+      };
+
+      sphinx = {
+        server = mkOption {
+          type = types.str;
+          default = "localhost:9312";
+          description = ''
+            Hostname:port combination for the Sphinx server.
+          '';
+        };
+
+        index = mkOption {
+          type = types.listOf types.str;
+          default = ["ttrss" "delta"];
+          description = ''
+            Index names in Sphinx configuration. Example configuration
+            files are available on tt-rss wiki.
+          '';
+        };
+      };
+
+      registration = {
+        enable = mkOption {
+          type = types.bool;
+          default = false;
+          description = ''
+            Allow users to register themselves. Please be aware that allowing
+            random people to access your tt-rss installation is a security risk
+            and potentially might lead to data loss or server exploit. Disabled
+            by default.
+          '';
+        };
+
+        notifyAddress = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            Email address to send new user notifications to.
+          '';
+        };
+
+        maxUsers = mkOption {
+          type = types.int;
+          default = 0;
+          description = ''
+            Maximum amount of users which will be allowed to register on this
+            system. 0 - no limit.
+          '';
+        };
+      };
+
+      email = {
+        server = mkOption {
+          type = types.str;
+          default = "";
+          example = "localhost:25";
+          description = ''
+            Hostname:port combination to send outgoing mail. Blank - use system
+            MTA.
+          '';
+        };
+
+        login = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            SMTP authentication login used when sending outgoing mail.
+          '';
+        };
+
+        password = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            SMTP authentication password used when sending outgoing mail.
+          '';
+        };
+
+        security = mkOption {
+          type = types.enum ["" "ssl" "tls"];
+          default = "";
+          description = ''
+            Used to select a secure SMTP connection. Allowed values: ssl, tls,
+            or empty.
+          '';
+        };
+
+        fromName = mkOption {
+          type = types.str;
+          default = "Tiny Tiny RSS";
+          description = ''
+            Name for sending outgoing mail. This applies to password reset
+            notifications, digest emails and any other mail.
+          '';
+        };
+
+        fromAddress = mkOption {
+          type = types.str;
+          default = "";
+          description = ''
+            Address for sending outgoing mail. This applies to password reset
+            notifications, digest emails and any other mail.
+          '';
+        };
+
+        digestSubject = mkOption {
+          type = types.str;
+          default = "[tt-rss] New headlines for last 24 hours";
+          description = ''
+            Subject line for email digests.
+          '';
+        };
+      };
+
+      sessionCookieLifetime = mkOption {
+        type = types.int;
+        default = 86400;
+        description = ''
+          Default lifetime of a session (e.g. login) cookie. In seconds,
+          0 means cookie will be deleted when browser closes.
+        '';
+      };
+
+      selfUrlPath = mkOption {
+        type = types.str;
+        description = ''
+          Full URL of your tt-rss installation. This should be set to the
+          location of tt-rss directory, e.g. http://example.org/tt-rss/
+          You need to set this option correctly otherwise several features
+          including PUSH, bookmarklets and browser integration will not work properly.
+        '';
+        example = "http://localhost";
+      };
+
+      feedCryptKey = mkOption {
+        type = types.str;
+        default = "";
+        description = ''
+          Key used for encryption of passwords for password-protected feeds
+          in the database. A string of 24 random characters. If left blank, encryption
+          is not used. Requires mcrypt functions.
+          Warning: changing this key will make your stored feed passwords impossible
+          to decrypt.
+        '';
+      };
+
+      singleUserMode = mkOption {
+        type = types.bool;
+        default = false;
+
+        description = ''
+          Operate in single user mode, disables all functionality related to
+          multiple users and authentication. Enabling this assumes you have
+          your tt-rss directory protected by other means (e.g. http auth).
+        '';
+      };
+
+      simpleUpdateMode = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Enables fallback update mode where tt-rss tries to update feeds in
+          background while tt-rss is open in your browser.
+          If you don't have a lot of feeds and don't want to or can't run
+          background processes while not running tt-rss, this method is generally
+          viable to keep your feeds up to date.
+          Still, there are more robust (and recommended) updating methods
+          available, you can read about them here: http://tt-rss.org/wiki/UpdatingFeeds
+        '';
+      };
+
+      forceArticlePurge = mkOption {
+        type = types.int;
+        default = 0;
+        description = ''
+          When this option is not 0, users ability to control feed purging
+          intervals is disabled and all articles (which are not starred)
+          older than this amount of days are purged.
+        '';
+      };
+
+      enableGZipOutput = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Selectively gzip output to improve wire performance. This requires
+          PHP Zlib extension on the server.
+          Enabling this can break tt-rss in several httpd/php configurations,
+          if you experience weird errors and tt-rss failing to start, blank pages
+          after login, or content encoding errors, disable it.
+        '';
+      };
+
+      plugins = mkOption {
+        type = types.listOf types.str;
+        default = ["auth_internal" "note"];
+        description = ''
+          List of plugins to load automatically for all users.
+          System plugins have to be specified here. Please enable at least one
+          authentication plugin here (auth_*).
+          Users may enable other user plugins from Preferences/Plugins but may not
+          disable plugins specified in this list.
+          Disabling auth_internal in this list would automatically disable
+          reset password link on the login form.
+        '';
+      };
+
+      pluginPackages = mkOption {
+        type = types.listOf types.package;
+        default = [];
+        description = ''
+          List of plugins to install. The list elements are expected to
+          be derivations. All elements in this derivation are automatically
+          copied to the <literal>plugins.local</literal> directory.
+        '';
+      };
+
+      themePackages = mkOption {
+        type = types.listOf types.package;
+        default = [];
+        description = ''
+          List of themes to install. The list elements are expected to
+          be derivations. All elements in this derivation are automatically
+          copied to the <literal>themes.local</literal> directory.
+        '';
+      };
+
+      logDestination = mkOption {
+        type = types.enum ["" "sql" "syslog"];
+        default = "sql";
+        description = ''
+          Log destination to use. Possible values: sql (uses internal logging
+          you can read in Preferences -> System), syslog - logs to system log.
+          Setting this to blank uses PHP logging (usually to http server
+          error.log).
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = types.lines;
+        default = "";
+        description = ''
+          Additional lines to append to <literal>config.php</literal>.
+        '';
+      };
+    };
+  };
+
+  imports = [
+    (mkRemovedOptionModule ["services" "tt-rss" "checkForUpdates"] ''
+      This option was removed because setting this to true will cause TT-RSS
+      to be unable to start if an automatic update of the code in
+      services.tt-rss.root leads to a database schema upgrade that is not
+      supported by the code active in the Nix store.
+    '')
+  ];
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+
+    assertions = [
+      {
+        assertion = cfg.database.password != null -> cfg.database.passwordFile == null;
+        message = "Cannot set both password and passwordFile";
+      }
+    ];
+
+    services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
+      ${poolName} = {
+        inherit (cfg) user;
+        settings = mapAttrs (name: mkDefault) {
+          "listen.owner" = "nginx";
+          "listen.group" = "nginx";
+          "listen.mode" = "0600";
+          "pm" = "dynamic";
+          "pm.max_children" = 75;
+          "pm.start_servers" = 10;
+          "pm.min_spare_servers" = 5;
+          "pm.max_spare_servers" = 20;
+          "pm.max_requests" = 500;
+          "catch_workers_output" = 1;
+        };
+      };
+    };
+
+    # NOTE: No configuration is done if not using virtual host
+    services.nginx = mkIf (cfg.virtualHost != null) {
+      enable = true;
+      virtualHosts = {
+        ${cfg.virtualHost} = {
+          root = "${cfg.root}/www";
+
+          locations."/" = {
+            index = "index.php";
+          };
+
+          locations."^~ /feed-icons" = {
+            root = "${cfg.root}";
+          };
+
+          locations."~ \\.php$" = {
+            extraConfig = ''
+              fastcgi_split_path_info ^(.+\.php)(/.+)$;
+              fastcgi_pass unix:${config.services.phpfpm.pools.${cfg.pool}.socket};
+              fastcgi_index index.php;
+            '';
+          };
+        };
+      };
+    };
+
+    systemd.tmpfiles.rules = [
+      "d '${cfg.root}' 0555 ${cfg.user} tt_rss - -"
+      "d '${cfg.root}/lock' 0755 ${cfg.user} tt_rss - -"
+      "d '${cfg.root}/cache' 0755 ${cfg.user} tt_rss - -"
+      "d '${cfg.root}/cache/upload' 0755 ${cfg.user} tt_rss - -"
+      "d '${cfg.root}/cache/images' 0755 ${cfg.user} tt_rss - -"
+      "d '${cfg.root}/cache/export' 0755 ${cfg.user} tt_rss - -"
+      "d '${cfg.root}/feed-icons' 0755 ${cfg.user} tt_rss - -"
+      "L+ '${cfg.root}/www' - - - - ${servedRoot}"
+    ];
+
+    systemd.services = {
+      phpfpm-tt-rss = mkIf (cfg.pool == "${poolName}") {
+        restartTriggers = [ servedRoot ];
+      };
+
+      tt-rss = {
+        description = "Tiny Tiny RSS feeds update daemon";
+
+        preStart = let
+          callSql = e:
+              if cfg.database.type == "pgsql" then ''
+                  ${optionalString (cfg.database.password != null) "PGPASSWORD=${cfg.database.password}"} \
+                  ${optionalString (cfg.database.passwordFile != null) "PGPASSWORD=$(cat ${cfg.database.passwordFile})"} \
+                  ${config.services.postgresql.package}/bin/psql \
+                    -U ${cfg.database.user} \
+                    ${optionalString (cfg.database.host != null) "-h ${cfg.database.host} --port ${toString dbPort}"} \
+                    -c '${e}' \
+                    ${cfg.database.name}''
+
+              else if cfg.database.type == "mysql" then ''
+                  echo '${e}' | ${config.services.mysql.package}/bin/mysql \
+                    -u ${cfg.database.user} \
+                    ${optionalString (cfg.database.password != null) "-p${cfg.database.password}"} \
+                    ${optionalString (cfg.database.host != null) "-h ${cfg.database.host} -P ${toString dbPort}"} \
+                    ${cfg.database.name}''
+
+              else "";
+
+        in (optionalString (cfg.database.type == "pgsql") ''
+          exists=$(${callSql "select count(*) > 0 from pg_tables where tableowner = user"} \
+          | tail -n+3 | head -n-2 | sed -e 's/[ \n\t]*//')
+
+          if [ "$exists" == 'f' ]; then
+            ${callSql "\\i ${pkgs.tt-rss}/schema/ttrss_schema_${cfg.database.type}.sql"}
+          else
+            echo 'The database contains some data. Leaving it as it is.'
+          fi;
+        '')
+
+        + (optionalString (cfg.database.type == "mysql") ''
+          exists=$(${callSql "select count(*) > 0 from information_schema.tables where table_schema = schema()"} \
+          | tail -n+2 | sed -e 's/[ \n\t]*//')
+
+          if [ "$exists" == '0' ]; then
+            ${callSql "\\. ${pkgs.tt-rss}/schema/ttrss_schema_${cfg.database.type}.sql"}
+          else
+            echo 'The database contains some data. Leaving it as it is.'
+          fi;
+        '');
+
+        serviceConfig = {
+          User = "${cfg.user}";
+          Group = "tt_rss";
+          ExecStart = "${pkgs.php}/bin/php ${cfg.root}/www/update.php --daemon --quiet";
+          Restart = "on-failure";
+          RestartSec = "60";
+          SyslogIdentifier = "tt-rss";
+        };
+
+        wantedBy = [ "multi-user.target" ];
+        requires = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
+        after = [ "network.target" ] ++ optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
+      };
+    };
+
+    services.mysql = mkIf mysqlLocal {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        {
+          name = cfg.user;
+          ensurePermissions = {
+            "${cfg.database.name}.*" = "ALL PRIVILEGES";
+          };
+        }
+      ];
+    };
+
+    services.postgresql = mkIf pgsqlLocal {
+      enable = mkDefault true;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        { name = cfg.user;
+          ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    users.users.tt_rss = optionalAttrs (cfg.user == "tt_rss") {
+      description = "tt-rss service user";
+      isSystemUser = true;
+      group = "tt_rss";
+    };
+
+    users.groups.tt_rss = {};
+  };
+}
diff --git a/nixos/modules/services/web-apps/vikunja.nix b/nixos/modules/services/web-apps/vikunja.nix
new file mode 100644
index 00000000000..7575e96ca81
--- /dev/null
+++ b/nixos/modules/services/web-apps/vikunja.nix
@@ -0,0 +1,145 @@
+{ pkgs, lib, config, ... }:
+
+with lib;
+
+let
+  cfg = config.services.vikunja;
+  format = pkgs.formats.yaml {};
+  configFile = format.generate "config.yaml" cfg.settings;
+  useMysql = cfg.database.type == "mysql";
+  usePostgresql = cfg.database.type == "postgres";
+in {
+  options.services.vikunja = with lib; {
+    enable = mkEnableOption "vikunja service";
+    package-api = mkOption {
+      default = pkgs.vikunja-api;
+      type = types.package;
+      defaultText = literalExpression "pkgs.vikunja-api";
+      description = "vikunja-api derivation to use.";
+    };
+    package-frontend = mkOption {
+      default = pkgs.vikunja-frontend;
+      type = types.package;
+      defaultText = literalExpression "pkgs.vikunja-frontend";
+      description = "vikunja-frontend derivation to use.";
+    };
+    environmentFiles = mkOption {
+      type = types.listOf types.path;
+      default = [ ];
+      description = ''
+        List of environment files set in the vikunja systemd service.
+        For example passwords should be set in one of these files.
+      '';
+    };
+    setupNginx = mkOption {
+      type = types.bool;
+      default = config.services.nginx.enable;
+      defaultText = literalExpression "config.services.nginx.enable";
+      description = ''
+        Whether to setup NGINX.
+        Further nginx configuration can be done by changing
+        <option>services.nginx.virtualHosts.&lt;frontendHostname&gt;</option>.
+        This does not enable TLS or ACME by default. To enable this, set the
+        <option>services.nginx.virtualHosts.&lt;frontendHostname&gt;.enableACME</option> to
+        <literal>true</literal> and if appropriate do the same for
+        <option>services.nginx.virtualHosts.&lt;frontendHostname&gt;.forceSSL</option>.
+      '';
+    };
+    frontendScheme = mkOption {
+      type = types.enum [ "http" "https" ];
+      description = ''
+        Whether the site is available via http or https.
+        This does not configure https or ACME in nginx!
+      '';
+    };
+    frontendHostname = mkOption {
+      type = types.str;
+      description = "The Hostname under which the frontend is running.";
+    };
+
+    settings = mkOption {
+      type = format.type;
+      default = {};
+      description = ''
+        Vikunja configuration. Refer to
+        <link xlink:href="https://vikunja.io/docs/config-options/"/>
+        for details on supported values.
+        '';
+    };
+    database = {
+      type = mkOption {
+        type = types.enum [ "sqlite" "mysql" "postgres" ];
+        example = "postgres";
+        default = "sqlite";
+        description = "Database engine to use.";
+      };
+      host = mkOption {
+        type = types.str;
+        default = "localhost";
+        description = "Database host address. Can also be a socket.";
+      };
+      user = mkOption {
+        type = types.str;
+        default = "vikunja";
+        description = "Database user.";
+      };
+      database = mkOption {
+        type = types.str;
+        default = "vikunja";
+        description = "Database name.";
+      };
+      path = mkOption {
+        type = types.str;
+        default = "/var/lib/vikunja/vikunja.db";
+        description = "Path to the sqlite3 database file.";
+      };
+    };
+  };
+  config = lib.mkIf cfg.enable {
+    services.vikunja.settings = {
+      database = {
+        inherit (cfg.database) type host user database path;
+      };
+      service = {
+        frontendurl = "${cfg.frontendScheme}://${cfg.frontendHostname}/";
+      };
+      files = {
+        basepath = "/var/lib/vikunja/files";
+      };
+    };
+
+    systemd.services.vikunja-api = {
+      description = "vikunja-api";
+      after = [ "network.target" ] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service";
+      wantedBy = [ "multi-user.target" ];
+      path = [ cfg.package-api ];
+      restartTriggers = [ configFile ];
+
+      serviceConfig = {
+        Type = "simple";
+        DynamicUser = true;
+        StateDirectory = "vikunja";
+        ExecStart = "${cfg.package-api}/bin/vikunja";
+        Restart = "always";
+        EnvironmentFile = cfg.environmentFiles;
+      };
+    };
+
+    services.nginx.virtualHosts."${cfg.frontendHostname}" = mkIf cfg.setupNginx {
+      locations = {
+        "/" = {
+          root = cfg.package-frontend;
+          tryFiles = "try_files $uri $uri/ /";
+        };
+        "~* ^/(api|dav|\\.well-known)/" = {
+          proxyPass = "http://localhost:3456";
+          extraConfig = ''
+            client_max_body_size 20M;
+          '';
+        };
+      };
+    };
+
+    environment.etc."vikunja/config.yaml".source = configFile;
+  };
+}
diff --git a/nixos/modules/services/web-apps/virtlyst.nix b/nixos/modules/services/web-apps/virtlyst.nix
new file mode 100644
index 00000000000..37bdbb0e3b4
--- /dev/null
+++ b/nixos/modules/services/web-apps/virtlyst.nix
@@ -0,0 +1,73 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.virtlyst;
+  stateDir = "/var/lib/virtlyst";
+
+  ini = pkgs.writeText "virtlyst-config.ini" ''
+    [wsgi]
+    master = true
+    threads = auto
+    http-socket = ${cfg.httpSocket}
+    application = ${pkgs.virtlyst}/lib/libVirtlyst.so
+    chdir2 = ${stateDir}
+    static-map = /static=${pkgs.virtlyst}/root/static
+
+    [Cutelyst]
+    production = true
+    DatabasePath = virtlyst.sqlite
+    TemplatePath = ${pkgs.virtlyst}/root/src
+
+    [Rules]
+    cutelyst.* = true
+    virtlyst.* = true
+  '';
+
+in
+
+{
+
+  options.services.virtlyst = {
+    enable = mkEnableOption "Virtlyst libvirt web interface";
+
+    adminPassword = mkOption {
+      type = types.str;
+      description = ''
+        Initial admin password with which the database will be seeded.
+      '';
+    };
+
+    httpSocket = mkOption {
+      type = types.str;
+      default = "localhost:3000";
+      description = ''
+        IP and/or port to which to bind the http socket.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.virtlyst = {
+      home = stateDir;
+      createHome = true;
+      group = mkIf config.virtualisation.libvirtd.enable "libvirtd";
+      isSystemUser = true;
+    };
+
+    systemd.services.virtlyst = {
+      wantedBy = [ "multi-user.target" ];
+      environment = {
+        VIRTLYST_ADMIN_PASSWORD = cfg.adminPassword;
+      };
+      serviceConfig = {
+        ExecStart = "${pkgs.cutelyst}/bin/cutelyst-wsgi2 --ini ${ini}";
+        User = "virtlyst";
+        WorkingDirectory = stateDir;
+      };
+    };
+  };
+
+}
diff --git a/nixos/modules/services/web-apps/whitebophir.nix b/nixos/modules/services/web-apps/whitebophir.nix
new file mode 100644
index 00000000000..f9db6fe379b
--- /dev/null
+++ b/nixos/modules/services/web-apps/whitebophir.nix
@@ -0,0 +1,52 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.whitebophir;
+in {
+  options = {
+    services.whitebophir = {
+      enable = mkEnableOption "whitebophir, an online collaborative whiteboard server (persistent state will be maintained under <filename>/var/lib/whitebophir</filename>)";
+
+      package = mkOption {
+        default = pkgs.whitebophir;
+        defaultText = literalExpression "pkgs.whitebophir";
+        type = types.package;
+        description = "Whitebophir package to use.";
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "0.0.0.0";
+        description = "Address to listen on (use 0.0.0.0 to allow access from any address).";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 5001;
+        description = "Port to bind to.";
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.whitebophir = {
+      description = "Whitebophir Service";
+      wantedBy    = [ "multi-user.target" ];
+      after       = [ "network.target" ];
+      environment = {
+        PORT            = toString cfg.port;
+        HOST            = toString cfg.listenAddress;
+        WBO_HISTORY_DIR = "/var/lib/whitebophir";
+      };
+
+      serviceConfig = {
+        DynamicUser    = true;
+        ExecStart      = "${cfg.package}/bin/whitebophir";
+        Restart        = "always";
+        StateDirectory = "whitebophir";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/wiki-js.nix b/nixos/modules/services/web-apps/wiki-js.nix
new file mode 100644
index 00000000000..1a6259dffee
--- /dev/null
+++ b/nixos/modules/services/web-apps/wiki-js.nix
@@ -0,0 +1,139 @@
+{ lib, pkgs, config, ... }:
+
+with lib;
+
+let
+  cfg = config.services.wiki-js;
+
+  format = pkgs.formats.json { };
+
+  configFile = format.generate "wiki-js.yml" cfg.settings;
+in {
+  options.services.wiki-js = {
+    enable = mkEnableOption "wiki-js";
+
+    environmentFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/root/wiki-js.env";
+      description = ''
+        Environment fiel to inject e.g. secrets into the configuration.
+      '';
+    };
+
+    stateDirectoryName = mkOption {
+      default = "wiki-js";
+      type = types.str;
+      description = ''
+        Name of the directory in <filename>/var/lib</filename>.
+      '';
+    };
+
+    settings = mkOption {
+      default = {};
+      type = types.submodule {
+        freeformType = format.type;
+        options = {
+          port = mkOption {
+            type = types.port;
+            default = 3000;
+            description = ''
+              TCP port the process should listen to.
+            '';
+          };
+
+          bindIP = mkOption {
+            default = "0.0.0.0";
+            type = types.str;
+            description = ''
+              IPs the service should listen to.
+            '';
+          };
+
+          db = {
+            type = mkOption {
+              default = "postgres";
+              type = types.enum [ "postgres" "mysql" "mariadb" "mssql" ];
+              description = ''
+                Database driver to use for persistence. Please note that <literal>sqlite</literal>
+                is currently not supported as the build process for it is currently not implemented
+                in <package>pkgs.wiki-js</package> and it's not recommended by upstream for
+                production use.
+              '';
+            };
+            host = mkOption {
+              type = types.str;
+              example = "/run/postgresql";
+              description = ''
+                Hostname or socket-path to connect to.
+              '';
+            };
+            db = mkOption {
+              default = "wiki";
+              type = types.str;
+              description = ''
+                Name of the database to use.
+              '';
+            };
+          };
+
+          logLevel = mkOption {
+            default = "info";
+            type = types.enum [ "error" "warn" "info" "verbose" "debug" "silly" ];
+            description = ''
+              Define how much detail is supposed to be logged at runtime.
+            '';
+          };
+
+          offline = mkEnableOption "offline mode" // {
+            description = ''
+              Disable latest file updates and enable
+              <link xlink:href="https://docs.requarks.io/install/sideload">sideloading</link>.
+            '';
+          };
+        };
+      };
+      description = ''
+        Settings to configure <package>wiki-js</package>. This directly
+        corresponds to <link xlink:href="https://docs.requarks.io/install/config">the upstream
+        configuration options</link>.
+
+        Secrets can be injected via the environment by
+        <itemizedlist>
+          <listitem><para>specifying <xref linkend="opt-services.wiki-js.environmentFile" />
+          to contain secrets</para></listitem>
+          <listitem><para>and setting sensitive values to <literal>$(ENVIRONMENT_VAR)</literal>
+          with this value defined in the environment-file.</para></listitem>
+        </itemizedlist>
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.wiki-js.settings.dataPath = "/var/lib/${cfg.stateDirectoryName}";
+    systemd.services.wiki-js = {
+      description = "A modern and powerful wiki app built on Node.js";
+      documentation = [ "https://docs.requarks.io/" ];
+      wantedBy = [ "multi-user.target" ];
+
+      path = with pkgs; [ coreutils ];
+      preStart = ''
+        ln -sf ${configFile} /var/lib/${cfg.stateDirectoryName}/config.yml
+        ln -sf ${pkgs.wiki-js}/server /var/lib/${cfg.stateDirectoryName}
+        ln -sf ${pkgs.wiki-js}/assets /var/lib/${cfg.stateDirectoryName}
+        ln -sf ${pkgs.wiki-js}/package.json /var/lib/${cfg.stateDirectoryName}/package.json
+      '';
+
+      serviceConfig = {
+        EnvironmentFile = mkIf (cfg.environmentFile != null) cfg.environmentFile;
+        StateDirectory = cfg.stateDirectoryName;
+        WorkingDirectory = "/var/lib/${cfg.stateDirectoryName}";
+        DynamicUser = true;
+        PrivateTmp = true;
+        ExecStart = "${pkgs.nodejs}/bin/node ${pkgs.wiki-js}/server";
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ ma27 ];
+}
diff --git a/nixos/modules/services/web-apps/wordpress.nix b/nixos/modules/services/web-apps/wordpress.nix
new file mode 100644
index 00000000000..59471a739cb
--- /dev/null
+++ b/nixos/modules/services/web-apps/wordpress.nix
@@ -0,0 +1,480 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg = config.services.wordpress;
+  eachSite = cfg.sites;
+  user = "wordpress";
+  webserver = config.services.${cfg.webserver};
+  stateDir = hostName: "/var/lib/wordpress/${hostName}";
+
+  pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
+    pname = "wordpress-${hostName}";
+    version = src.version;
+    src = cfg.package;
+
+    installPhase = ''
+      mkdir -p $out
+      cp -r * $out/
+
+      # symlink the wordpress config
+      ln -s ${wpConfig hostName cfg} $out/share/wordpress/wp-config.php
+      # symlink uploads directory
+      ln -s ${cfg.uploadsDir} $out/share/wordpress/wp-content/uploads
+
+      # https://github.com/NixOS/nixpkgs/pull/53399
+      #
+      # Symlinking works for most plugins and themes, but Avada, for instance, fails to
+      # understand the symlink, causing its file path stripping to fail. This results in
+      # requests that look like: https://example.com/wp-content//nix/store/...plugin/path/some-file.js
+      # Since hard linking directories is not allowed, copying is the next best thing.
+
+      # copy additional plugin(s) and theme(s)
+      ${concatMapStringsSep "\n" (theme: "cp -r ${theme} $out/share/wordpress/wp-content/themes/${theme.name}") cfg.themes}
+      ${concatMapStringsSep "\n" (plugin: "cp -r ${plugin} $out/share/wordpress/wp-content/plugins/${plugin.name}") cfg.plugins}
+    '';
+  };
+
+  wpConfig = hostName: cfg: pkgs.writeText "wp-config-${hostName}.php" ''
+    <?php
+      define('DB_NAME', '${cfg.database.name}');
+      define('DB_HOST', '${cfg.database.host}:${if cfg.database.socket != null then cfg.database.socket else toString cfg.database.port}');
+      define('DB_USER', '${cfg.database.user}');
+      ${optionalString (cfg.database.passwordFile != null) "define('DB_PASSWORD', file_get_contents('${cfg.database.passwordFile}'));"}
+      define('DB_CHARSET', 'utf8');
+      $table_prefix  = '${cfg.database.tablePrefix}';
+
+      require_once('${stateDir hostName}/secret-keys.php');
+
+      # wordpress is installed onto a read-only file system
+      define('DISALLOW_FILE_EDIT', true);
+      define('AUTOMATIC_UPDATER_DISABLED', true);
+
+      ${cfg.extraConfig}
+
+      if ( !defined('ABSPATH') )
+        define('ABSPATH', dirname(__FILE__) . '/');
+
+      require_once(ABSPATH . 'wp-settings.php');
+    ?>
+  '';
+
+  secretsVars = [ "AUTH_KEY" "SECURE_AUTH_KEY" "LOGGED_IN_KEY" "NONCE_KEY" "AUTH_SALT" "SECURE_AUTH_SALT" "LOGGED_IN_SALT" "NONCE_SALT" ];
+  secretsScript = hostStateDir: ''
+    # The match in this line is not a typo, see https://github.com/NixOS/nixpkgs/pull/124839
+    grep -q "LOOGGED_IN_KEY" "${hostStateDir}/secret-keys.php" && rm "${hostStateDir}/secret-keys.php"
+    if ! test -e "${hostStateDir}/secret-keys.php"; then
+      umask 0177
+      echo "<?php" >> "${hostStateDir}/secret-keys.php"
+      ${concatMapStringsSep "\n" (var: ''
+        echo "define('${var}', '`tr -dc a-zA-Z0-9 </dev/urandom | head -c 64`');" >> "${hostStateDir}/secret-keys.php"
+      '') secretsVars}
+      echo "?>" >> "${hostStateDir}/secret-keys.php"
+      chmod 440 "${hostStateDir}/secret-keys.php"
+    fi
+  '';
+
+  siteOpts = { lib, name, ... }:
+    {
+      options = {
+        package = mkOption {
+          type = types.package;
+          default = pkgs.wordpress;
+          defaultText = literalExpression "pkgs.wordpress";
+          description = "Which WordPress package to use.";
+        };
+
+        uploadsDir = mkOption {
+          type = types.path;
+          default = "/var/lib/wordpress/${name}/uploads";
+          description = ''
+            This directory is used for uploads of pictures. The directory passed here is automatically
+            created and permissions adjusted as required.
+          '';
+        };
+
+        plugins = mkOption {
+          type = types.listOf types.path;
+          default = [];
+          description = ''
+            List of path(s) to respective plugin(s) which are copied from the 'plugins' directory.
+            <note><para>These plugins need to be packaged before use, see example.</para></note>
+          '';
+          example = literalExpression ''
+            let
+              # Wordpress plugin 'embed-pdf-viewer' installation example
+              embedPdfViewerPlugin = pkgs.stdenv.mkDerivation {
+                name = "embed-pdf-viewer-plugin";
+                # Download the theme from the wordpress site
+                src = pkgs.fetchurl {
+                  url = "https://downloads.wordpress.org/plugin/embed-pdf-viewer.2.0.3.zip";
+                  sha256 = "1rhba5h5fjlhy8p05zf0p14c9iagfh96y91r36ni0rmk6y891lyd";
+                };
+                # We need unzip to build this package
+                nativeBuildInputs = [ pkgs.unzip ];
+                # Installing simply means copying all files to the output directory
+                installPhase = "mkdir -p $out; cp -R * $out/";
+              };
+            # And then pass this theme to the themes list like this:
+            in [ embedPdfViewerPlugin ]
+          '';
+        };
+
+        themes = mkOption {
+          type = types.listOf types.path;
+          default = [];
+          description = ''
+            List of path(s) to respective theme(s) which are copied from the 'theme' directory.
+            <note><para>These themes need to be packaged before use, see example.</para></note>
+          '';
+          example = literalExpression ''
+            let
+              # Let's package the responsive theme
+              responsiveTheme = pkgs.stdenv.mkDerivation {
+                name = "responsive-theme";
+                # Download the theme from the wordpress site
+                src = pkgs.fetchurl {
+                  url = "https://downloads.wordpress.org/theme/responsive.3.14.zip";
+                  sha256 = "0rjwm811f4aa4q43r77zxlpklyb85q08f9c8ns2akcarrvj5ydx3";
+                };
+                # We need unzip to build this package
+                nativeBuildInputs = [ pkgs.unzip ];
+                # Installing simply means copying all files to the output directory
+                installPhase = "mkdir -p $out; cp -R * $out/";
+              };
+            # And then pass this theme to the themes list like this:
+            in [ responsiveTheme ]
+          '';
+        };
+
+        database = {
+          host = mkOption {
+            type = types.str;
+            default = "localhost";
+            description = "Database host address.";
+          };
+
+          port = mkOption {
+            type = types.port;
+            default = 3306;
+            description = "Database host port.";
+          };
+
+          name = mkOption {
+            type = types.str;
+            default = "wordpress";
+            description = "Database name.";
+          };
+
+          user = mkOption {
+            type = types.str;
+            default = "wordpress";
+            description = "Database user.";
+          };
+
+          passwordFile = mkOption {
+            type = types.nullOr types.path;
+            default = null;
+            example = "/run/keys/wordpress-dbpassword";
+            description = ''
+              A file containing the password corresponding to
+              <option>database.user</option>.
+            '';
+          };
+
+          tablePrefix = mkOption {
+            type = types.str;
+            default = "wp_";
+            description = ''
+              The $table_prefix is the value placed in the front of your database tables.
+              Change the value if you want to use something other than wp_ for your database
+              prefix. Typically this is changed if you are installing multiple WordPress blogs
+              in the same database.
+
+              See <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php#table_prefix'/>.
+            '';
+          };
+
+          socket = mkOption {
+            type = types.nullOr types.path;
+            default = null;
+            defaultText = literalExpression "/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 (import ../web-servers/apache-httpd/vhost-options.nix);
+          example = literalExpression ''
+            {
+              adminAddr = "webmaster@example.org";
+              forceSSL = true;
+              enableACME = true;
+            }
+          '';
+          description = ''
+            Apache configuration can be done by adapting <option>services.httpd.virtualHosts</option>.
+          '';
+        };
+
+        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 WordPress PHP pool. See the documentation on <literal>php-fpm.conf</literal>
+            for details on configuration directives.
+          '';
+        };
+
+        extraConfig = mkOption {
+          type = types.lines;
+          default = "";
+          description = ''
+            Any additional text to be appended to the wp-config.php
+            configuration file. This is a PHP script. For configuration
+            settings, see <link xlink:href='https://codex.wordpress.org/Editing_wp-config.php'/>.
+          '';
+          example = ''
+            define( 'AUTOSAVE_INTERVAL', 60 ); // Seconds
+          '';
+        };
+      };
+
+      config.virtualHost.hostName = mkDefault name;
+    };
+in
+{
+  # interface
+  options = {
+    services.wordpress = {
+
+      sites = mkOption {
+        type = types.attrsOf (types.submodule siteOpts);
+        default = {};
+        description = "Specification of one or more WordPress sites to serve";
+      };
+
+      webserver = mkOption {
+        type = types.enum [ "httpd" "nginx" "caddy" ];
+        default = "httpd";
+        description = ''
+          Whether to use apache2 or nginx for virtual host management.
+
+          Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.&lt;name&gt;</literal>.
+          See <xref linkend="opt-services.nginx.virtualHosts"/> for further information.
+
+          Further apache2 configuration can be done by adapting <literal>services.httpd.virtualHosts.&lt;name&gt;</literal>.
+          See <xref linkend="opt-services.httpd.virtualHosts"/> for further information.
+        '';
+      };
+
+    };
+  };
+
+  # implementation
+  config = mkIf (eachSite != {}) (mkMerge [{
+
+    assertions =
+      (mapAttrsToList (hostName: cfg:
+        { assertion = cfg.database.createLocally -> cfg.database.user == user;
+          message = ''services.wordpress.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
+        }) eachSite) ++
+      (mapAttrsToList (hostName: cfg:
+        { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
+          message = ''services.wordpress.sites."${hostName}".database.passwordFile cannot be specified if services.wordpress.sites."${hostName}".database.createLocally is set to true.'';
+        }) eachSite);
+
+
+    services.mysql = mkIf (any (v: v.database.createLocally) (attrValues eachSite)) {
+      enable = true;
+      package = mkDefault pkgs.mariadb;
+      ensureDatabases = mapAttrsToList (hostName: cfg: cfg.database.name) eachSite;
+      ensureUsers = mapAttrsToList (hostName: cfg:
+        { name = cfg.database.user;
+          ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
+        }
+      ) eachSite;
+    };
+
+    services.phpfpm.pools = mapAttrs' (hostName: cfg: (
+      nameValuePair "wordpress-${hostName}" {
+        inherit user;
+        group = webserver.group;
+        settings = {
+          "listen.owner" = webserver.user;
+          "listen.group" = webserver.group;
+        } // cfg.poolConfig;
+      }
+    )) eachSite;
+
+  }
+
+  (mkIf (cfg.webserver == "httpd") {
+    services.httpd = {
+      enable = true;
+      extraModules = [ "proxy_fcgi" ];
+      virtualHosts = mapAttrs (hostName: cfg: mkMerge [ cfg.virtualHost {
+        documentRoot = mkForce "${pkg hostName cfg}/share/wordpress";
+        extraConfig = ''
+          <Directory "${pkg hostName cfg}/share/wordpress">
+            <FilesMatch "\.php$">
+              <If "-f %{REQUEST_FILENAME}">
+                SetHandler "proxy:unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket}|fcgi://localhost/"
+              </If>
+            </FilesMatch>
+
+            # standard wordpress .htaccess contents
+            <IfModule mod_rewrite.c>
+              RewriteEngine On
+              RewriteBase /
+              RewriteRule ^index\.php$ - [L]
+              RewriteCond %{REQUEST_FILENAME} !-f
+              RewriteCond %{REQUEST_FILENAME} !-d
+              RewriteRule . /index.php [L]
+            </IfModule>
+
+            DirectoryIndex index.php
+            Require all granted
+            Options +FollowSymLinks -Indexes
+          </Directory>
+
+          # https://wordpress.org/support/article/hardening-wordpress/#securing-wp-config-php
+          <Files wp-config.php>
+            Require all denied
+          </Files>
+        '';
+      } ]) eachSite;
+    };
+  })
+
+  {
+    systemd.tmpfiles.rules = flatten (mapAttrsToList (hostName: cfg: [
+      "d '${stateDir hostName}' 0750 ${user} ${webserver.group} - -"
+      "d '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
+      "Z '${cfg.uploadsDir}' 0750 ${user} ${webserver.group} - -"
+    ]) eachSite);
+
+    systemd.services = mkMerge [
+      (mapAttrs' (hostName: cfg: (
+        nameValuePair "wordpress-init-${hostName}" {
+          wantedBy = [ "multi-user.target" ];
+          before = [ "phpfpm-wordpress-${hostName}.service" ];
+          after = optional cfg.database.createLocally "mysql.service";
+          script = secretsScript (stateDir hostName);
+
+          serviceConfig = {
+            Type = "oneshot";
+            User = user;
+            Group = webserver.group;
+          };
+      })) eachSite)
+
+      (optionalAttrs (any (v: v.database.createLocally) (attrValues eachSite)) {
+        httpd.after = [ "mysql.service" ];
+      })
+    ];
+
+    users.users.${user} = {
+      group = webserver.group;
+      isSystemUser = true;
+    };
+  }
+
+  (mkIf (cfg.webserver == "nginx") {
+    services.nginx = {
+      enable = true;
+      virtualHosts = mapAttrs (hostName: cfg: {
+        serverName = mkDefault hostName;
+        root = "${pkg hostName cfg}/share/wordpress";
+        extraConfig = ''
+          index index.php;
+        '';
+        locations = {
+          "/" = {
+            priority = 200;
+            extraConfig = ''
+              try_files $uri $uri/ /index.php$is_args$args;
+            '';
+          };
+          "~ \\.php$" = {
+            priority = 500;
+            extraConfig = ''
+              fastcgi_split_path_info ^(.+\.php)(/.+)$;
+              fastcgi_pass unix:${config.services.phpfpm.pools."wordpress-${hostName}".socket};
+              fastcgi_index index.php;
+              include "${config.services.nginx.package}/conf/fastcgi.conf";
+              fastcgi_param PATH_INFO $fastcgi_path_info;
+              fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
+              # Mitigate https://httpoxy.org/ vulnerabilities
+              fastcgi_param HTTP_PROXY "";
+              fastcgi_intercept_errors off;
+              fastcgi_buffer_size 16k;
+              fastcgi_buffers 4 16k;
+              fastcgi_connect_timeout 300;
+              fastcgi_send_timeout 300;
+              fastcgi_read_timeout 300;
+            '';
+          };
+          "~ /\\." = {
+            priority = 800;
+            extraConfig = "deny all;";
+          };
+          "~* /(?:uploads|files)/.*\\.php$" = {
+            priority = 900;
+            extraConfig = "deny all;";
+          };
+          "~* \\.(js|css|png|jpg|jpeg|gif|ico)$" = {
+            priority = 1000;
+            extraConfig = ''
+              expires max;
+              log_not_found off;
+            '';
+          };
+        };
+      }) eachSite;
+    };
+  })
+
+  (mkIf (cfg.webserver == "caddy") {
+    services.caddy = {
+      enable = true;
+      virtualHosts = mapAttrs' (hostName: cfg: (
+        nameValuePair "http://${hostName}" {
+          extraConfig = ''
+            root    * /${pkg hostName cfg}/share/wordpress
+            file_server
+
+            php_fastcgi unix/${config.services.phpfpm.pools."wordpress-${hostName}".socket}
+
+            @uploads {
+              path_regexp path /uploads\/(.*)\.php
+            }
+            rewrite @uploads /
+
+            @wp-admin {
+              path  not ^\/wp-admin/*
+            }
+            rewrite @wp-admin {path}/index.php?{query}
+          '';
+        }
+      )) eachSite;
+    };
+  })
+
+
+  ]);
+}
diff --git a/nixos/modules/services/web-apps/youtrack.nix b/nixos/modules/services/web-apps/youtrack.nix
new file mode 100644
index 00000000000..b83265ffeab
--- /dev/null
+++ b/nixos/modules/services/web-apps/youtrack.nix
@@ -0,0 +1,181 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.youtrack;
+
+  extraAttr = concatStringsSep " " (mapAttrsToList (k: v: "-D${k}=${v}") (stdParams // cfg.extraParams));
+  mergeAttrList = lib.foldl' lib.mergeAttrs {};
+
+  stdParams = mergeAttrList [
+    (optionalAttrs (cfg.baseUrl != null) {
+      "jetbrains.youtrack.baseUrl" = cfg.baseUrl;
+    })
+    {
+    "java.aws.headless" = "true";
+    "jetbrains.youtrack.disableBrowser" = "true";
+    }
+  ];
+in
+{
+  options.services.youtrack = {
+
+    enable = mkEnableOption "YouTrack service";
+
+    address = mkOption {
+      description = ''
+        The interface youtrack will listen on.
+      '';
+      default = "127.0.0.1";
+      type = types.str;
+    };
+
+    baseUrl = mkOption {
+      description = ''
+        Base URL for youtrack. Will be auto-detected and stored in database.
+      '';
+      type = types.nullOr types.str;
+      default = null;
+    };
+
+    extraParams = mkOption {
+      default = {};
+      description = ''
+        Extra parameters to pass to youtrack. See
+        https://www.jetbrains.com/help/youtrack/standalone/YouTrack-Java-Start-Parameters.html
+        for more information.
+      '';
+      example = literalExpression ''
+        {
+          "jetbrains.youtrack.overrideRootPassword" = "tortuga";
+        }
+      '';
+      type = types.attrsOf types.str;
+    };
+
+    package = mkOption {
+      description = ''
+        Package to use.
+      '';
+      type = types.package;
+      default = pkgs.youtrack;
+      defaultText = literalExpression "pkgs.youtrack";
+    };
+
+    port = mkOption {
+      description = ''
+        The port youtrack will listen on.
+      '';
+      default = 8080;
+      type = types.int;
+    };
+
+    statePath = mkOption {
+      description = ''
+        Where to keep the youtrack database.
+      '';
+      type = types.path;
+      default = "/var/lib/youtrack";
+    };
+
+    virtualHost = mkOption {
+      description = ''
+        Name of the nginx virtual host to use and setup.
+        If null, do not setup anything.
+      '';
+      default = null;
+      type = types.nullOr types.str;
+    };
+
+    jvmOpts = mkOption {
+      description = ''
+        Extra options to pass to the JVM.
+        See https://www.jetbrains.com/help/youtrack/standalone/Configure-JVM-Options.html
+        for more information.
+      '';
+      type = types.separatedString " ";
+      example = "-XX:MetaspaceSize=250m";
+      default = "";
+    };
+
+    maxMemory = mkOption {
+      description = ''
+        Maximum Java heap size
+      '';
+      type = types.str;
+      default = "1g";
+    };
+
+    maxMetaspaceSize = mkOption {
+      description = ''
+        Maximum java Metaspace memory.
+      '';
+      type = types.str;
+      default = "350m";
+    };
+  };
+
+  config = mkIf cfg.enable {
+
+    systemd.services.youtrack = {
+      environment.HOME = cfg.statePath;
+      environment.YOUTRACK_JVM_OPTS = "${extraAttr}";
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+      path = with pkgs; [ unixtools.hostname ];
+      serviceConfig = {
+        Type = "simple";
+        User = "youtrack";
+        Group = "youtrack";
+        Restart = "on-failure";
+        ExecStart = ''${cfg.package}/bin/youtrack --J-Xmx${cfg.maxMemory} --J-XX:MaxMetaspaceSize=${cfg.maxMetaspaceSize} ${cfg.jvmOpts} ${cfg.address}:${toString cfg.port}'';
+      };
+    };
+
+    users.users.youtrack = {
+      description = "Youtrack service user";
+      isSystemUser = true;
+      home = cfg.statePath;
+      createHome = true;
+      group = "youtrack";
+    };
+
+    users.groups.youtrack = {};
+
+    services.nginx = mkIf (cfg.virtualHost != null) {
+      upstreams.youtrack.servers."${cfg.address}:${toString cfg.port}" = {};
+      virtualHosts.${cfg.virtualHost}.locations = {
+        "/" = {
+          proxyPass = "http://youtrack";
+          extraConfig = ''
+            client_max_body_size 10m;
+            proxy_http_version 1.1;
+            proxy_set_header X-Forwarded-Host $http_host;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+          '';
+        };
+
+        "/api/eventSourceBus" = {
+          proxyPass = "http://youtrack";
+          extraConfig = ''
+            proxy_cache off;
+            proxy_buffering off;
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+            proxy_set_header Connection "";
+            chunked_transfer_encoding off;
+            client_max_body_size 10m;
+            proxy_http_version 1.1;
+            proxy_set_header X-Forwarded-Host $http_host;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+          '';
+        };
+
+      };
+    };
+
+  };
+}
diff --git a/nixos/modules/services/web-apps/zabbix.nix b/nixos/modules/services/web-apps/zabbix.nix
new file mode 100644
index 00000000000..538dac0d5be
--- /dev/null
+++ b/nixos/modules/services/web-apps/zabbix.nix
@@ -0,0 +1,238 @@
+{ config, lib, options, pkgs, ... }:
+
+let
+
+  inherit (lib) mkDefault mkEnableOption mkForce mkIf mkMerge mkOption types;
+  inherit (lib) literalExpression mapAttrs optionalString versionAtLeast;
+
+  cfg = config.services.zabbixWeb;
+  opt = options.services.zabbixWeb;
+  fpm = config.services.phpfpm.pools.zabbix;
+
+  user = "zabbix";
+  group = "zabbix";
+  stateDir = "/var/lib/zabbix";
+
+  zabbixConfig = pkgs.writeText "zabbix.conf.php" ''
+    <?php
+    // Zabbix GUI configuration file.
+    global $DB;
+    $DB['TYPE'] = '${ { mysql = "MYSQL"; pgsql = "POSTGRESQL"; oracle = "ORACLE"; }.${cfg.database.type} }';
+    $DB['SERVER'] = '${cfg.database.host}';
+    $DB['PORT'] = '${toString cfg.database.port}';
+    $DB['DATABASE'] = '${cfg.database.name}';
+    $DB['USER'] = '${cfg.database.user}';
+    # NOTE: file_get_contents adds newline at the end of returned string
+    $DB['PASSWORD'] = ${if cfg.database.passwordFile != null then "trim(file_get_contents('${cfg.database.passwordFile}'), \"\\r\\n\")" else "''"};
+    // Schema name. Used for IBM DB2 and PostgreSQL.
+    $DB['SCHEMA'] = ''';
+    $ZBX_SERVER = '${cfg.server.address}';
+    $ZBX_SERVER_PORT = '${toString cfg.server.port}';
+    $ZBX_SERVER_NAME = ''';
+    $IMAGE_FORMAT_DEFAULT = IMAGE_FORMAT_PNG;
+
+    ${cfg.extraConfig}
+  '';
+
+in
+{
+  # interface
+
+  options.services = {
+    zabbixWeb = {
+      enable = mkEnableOption "the Zabbix web interface";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.zabbix.web;
+        defaultText = literalExpression "zabbix.web";
+        description = "Which Zabbix package to use.";
+      };
+
+      server = {
+        port = mkOption {
+          type = types.int;
+          description = "The port of the Zabbix server to connect to.";
+          default = 10051;
+        };
+
+        address = mkOption {
+          type = types.str;
+          description = "The IP address or hostname of the Zabbix server to connect to.";
+          default = "localhost";
+        };
+      };
+
+      database = {
+        type = mkOption {
+          type = types.enum [ "mysql" "pgsql" "oracle" ];
+          example = "mysql";
+          default = "pgsql";
+          description = "Database engine to use.";
+        };
+
+        host = mkOption {
+          type = types.str;
+          default = "";
+          description = "Database host address.";
+        };
+
+        port = mkOption {
+          type = types.int;
+          default =
+            if cfg.database.type == "mysql" then config.services.mysql.port
+            else if cfg.database.type == "pgsql" then config.services.postgresql.port
+            else 1521;
+          defaultText = literalExpression ''
+            if config.${opt.database.type} == "mysql" then config.${options.services.mysql.port}
+            else if config.${opt.database.type} == "pgsql" then config.${options.services.postgresql.port}
+            else 1521
+          '';
+          description = "Database host port.";
+        };
+
+        name = mkOption {
+          type = types.str;
+          default = "zabbix";
+          description = "Database name.";
+        };
+
+        user = mkOption {
+          type = types.str;
+          default = "zabbix";
+          description = "Database user.";
+        };
+
+        passwordFile = mkOption {
+          type = types.nullOr types.path;
+          default = null;
+          example = "/run/keys/zabbix-dbpassword";
+          description = ''
+            A file containing the password corresponding to
+            <option>database.user</option>.
+          '';
+        };
+
+        socket = mkOption {
+          type = types.nullOr types.path;
+          default = null;
+          example = "/run/postgresql";
+          description = "Path to the unix socket file to use for authentication.";
+        };
+      };
+
+      virtualHost = mkOption {
+        type = types.submodule (import ../web-servers/apache-httpd/vhost-options.nix);
+        example = literalExpression ''
+          {
+            hostName = "zabbix.example.org";
+            adminAddr = "webmaster@example.org";
+            forceSSL = true;
+            enableACME = true;
+          }
+        '';
+        description = ''
+          Apache configuration can be done by adapting <literal>services.httpd.virtualHosts.&lt;name&gt;</literal>.
+          See <xref linkend="opt-services.httpd.virtualHosts"/> 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 Zabbix PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives.
+        '';
+      };
+
+      extraConfig = mkOption {
+        type = types.lines;
+        default = "";
+        description = ''
+          Additional configuration to be copied verbatim into <filename>zabbix.conf.php</filename>.
+        '';
+      };
+
+    };
+  };
+
+  # implementation
+
+  config = mkIf cfg.enable {
+
+    services.zabbixWeb.extraConfig = optionalString ((versionAtLeast config.system.stateVersion "20.09") && (versionAtLeast cfg.package.version "5.0.0")) ''
+      $DB['DOUBLE_IEEE754'] = 'true';
+    '';
+
+    systemd.tmpfiles.rules = [
+      "d '${stateDir}' 0750 ${user} ${group} - -"
+      "d '${stateDir}/session' 0750 ${user} ${config.services.httpd.group} - -"
+    ];
+
+    services.phpfpm.pools.zabbix = {
+      inherit user;
+      group = config.services.httpd.group;
+      phpOptions = ''
+        # https://www.zabbix.com/documentation/current/manual/installation/install
+        memory_limit = 128M
+        post_max_size = 16M
+        upload_max_filesize = 2M
+        max_execution_time = 300
+        max_input_time = 300
+        session.auto_start = 0
+        mbstring.func_overload = 0
+        always_populate_raw_post_data = -1
+        # https://bbs.archlinux.org/viewtopic.php?pid=1745214#p1745214
+        session.save_path = ${stateDir}/session
+      '' + optionalString (config.time.timeZone != null) ''
+        date.timezone = "${config.time.timeZone}"
+      '' + optionalString (cfg.database.type == "oracle") ''
+        extension=${pkgs.phpPackages.oci8}/lib/php/extensions/oci8.so
+      '';
+      phpEnv.ZABBIX_CONFIG = "${zabbixConfig}";
+      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.${cfg.virtualHost.hostName} = mkMerge [ cfg.virtualHost {
+        documentRoot = mkForce "${cfg.package}/share/zabbix";
+        extraConfig = ''
+          <Directory "${cfg.package}/share/zabbix">
+            <FilesMatch "\.php$">
+              <If "-f %{REQUEST_FILENAME}">
+                SetHandler "proxy:unix:${fpm.socket}|fcgi://localhost/"
+              </If>
+            </FilesMatch>
+            AllowOverride all
+            Options -Indexes
+            DirectoryIndex index.php
+          </Directory>
+        '';
+      } ];
+    };
+
+    users.users.${user} = mapAttrs (name: mkDefault) {
+      description = "Zabbix daemon user";
+      uid = config.ids.uids.zabbix;
+      inherit group;
+    };
+
+    users.groups.${group} = mapAttrs (name: mkDefault) {
+      gid = config.ids.gids.zabbix;
+    };
+
+  };
+}