about 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/bookstack.nix33
-rw-r--r--nixos/modules/services/web-apps/engelsystem.nix1
-rw-r--r--nixos/modules/services/web-apps/freshrss.nix7
-rw-r--r--nixos/modules/services/web-apps/gotosocial.nix2
-rw-r--r--nixos/modules/services/web-apps/komga.nix145
-rw-r--r--nixos/modules/services/web-apps/lemmy.nix3
-rw-r--r--nixos/modules/services/web-apps/mastodon.nix47
-rw-r--r--nixos/modules/services/web-apps/mattermost.nix4
-rw-r--r--nixos/modules/services/web-apps/mealie.nix79
-rw-r--r--nixos/modules/services/web-apps/miniflux.nix28
-rw-r--r--nixos/modules/services/web-apps/moodle.nix7
-rw-r--r--nixos/modules/services/web-apps/nextcloud-notify_push.nix2
-rw-r--r--nixos/modules/services/web-apps/nextcloud.md2
-rw-r--r--nixos/modules/services/web-apps/nextcloud.nix58
-rw-r--r--nixos/modules/services/web-apps/nifi.nix13
-rw-r--r--nixos/modules/services/web-apps/photoprism.nix17
-rw-r--r--nixos/modules/services/web-apps/pretalx.nix415
-rw-r--r--nixos/modules/services/web-apps/pretix.nix580
-rw-r--r--nixos/modules/services/web-apps/restya-board.nix380
-rw-r--r--nixos/modules/services/web-apps/suwayomi-server.nix55
-rw-r--r--nixos/modules/services/web-apps/tt-rss.nix19
-rw-r--r--nixos/modules/services/web-apps/vikunja.nix47
-rw-r--r--nixos/modules/services/web-apps/writefreely.nix6
-rw-r--r--nixos/modules/services/web-apps/youtrack.md30
-rw-r--r--nixos/modules/services/web-apps/youtrack.nix239
25 files changed, 1534 insertions, 685 deletions
diff --git a/nixos/modules/services/web-apps/bookstack.nix b/nixos/modules/services/web-apps/bookstack.nix
index d846c98577c8..4999eceb2b60 100644
--- a/nixos/modules/services/web-apps/bookstack.nix
+++ b/nixos/modules/services/web-apps/bookstack.nix
@@ -412,20 +412,25 @@ in {
       '';
     };
 
-    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} - -"
-    ];
+    systemd.tmpfiles.settings."10-bookstack" = let
+      defaultConfig = {
+        inherit user group;
+        mode = "0700";
+      };
+    in {
+      "${cfg.dataDir}".d = defaultConfig // { mode = "0710"; };
+      "${cfg.dataDir}/public".d = defaultConfig // { mode = "0750"; };
+      "${cfg.dataDir}/public/uploads".d = defaultConfig // { mode = "0750"; };
+      "${cfg.dataDir}/storage".d = defaultConfig;
+      "${cfg.dataDir}/storage/app".d = defaultConfig;
+      "${cfg.dataDir}/storage/fonts".d = defaultConfig;
+      "${cfg.dataDir}/storage/framework".d = defaultConfig;
+      "${cfg.dataDir}/storage/framework/cache".d = defaultConfig;
+      "${cfg.dataDir}/storage/framework/sessions".d = defaultConfig;
+      "${cfg.dataDir}/storage/framework/views".d = defaultConfig;
+      "${cfg.dataDir}/storage/logs".d = defaultConfig;
+      "${cfg.dataDir}/storage/uploads".d = defaultConfig;
+    };
 
     users = {
       users = mkIf (user == "bookstack") {
diff --git a/nixos/modules/services/web-apps/engelsystem.nix b/nixos/modules/services/web-apps/engelsystem.nix
index 669620debce5..ae7b2b9e7d0c 100644
--- a/nixos/modules/services/web-apps/engelsystem.nix
+++ b/nixos/modules/services/web-apps/engelsystem.nix
@@ -99,7 +99,6 @@ in {
       '';
 
     services.phpfpm.pools.engelsystem = {
-      phpPackage = pkgs.php81;
       user = "engelsystem";
       settings = {
         "listen.owner" = config.services.nginx.user;
diff --git a/nixos/modules/services/web-apps/freshrss.nix b/nixos/modules/services/web-apps/freshrss.nix
index c8399143c37b..edec9d547a30 100644
--- a/nixos/modules/services/web-apps/freshrss.nix
+++ b/nixos/modules/services/web-apps/freshrss.nix
@@ -228,9 +228,10 @@ in
       };
       users.groups."${cfg.user}" = { };
 
-      systemd.tmpfiles.rules = [
-        "d '${cfg.dataDir}' - ${cfg.user} ${config.users.users.${cfg.user}.group} - -"
-      ];
+      systemd.tmpfiles.settings."10-freshrss".${cfg.dataDir}.d = {
+        inherit (cfg) user;
+        group = config.users.users.${cfg.user}.group;
+      };
 
       systemd.services.freshrss-config =
         let
diff --git a/nixos/modules/services/web-apps/gotosocial.nix b/nixos/modules/services/web-apps/gotosocial.nix
index 45464f646da8..657509c11005 100644
--- a/nixos/modules/services/web-apps/gotosocial.nix
+++ b/nixos/modules/services/web-apps/gotosocial.nix
@@ -27,7 +27,7 @@ let
 in
 {
   meta.doc = ./gotosocial.md;
-  meta.maintainers = with lib.maintainers; [ misuzu ];
+  meta.maintainers = with lib.maintainers; [ misuzu blakesmith ];
 
   options.services.gotosocial = {
     enable = lib.mkEnableOption (lib.mdDoc "ActivityPub social network server");
diff --git a/nixos/modules/services/web-apps/komga.nix b/nixos/modules/services/web-apps/komga.nix
index 31f475fc7b04..d7ab2a9e612e 100644
--- a/nixos/modules/services/web-apps/komga.nix
+++ b/nixos/modules/services/web-apps/komga.nix
@@ -1,99 +1,122 @@
-{ config, pkgs, lib, ... }:
-
-with lib;
+{
+  config,
+  pkgs,
+  lib,
+  ...
+}:
 
 let
   cfg = config.services.komga;
-
-in {
+  inherit (lib) mkOption mkEnableOption maintainers;
+  inherit (lib.types) port str bool;
+in
+{
   options = {
     services.komga = {
-      enable = mkEnableOption (lib.mdDoc "Komga, a free and open source comics/mangas media server");
+      enable = mkEnableOption "Komga, a free and open source comics/mangas media server";
 
       port = mkOption {
-        type = types.port;
+        type = port;
         default = 8080;
-        description = lib.mdDoc ''
-          The port that Komga will listen on.
-        '';
+        description = "The port that Komga will listen on.";
       };
 
       user = mkOption {
-        type = types.str;
+        type = str;
         default = "komga";
-        description = lib.mdDoc ''
-          User account under which Komga runs.
-        '';
+        description = "User account under which Komga runs.";
       };
 
       group = mkOption {
-        type = types.str;
+        type = str;
         default = "komga";
-        description = lib.mdDoc ''
-          Group under which Komga runs.
-        '';
+        description = "Group under which Komga runs.";
       };
 
       stateDir = mkOption {
-        type = types.str;
+        type = str;
         default = "/var/lib/komga";
-        description = lib.mdDoc ''
-          State and configuration directory Komga will use.
-        '';
+        description = "State and configuration directory Komga will use.";
       };
 
       openFirewall = mkOption {
-        type = types.bool;
+        type = bool;
         default = false;
-        description = lib.mdDoc ''
-          Whether to open the firewall for the port in {option}`services.komga.port`.
-        '';
+        description = "Whether to open the firewall for the port in {option}`services.komga.port`.";
       };
     };
   };
 
-  config = mkIf cfg.enable {
-
-    networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
+  config =
+    let
+      inherit (lib) mkIf getExe;
+    in
+    mkIf cfg.enable {
 
-    users.groups = mkIf (cfg.group == "komga") {
-      komga = {};
-    };
+      networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ];
 
-    users.users = mkIf (cfg.user == "komga") {
-      komga = {
-        group = cfg.group;
-        home = cfg.stateDir;
-        description = "Komga Daemon user";
-        isSystemUser = true;
-      };
-    };
+      users.groups = mkIf (cfg.group == "komga") { komga = { }; };
 
-    systemd.services.komga = {
-      environment = {
-        SERVER_PORT = builtins.toString cfg.port;
-        KOMGA_CONFIGDIR = cfg.stateDir;
+      users.users = mkIf (cfg.user == "komga") {
+        komga = {
+          group = cfg.group;
+          home = cfg.stateDir;
+          description = "Komga Daemon user";
+          isSystemUser = true;
+        };
       };
 
-      description = "Komga is a free and open source comics/mangas media server";
-
-      wantedBy = [ "multi-user.target" ];
-      wants = [ "network-online.target" ];
-      after = [ "network-online.target" ];
-
-      serviceConfig = {
-        User = cfg.user;
-        Group = cfg.group;
-
-        Type = "simple";
-        Restart = "on-failure";
-        ExecStart = "${pkgs.komga}/bin/komga";
-
-        StateDirectory = mkIf (cfg.stateDir == "/var/lib/komga") "komga";
+      systemd.services.komga = {
+        environment = {
+          SERVER_PORT = builtins.toString cfg.port;
+          KOMGA_CONFIGDIR = cfg.stateDir;
+        };
+
+        description = "Komga is a free and open source comics/mangas media server";
+
+        wantedBy = [ "multi-user.target" ];
+        wants = [ "network-online.target" ];
+        after = [ "network-online.target" ];
+
+        serviceConfig = {
+          User = cfg.user;
+          Group = cfg.group;
+
+          Type = "simple";
+          Restart = "on-failure";
+          ExecStart = getExe pkgs.komga;
+
+          StateDirectory = mkIf (cfg.stateDir == "/var/lib/komga") "komga";
+
+          RemoveIPC = true;
+          NoNewPrivileges = true;
+          CapabilityBoundingSet = "";
+          SystemCallFilter = [ "@system-service" ];
+          ProtectSystem = "full";
+          PrivateTmp = true;
+          ProtectProc = "invisible";
+          ProtectClock = true;
+          ProcSubset = "pid";
+          PrivateUsers = true;
+          PrivateDevices = true;
+          ProtectHostname = true;
+          ProtectKernelTunables = true;
+          RestrictAddressFamilies = [
+            "AF_INET"
+            "AF_INET6"
+            "AF_NETLINK"
+          ];
+          LockPersonality = true;
+          RestrictNamespaces = true;
+          ProtectKernelLogs = true;
+          ProtectControlGroups = true;
+          ProtectKernelModules = true;
+          SystemCallArchitectures = "native";
+          RestrictSUIDSGID = true;
+          RestrictRealtime = true;
+        };
       };
-
     };
-  };
 
   meta.maintainers = with maintainers; [ govanify ];
 }
diff --git a/nixos/modules/services/web-apps/lemmy.nix b/nixos/modules/services/web-apps/lemmy.nix
index bde9051a7033..968dcac93fab 100644
--- a/nixos/modules/services/web-apps/lemmy.nix
+++ b/nixos/modules/services/web-apps/lemmy.nix
@@ -204,7 +204,6 @@ in
           };
           "/" = {
             # mixed frontend and backend requests, based on the request headers
-            proxyPass = "$proxpass";
             recommendedProxySettings = true;
             extraConfig = ''
               set $proxpass "${ui}";
@@ -220,6 +219,8 @@ in
 
               # Cuts off the trailing slash on URLs to make them valid
               rewrite ^(.+)/+$ $1 permanent;
+
+              proxy_pass $proxpass;
             '';
           };
         };
diff --git a/nixos/modules/services/web-apps/mastodon.nix b/nixos/modules/services/web-apps/mastodon.nix
index 538e728fcc72..7fc710c6fcec 100644
--- a/nixos/modules/services/web-apps/mastodon.nix
+++ b/nixos/modules/services/web-apps/mastodon.nix
@@ -4,7 +4,8 @@ let
   cfg = config.services.mastodon;
   opt = options.services.mastodon;
 
-  # We only want to create a database if we're actually going to connect to it.
+  # We only want to create a Redis and PostgreSQL databases if we're actually going to connect to it local.
+  redisActuallyCreateLocally = cfg.redis.createLocally && (cfg.redis.host == "127.0.0.1" || cfg.redis.enableUnixSocket);
   databaseActuallyCreateLocally = cfg.database.createLocally && cfg.database.host == "/run/postgresql";
 
   env = {
@@ -33,6 +34,7 @@ let
 
     TRUSTED_PROXY_IP = cfg.trustedProxy;
   }
+  // lib.optionalAttrs (cfg.redis.createLocally && cfg.redis.enableUnixSocket) { REDIS_URL = "unix://${config.services.redis.servers.mastodon.unixSocket}"; }
   // lib.optionalAttrs (cfg.database.host != "/run/postgresql" && cfg.database.port != null) { DB_PORT = toString cfg.database.port; }
   // lib.optionalAttrs cfg.smtp.authenticate { SMTP_LOGIN  = cfg.smtp.user; }
   // lib.optionalAttrs (cfg.elasticsearch.host != null) { ES_HOST = cfg.elasticsearch.host; }
@@ -116,9 +118,11 @@ let
       threads = toString (if processCfg.threads == null then cfg.sidekiqThreads else processCfg.threads);
     in {
       after = [ "network.target" "mastodon-init-dirs.service" ]
+        ++ lib.optional redisActuallyCreateLocally "redis-mastodon.service"
         ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
         ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
       requires = [ "mastodon-init-dirs.service" ]
+        ++ lib.optional redisActuallyCreateLocally "redis-mastodon.service"
         ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
         ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
       description = "Mastodon sidekiq${jobClassLabel}";
@@ -133,6 +137,7 @@ let
         RestartSec = 20;
         EnvironmentFile = [ "/var/lib/mastodon/.secrets_env" ] ++ cfg.extraEnvFiles;
         WorkingDirectory = cfg.package;
+        LimitNOFILE = "1024000";
         # System Call Filtering
         SystemCallFilter = [ ("~" + lib.concatStringsSep " " systemCallsList) "@chown" "pipe" "pipe2" ];
       } // cfgService;
@@ -145,9 +150,11 @@ let
         name = "mastodon-streaming-${toString i}";
         value = {
           after = [ "network.target" "mastodon-init-dirs.service" ]
+            ++ lib.optional redisActuallyCreateLocally "redis-mastodon.service"
             ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
             ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
           requires = [ "mastodon-init-dirs.service" ]
+            ++ lib.optional redisActuallyCreateLocally "redis-mastodon.service"
             ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
             ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
           wantedBy = [ "mastodon.target" "mastodon-streaming.target" ];
@@ -403,6 +410,19 @@ in {
           type = lib.types.port;
           default = 31637;
         };
+
+        passwordFile = lib.mkOption {
+          description = lib.mdDoc "A file containing the password for Redis database.";
+          type = lib.types.nullOr lib.types.path;
+          default = null;
+          example = "/run/keys/mastodon-redis-password";
+        };
+
+        enableUnixSocket = lib.mkOption {
+          description = lib.mdDoc "Use Unix socket";
+          type = lib.types.bool;
+          default = true;
+        };
       };
 
       database = {
@@ -612,6 +632,13 @@ in {
   config = lib.mkIf cfg.enable (lib.mkMerge [{
     assertions = [
       {
+        assertion = redisActuallyCreateLocally -> (!cfg.redis.enableUnixSocket || cfg.redis.passwordFile == null);
+        message = ''
+          <option>services.mastodon.redis.enableUnixSocket</option> needs to be disabled if
+            <option>services.mastodon.redis.passwordFile</option> is used.
+        '';
+      }
+      {
         assertion = databaseActuallyCreateLocally -> (cfg.user == cfg.database.user && cfg.database.user == cfg.database.name);
         message = ''
           For local automatic database provisioning (services.mastodon.database.createLocally == true) with peer
@@ -688,6 +715,8 @@ in {
         OTP_SECRET="$(cat ${cfg.otpSecretFile})"
         VAPID_PRIVATE_KEY="$(cat ${cfg.vapidPrivateKeyFile})"
         VAPID_PUBLIC_KEY="$(cat ${cfg.vapidPublicKeyFile})"
+      '' + lib.optionalString (cfg.redis.passwordFile != null)''
+        REDIS_PASSWORD="$(cat ${cfg.redis.passwordFile})"
       '' + lib.optionalString (cfg.database.passwordFile != null) ''
         DB_PASS="$(cat ${cfg.database.passwordFile})"
       '' + lib.optionalString cfg.smtp.authenticate ''
@@ -750,9 +779,11 @@ in {
 
     systemd.services.mastodon-web = {
       after = [ "network.target" "mastodon-init-dirs.service" ]
+        ++ lib.optional redisActuallyCreateLocally "redis-mastodon.service"
         ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
         ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
       requires = [ "mastodon-init-dirs.service" ]
+        ++ lib.optional redisActuallyCreateLocally "redis-mastodon.service"
         ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
         ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
       wantedBy = [ "mastodon.target" ];
@@ -833,11 +864,14 @@ in {
       enable = true;
       hostname = lib.mkDefault "${cfg.localDomain}";
     };
-    services.redis.servers.mastodon = lib.mkIf (cfg.redis.createLocally && cfg.redis.host == "127.0.0.1") {
-      enable = true;
-      port = cfg.redis.port;
-      bind = "127.0.0.1";
-    };
+    services.redis.servers.mastodon = lib.mkIf redisActuallyCreateLocally (lib.mkMerge [
+      {
+        enable = true;
+      }
+      (lib.mkIf (!cfg.redis.enableUnixSocket) {
+        port = cfg.redis.port;
+      })
+    ]);
     services.postgresql = lib.mkIf databaseActuallyCreateLocally {
       enable = true;
       ensureUsers = [
@@ -858,6 +892,7 @@ in {
         };
       })
       (lib.attrsets.setAttrByPath [ cfg.user "packages" ] [ cfg.package pkgs.imagemagick ])
+      (lib.mkIf (cfg.redis.createLocally && cfg.redis.enableUnixSocket) {${config.services.mastodon.user}.extraGroups = [ "redis-mastodon" ];})
     ];
 
     users.groups.${cfg.group}.members = lib.optional cfg.configureNginx config.services.nginx.user;
diff --git a/nixos/modules/services/web-apps/mattermost.nix b/nixos/modules/services/web-apps/mattermost.nix
index 503559432374..3d03c96d1c19 100644
--- a/nixos/modules/services/web-apps/mattermost.nix
+++ b/nixos/modules/services/web-apps/mattermost.nix
@@ -277,9 +277,7 @@ in
 
       # The systemd service will fail to execute the preStart hook
       # if the WorkingDirectory does not exist
-      systemd.tmpfiles.rules = [
-        ''d "${cfg.statePath}" -''
-      ];
+      systemd.tmpfiles.settings."10-mattermost".${cfg.statePath}.d = { };
 
       systemd.services.mattermost = {
         description = "Mattermost chat service";
diff --git a/nixos/modules/services/web-apps/mealie.nix b/nixos/modules/services/web-apps/mealie.nix
new file mode 100644
index 000000000000..8bb7542c6b56
--- /dev/null
+++ b/nixos/modules/services/web-apps/mealie.nix
@@ -0,0 +1,79 @@
+{ config, lib, pkgs, ...}:
+let
+  cfg = config.services.mealie;
+  pkg = cfg.package;
+in
+{
+  options.services.mealie = {
+    enable = lib.mkEnableOption "Mealie, a recipe manager and meal planner";
+
+    package = lib.mkPackageOption pkgs "mealie" { };
+
+    listenAddress = lib.mkOption {
+      type = lib.types.str;
+      default = "0.0.0.0";
+      description = "Address on which the service should listen.";
+    };
+
+    port = lib.mkOption {
+      type = lib.types.port;
+      default = 9000;
+      description = "Port on which to serve the Mealie service.";
+    };
+
+    settings = lib.mkOption {
+      type = with lib.types; attrsOf anything;
+      default = {};
+      description = lib.mdDoc ''
+        Configuration of the Mealie service.
+
+        See [the mealie documentation](https://nightly.mealie.io/documentation/getting-started/installation/backend-config/) for available options and default values.
+
+        In addition to the official documentation, you can set {env}`MEALIE_LOG_FILE`.
+      '';
+      example = {
+        ALLOW_SIGNUP = "false";
+      };
+    };
+
+    credentialsFile = lib.mkOption {
+      type = with lib.types; nullOr path;
+      default = null;
+      example = "/run/secrets/mealie-credentials.env";
+      description = ''
+        File containing credentials used in mealie such as {env}`POSTGRES_PASSWORD`
+        or sensitive LDAP options.
+
+        Expects the format of an `EnvironmentFile=`, as described by {manpage}`systemd.exec(5)`.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.mealie = {
+      description = "Mealie, a self hosted recipe manager and meal planner";
+
+      after = [ "network-online.target" ];
+      wants = [ "network-online.target" ];
+      wantedBy = [ "multi-user.target" ];
+
+      environment = {
+        PRODUCTION = "true";
+        ALEMBIC_CONFIG_FILE="${pkg}/config/alembic.ini";
+        API_PORT = toString cfg.port;
+        DATA_DIR = "/var/lib/mealie";
+        CRF_MODEL_PATH = "/var/lib/mealie/model.crfmodel";
+      } // (builtins.mapAttrs (_: val: toString val) cfg.settings);
+
+      serviceConfig = {
+        DynamicUser = true;
+        User = "mealie";
+        ExecStartPre = "${pkg}/libexec/init_db";
+        ExecStart = "${lib.getExe pkg} -b ${cfg.listenAddress}:${builtins.toString cfg.port}";
+        EnvironmentFile = lib.mkIf (cfg.credentialsFile != null) cfg.credentialsFile;
+        StateDirectory = "mealie";
+        StandardOutput="journal";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/miniflux.nix b/nixos/modules/services/web-apps/miniflux.nix
index 1a5b7d0c24e9..16b6fb0d655d 100644
--- a/nixos/modules/services/web-apps/miniflux.nix
+++ b/nixos/modules/services/web-apps/miniflux.nix
@@ -16,10 +16,20 @@ in
 {
   options = {
     services.miniflux = {
-      enable = mkEnableOption (lib.mdDoc "miniflux and creates a local postgres database for it");
+      enable = mkEnableOption (lib.mdDoc "miniflux");
 
       package = mkPackageOption pkgs "miniflux" { };
 
+      createDatabaseLocally = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        description = ''
+          Whether a PostgreSQL database should be automatically created and
+          configured on the local host. If set to `false`, you need provision a
+          database yourself and make sure to create the hstore extension in it.
+        '';
+      };
+
       config = mkOption {
         type = with types; attrsOf (oneOf [ str int ]);
         example = literalExpression ''
@@ -38,7 +48,7 @@ in
         '';
       };
 
-      adminCredentialsFile = mkOption  {
+      adminCredentialsFile = mkOption {
         type = types.path;
         description = lib.mdDoc ''
           File containing the ADMIN_USERNAME and
@@ -51,14 +61,14 @@ in
   };
 
   config = mkIf cfg.enable {
-    services.miniflux.config =  {
+    services.miniflux.config = {
       LISTEN_ADDR = mkDefault defaultAddress;
-      DATABASE_URL = "user=miniflux host=/run/postgresql dbname=miniflux";
+      DATABASE_URL = lib.mkIf cfg.createDatabaseLocally "user=miniflux host=/run/postgresql dbname=miniflux";
       RUN_MIGRATIONS = 1;
       CREATE_ADMIN = 1;
     };
 
-    services.postgresql = {
+    services.postgresql = lib.mkIf cfg.createDatabaseLocally {
       enable = true;
       ensureUsers = [ {
         name = "miniflux";
@@ -67,7 +77,7 @@ in
       ensureDatabases = [ "miniflux" ];
     };
 
-    systemd.services.miniflux-dbsetup = {
+    systemd.services.miniflux-dbsetup = lib.mkIf cfg.createDatabaseLocally {
       description = "Miniflux database setup";
       requires = [ "postgresql.service" ];
       after = [ "network.target" "postgresql.service" ];
@@ -81,8 +91,9 @@ in
     systemd.services.miniflux = {
       description = "Miniflux service";
       wantedBy = [ "multi-user.target" ];
-      requires = [ "miniflux-dbsetup.service" ];
-      after = [ "network.target" "postgresql.service" "miniflux-dbsetup.service" ];
+      requires = lib.optional cfg.createDatabaseLocally "miniflux-dbsetup.service";
+      after = [ "network.target" ]
+        ++ lib.optionals cfg.createDatabaseLocally [ "postgresql.service" "miniflux-dbsetup.service" ];
 
       serviceConfig = {
         ExecStart = "${cfg.package}/bin/miniflux";
@@ -129,6 +140,7 @@ in
         include "${pkgs.apparmorRulesFromClosure { name = "miniflux"; } cfg.package}"
         r ${cfg.package}/bin/miniflux,
         r @{sys}/kernel/mm/transparent_hugepage/hpage_pmd_size,
+        rw /run/miniflux/**,
       }
     '';
   };
diff --git a/nixos/modules/services/web-apps/moodle.nix b/nixos/modules/services/web-apps/moodle.nix
index ce6a80054725..496a0e32436f 100644
--- a/nixos/modules/services/web-apps/moodle.nix
+++ b/nixos/modules/services/web-apps/moodle.nix
@@ -255,9 +255,10 @@ in
       } ];
     };
 
-    systemd.tmpfiles.rules = [
-      "d '${stateDir}' 0750 ${user} ${group} - -"
-    ];
+    systemd.tmpfiles.settings."10-moodle".${stateDir}.d = {
+      inherit user group;
+      mode = "0750";
+    };
 
     systemd.services.moodle-init = {
       wantedBy = [ "multi-user.target" ];
diff --git a/nixos/modules/services/web-apps/nextcloud-notify_push.nix b/nixos/modules/services/web-apps/nextcloud-notify_push.nix
index 759daa0c50dc..7b90e0bbaa9b 100644
--- a/nixos/modules/services/web-apps/nextcloud-notify_push.nix
+++ b/nixos/modules/services/web-apps/nextcloud-notify_push.nix
@@ -116,7 +116,7 @@ in
       }
 
       (lib.mkIf cfg.bendDomainToLocalhost {
-        nextcloud.extraOptions.trusted_proxies = [ "127.0.0.1" "::1" ];
+        nextcloud.settings.trusted_proxies = [ "127.0.0.1" "::1" ];
       })
     ];
   };
diff --git a/nixos/modules/services/web-apps/nextcloud.md b/nixos/modules/services/web-apps/nextcloud.md
index ce8f96a6a389..5db83d7e4463 100644
--- a/nixos/modules/services/web-apps/nextcloud.md
+++ b/nixos/modules/services/web-apps/nextcloud.md
@@ -51,7 +51,7 @@ to ensure that changes can be applied by changing the module's options.
 In case the application serves multiple domains (those are checked with
 [`$_SERVER['HTTP_HOST']`](https://www.php.net/manual/en/reserved.variables.server.php))
 it's needed to add them to
-[`services.nextcloud.extraOptions.trusted_domains`](#opt-services.nextcloud.extraOptions.trusted_domains).
+[`services.nextcloud.settings.trusted_domains`](#opt-services.nextcloud.settings.trusted_domains).
 
 Auto updates for Nextcloud apps can be enabled using
 [`services.nextcloud.autoUpdateApps`](#opt-services.nextcloud.autoUpdateApps.enable).
diff --git a/nixos/modules/services/web-apps/nextcloud.nix b/nixos/modules/services/web-apps/nextcloud.nix
index 0b19265942c0..7f998207c434 100644
--- a/nixos/modules/services/web-apps/nextcloud.nix
+++ b/nixos/modules/services/web-apps/nextcloud.nix
@@ -14,7 +14,6 @@ let
     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";
@@ -45,7 +44,7 @@ let
     };
   };
 
-  webroot = pkgs.runCommand
+  webroot = pkgs.runCommandLocal
     "${cfg.package.name or "nextcloud"}-with-apps"
     { }
     ''
@@ -183,8 +182,8 @@ let
     ];
 
     $CONFIG = array_replace_recursive($CONFIG, nix_decode_json_file(
-      "${jsonFormat.generate "nextcloud-extraOptions.json" cfg.extraOptions}",
-      "impossible: this should never happen (decoding generated extraOptions file %s failed)"
+      "${jsonFormat.generate "nextcloud-settings.json" cfg.settings}",
+      "impossible: this should never happen (decoding generated settings file %s failed)"
     ));
 
     ${optionalString (cfg.secretFile != null) ''
@@ -205,21 +204,22 @@ in {
       Add port to services.nextcloud.config.dbhost instead.
     '')
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "logLevel" ] [ "services" "nextcloud" "extraOptions" "loglevel" ])
+      [ "services" "nextcloud" "logLevel" ] [ "services" "nextcloud" "settings" "loglevel" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "logType" ] [ "services" "nextcloud" "extraOptions" "log_type" ])
+      [ "services" "nextcloud" "logType" ] [ "services" "nextcloud" "settings" "log_type" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "config" "defaultPhoneRegion" ] [ "services" "nextcloud" "extraOptions" "default_phone_region" ])
+      [ "services" "nextcloud" "config" "defaultPhoneRegion" ] [ "services" "nextcloud" "settings" "default_phone_region" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "config" "overwriteProtocol" ] [ "services" "nextcloud" "extraOptions" "overwriteprotocol" ])
+      [ "services" "nextcloud" "config" "overwriteProtocol" ] [ "services" "nextcloud" "settings" "overwriteprotocol" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "skeletonDirectory" ] [ "services" "nextcloud" "extraOptions" "skeletondirectory" ])
+      [ "services" "nextcloud" "skeletonDirectory" ] [ "services" "nextcloud" "settings" "skeletondirectory" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "globalProfiles" ] [ "services" "nextcloud" "extraOptions" "profile.enabled" ])
+      [ "services" "nextcloud" "globalProfiles" ] [ "services" "nextcloud" "settings" "profile.enabled" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "config" "extraTrustedDomains" ] [ "services" "nextcloud" "extraOptions" "trusted_domains" ])
+      [ "services" "nextcloud" "config" "extraTrustedDomains" ] [ "services" "nextcloud" "settings" "trusted_domains" ])
     (mkRenamedOptionModule
-      [ "services" "nextcloud" "config" "trustedProxies" ] [ "services" "nextcloud" "extraOptions" "trusted_proxies" ])
+      [ "services" "nextcloud" "config" "trustedProxies" ] [ "services" "nextcloud" "settings" "trusted_proxies" ])
+    (mkRenamedOptionModule ["services" "nextcloud" "extraOptions" ] [ "services" "nextcloud" "settings" ])
   ];
 
   options.services.nextcloud = {
@@ -648,7 +648,7 @@ in {
       '';
     };
 
-    extraOptions = mkOption {
+    settings = mkOption {
       type = types.submodule {
         freeformType = jsonFormat.type;
         options = {
@@ -770,7 +770,7 @@ in {
       default = null;
       description = lib.mdDoc ''
         Secret options which will be appended to Nextcloud's config.php file (written as JSON, in the same
-        form as the [](#opt-services.nextcloud.extraOptions) option), for example
+        form as the [](#opt-services.nextcloud.settings) option), for example
         `{"redis":{"password":"secret"}}`.
       '';
     };
@@ -872,9 +872,11 @@ in {
     { systemd.timers.nextcloud-cron = {
         wantedBy = [ "timers.target" ];
         after = [ "nextcloud-setup.service" ];
-        timerConfig.OnBootSec = "5m";
-        timerConfig.OnUnitActiveSec = "5m";
-        timerConfig.Unit = "nextcloud-cron.service";
+        timerConfig = {
+          OnBootSec = "5m";
+          OnUnitActiveSec = "5m";
+          Unit = "nextcloud-cron.service";
+        };
       };
 
       systemd.tmpfiles.rules = map (dir: "d ${dir} 0750 nextcloud nextcloud - -") [
@@ -930,7 +932,7 @@ in {
             (i: v: ''
               ${occ}/bin/nextcloud-occ config:system:set trusted_domains \
                 ${toString i} --value="${toString v}"
-            '') ([ cfg.hostName ] ++ cfg.extraOptions.trusted_domains));
+            '') ([ cfg.hostName ] ++ cfg.settings.trusted_domains));
 
         in {
           wantedBy = [ "multi-user.target" ];
@@ -991,15 +993,21 @@ in {
         nextcloud-cron = {
           after = [ "nextcloud-setup.service" ];
           environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
-          serviceConfig.Type = "oneshot";
-          serviceConfig.User = "nextcloud";
-          serviceConfig.ExecStart = "${phpPackage}/bin/php -f ${webroot}/cron.php";
+          serviceConfig = {
+            Type = "oneshot";
+            User = "nextcloud";
+            ExecCondition = "${lib.getExe phpPackage} -f ${webroot}/occ status -e";
+            ExecStart = "${lib.getExe phpPackage} -f ${webroot}/cron.php";
+            KillMode = "process";
+          };
         };
         nextcloud-update-plugins = mkIf cfg.autoUpdateApps.enable {
           after = [ "nextcloud-setup.service" ];
-          serviceConfig.Type = "oneshot";
-          serviceConfig.ExecStart = "${occ}/bin/nextcloud-occ app:update --all";
-          serviceConfig.User = "nextcloud";
+          serviceConfig = {
+            Type = "oneshot";
+            ExecStart = "${occ}/bin/nextcloud-occ app:update --all";
+            User = "nextcloud";
+          };
           startAt = cfg.autoUpdateApps.startAt;
         };
       };
@@ -1056,7 +1064,7 @@ in {
 
       services.nextcloud = {
         caching.redis = lib.mkIf cfg.configureRedis true;
-        extraOptions = mkMerge [({
+        settings = mkMerge [({
           datadirectory = lib.mkDefault "${datadir}/data";
           trusted_domains = [ cfg.hostName ];
         }) (lib.mkIf cfg.configureRedis {
diff --git a/nixos/modules/services/web-apps/nifi.nix b/nixos/modules/services/web-apps/nifi.nix
index 5ce561077836..c0fc443f0df7 100644
--- a/nixos/modules/services/web-apps/nifi.nix
+++ b/nixos/modules/services/web-apps/nifi.nix
@@ -163,10 +163,15 @@ in {
       Please do not disable HTTPS mode in production. In this mode, access to the nifi is opened without authentication.
     '';
 
-    systemd.tmpfiles.rules = [
-      "d '/var/lib/nifi/conf' 0750 ${cfg.user} ${cfg.group}"
-      "L+ '/var/lib/nifi/lib' - - - - ${cfg.package}/lib"
-    ];
+    systemd.tmpfiles.settings."10-nifi" = {
+      "/var/lib/nifi/conf".d = {
+        inherit (cfg) user group;
+        mode = "0750";
+      };
+      "/var/lib/nifi/lib"."L+" = {
+        argument = "${cfg.package}/lib";
+      };
+    };
 
 
     systemd.services.nifi = {
diff --git a/nixos/modules/services/web-apps/photoprism.nix b/nixos/modules/services/web-apps/photoprism.nix
index e25b03484424..39eb7c65c635 100644
--- a/nixos/modules/services/web-apps/photoprism.nix
+++ b/nixos/modules/services/web-apps/photoprism.nix
@@ -12,14 +12,14 @@ let
     lib.mapAttrs (_: toString) cfg.settings
   );
 
-  manage =
-    let
-      setupEnv = lib.concatStringsSep "\n" (lib.mapAttrsToList (name: val: "export ${name}=${lib.escapeShellArg val}") env);
-    in
-    pkgs.writeShellScript "manage" ''
-      ${setupEnv}
-      exec ${cfg.package}/bin/photoprism "$@"
-    '';
+  manage = pkgs.writeShellScript "manage" ''
+    set -o allexport # Export the following env vars
+    ${lib.toShellVars env}
+    eval "$(${config.systemd.package}/bin/systemctl show -pUID,MainPID photoprism.service | ${pkgs.gnused}/bin/sed "s/UID/ServiceUID/")"
+    exec ${pkgs.util-linux}/bin/nsenter \
+      -t $MainPID -m -S $ServiceUID -G $ServiceUID --wdns=${cfg.storagePath} \
+      ${cfg.package}/bin/photoprism "$@"
+  '';
 in
 {
   meta.maintainers = with lib.maintainers; [ stunkymonkey ];
@@ -104,6 +104,7 @@ in
         StateDirectory = "photoprism";
         WorkingDirectory = "/var/lib/photoprism";
         RuntimeDirectory = "photoprism";
+        ReadWritePaths = [ cfg.originalsPath cfg.importPath cfg.storagePath ];
 
         LoadCredential = lib.optionalString (cfg.passwordFile != null)
           "PHOTOPRISM_ADMIN_PASSWORD:${cfg.passwordFile}";
diff --git a/nixos/modules/services/web-apps/pretalx.nix b/nixos/modules/services/web-apps/pretalx.nix
new file mode 100644
index 000000000000..ff6218112d2f
--- /dev/null
+++ b/nixos/modules/services/web-apps/pretalx.nix
@@ -0,0 +1,415 @@
+{ config
+, lib
+, pkgs
+, utils
+, ...
+}:
+
+let
+  cfg = config.services.pretalx;
+  format = pkgs.formats.ini { };
+
+  configFile = format.generate "pretalx.cfg" cfg.settings;
+
+  extras = cfg.package.optional-dependencies.redis
+    ++ lib.optionals (cfg.settings.database.backend == "mysql") cfg.package.optional-dependencies.mysql
+    ++ lib.optionals (cfg.settings.database.backend == "postgresql") cfg.package.optional-dependencies.postgres;
+
+  pythonEnv = cfg.package.python.buildEnv.override {
+    extraLibs = [ (cfg.package.python.pkgs.toPythonModule cfg.package) ]
+      ++ (with cfg.package.python.pkgs; [ gunicorn ]
+      ++ lib.optional cfg.celery.enable celery) ++ extras;
+  };
+in
+
+{
+  meta = with lib; {
+    maintainers = teams.c3d2.members;
+  };
+
+  options.services.pretalx = {
+    enable = lib.mkEnableOption (lib.mdDoc "pretalx");
+
+    package = lib.mkPackageOptionMD pkgs "pretalx" {};
+
+    group = lib.mkOption {
+      type = lib.types.str;
+      default = "pretalx";
+      description = "Group under which pretalx should run.";
+    };
+
+    user = lib.mkOption {
+      type = lib.types.str;
+      default = "pretalx";
+      description = "User under which pretalx should run.";
+    };
+
+    gunicorn.extraArgs = lib.mkOption {
+      type = with lib.types; listOf str;
+      default = [
+        "--name=pretalx"
+      ];
+      example = [
+        "--name=pretalx"
+        "--workers=4"
+        "--max-requests=1200"
+        "--max-requests-jitter=50"
+        "--log-level=info"
+      ];
+      description = lib.mdDoc ''
+        Extra arguments to pass to gunicorn.
+        See <https://docs.pretalx.org/administrator/installation.html#step-6-starting-pretalx-as-a-service> for details.
+      '';
+      apply = lib.escapeShellArgs;
+    };
+
+    celery = {
+      enable = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        example = false;
+        description = lib.mdDoc ''
+          Whether to set up celery as an asynchronous task runner.
+        '';
+      };
+
+      extraArgs = lib.mkOption {
+        type = with lib.types; listOf str;
+        default = [ ];
+        description = lib.mdDoc ''
+          Extra arguments to pass to celery.
+
+          See <https://docs.celeryq.dev/en/stable/reference/cli.html#celery-worker> for more info.
+        '';
+        apply = utils.escapeSystemdExecArgs;
+      };
+    };
+
+    nginx = {
+      enable = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        example = false;
+        description = lib.mdDoc ''
+          Whether to set up an nginx virtual host.
+        '';
+      };
+
+      domain = lib.mkOption {
+        type = lib.types.str;
+        example = "talks.example.com";
+        description = lib.mdDoc ''
+          The domain name under which to set up the virtual host.
+        '';
+      };
+    };
+
+    database.createLocally = lib.mkOption {
+      type = lib.types.bool;
+      default = true;
+      example = false;
+      description = lib.mdDoc ''
+        Whether to automatically set up the database on the local DBMS instance.
+
+        Currently only supported for PostgreSQL. Not required for sqlite.
+      '';
+    };
+
+    settings = lib.mkOption {
+      type = lib.types.submodule {
+        freeformType = format.type;
+        options = {
+          database = {
+            backend = lib.mkOption {
+              type = lib.types.enum [
+                "postgresql"
+              ];
+              default = "postgresql";
+              description = lib.mdDoc ''
+                Database backend to use.
+
+                Currently only PostgreSQL gets tested, and as such we don't support any other DBMS.
+              '';
+              readOnly = true; # only postgres supported right now
+            };
+
+            host = lib.mkOption {
+              type = with lib.types; nullOr types.path;
+              default = if cfg.settings.database.backend == "postgresql" then "/run/postgresql"
+                else if cfg.settings.database.backend == "mysql" then "/run/mysqld/mysqld.sock"
+                else null;
+              defaultText = lib.literalExpression ''
+                if config.services.pretalx.settings..database.backend == "postgresql" then "/run/postgresql"
+                else if config.services.pretalx.settings.database.backend == "mysql" then "/run/mysqld/mysqld.sock"
+                else null
+              '';
+              description = lib.mdDoc ''
+                Database host or socket path.
+              '';
+            };
+
+            name = lib.mkOption {
+              type = lib.types.str;
+              default = "pretalx";
+              description = lib.mdDoc ''
+                Database name.
+              '';
+            };
+
+            user = lib.mkOption {
+              type = lib.types.str;
+              default = "pretalx";
+              description = lib.mdDoc ''
+                Database username.
+              '';
+            };
+          };
+
+          filesystem = {
+            data = lib.mkOption {
+              type = lib.types.path;
+              default = "/var/lib/pretalx";
+              description = lib.mdDoc ''
+                Base path for all other storage paths.
+              '';
+            };
+            logs = lib.mkOption {
+              type = lib.types.path;
+              default = "/var/log/pretalx";
+              description = lib.mdDoc ''
+                Path to the log directory, that pretalx logs message to.
+              '';
+            };
+            static = lib.mkOption {
+              type = lib.types.path;
+              default = "${cfg.package.static}/";
+              defaultText = lib.literalExpression "\${config.services.pretalx.package}.static}/";
+              readOnly = true;
+              description = lib.mdDoc ''
+                Path to the directory that contains static files.
+              '';
+            };
+          };
+
+          celery = {
+            backend = lib.mkOption {
+              type = with lib.types; nullOr str;
+              default = lib.optionalString cfg.celery.enable "redis+socket://${config.services.redis.servers.pretalx.unixSocket}?virtual_host=1";
+              defaultText = lib.literalExpression ''
+                optionalString config.services.pretalx.celery.enable "redis+socket://''${config.services.redis.servers.pretalx.unixSocket}?virtual_host=1"
+              '';
+              description = lib.mdDoc ''
+                URI to the celery backend used for the asynchronous job queue.
+              '';
+            };
+
+            broker = lib.mkOption {
+              type = with lib.types; nullOr str;
+              default = lib.optionalString cfg.celery.enable "redis+socket://${config.services.redis.servers.pretalx.unixSocket}?virtual_host=2";
+              defaultText = lib.literalExpression ''
+                optionalString config.services.pretalx.celery.enable "redis+socket://''${config.services.redis.servers.pretalx.unixSocket}?virtual_host=2"
+              '';
+              description = lib.mdDoc ''
+                URI to the celery broker used for the asynchronous job queue.
+              '';
+            };
+          };
+
+          redis = {
+            location = lib.mkOption {
+              type = with lib.types; nullOr str;
+              default = "unix://${config.services.redis.servers.pretalx.unixSocket}?db=0";
+              defaultText = lib.literalExpression ''
+                "unix://''${config.services.redis.servers.pretalx.unixSocket}?db=0"
+              '';
+              description = lib.mdDoc ''
+                URI to the redis server, used to speed up locking, caching and session storage.
+              '';
+            };
+
+            session = lib.mkOption {
+              type = lib.types.bool;
+              default = true;
+              example = false;
+              description = lib.mdDoc ''
+                Whether to use redis as the session storage.
+              '';
+            };
+          };
+
+          site = {
+            url = lib.mkOption {
+              type = lib.types.str;
+              default = "https://${cfg.nginx.domain}";
+              defaultText = lib.literalExpression "https://\${config.services.pretalx.nginx.domain}";
+              example = "https://talks.example.com";
+              description = lib.mdDoc ''
+                The base URI below which your pretalx instance will be reachable.
+              '';
+            };
+          };
+        };
+      };
+      default = { };
+      description = lib.mdDoc ''
+        pretalx configuration as a Nix attribute set. All settings can also be passed
+        from the environment.
+
+        See <https://docs.pretalx.org/administrator/configure.html> for possible options.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    # https://docs.pretalx.org/administrator/installation.html
+
+    environment.systemPackages = [
+      (pkgs.writeScriptBin "pretalx-manage" ''
+        cd ${cfg.settings.filesystem.data}
+        sudo=exec
+        if [[ "$USER" != ${cfg.user} ]]; then
+          sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} --preserve-env=PRETALX_CONFIG_FILE'
+        fi
+        export PRETALX_CONFIG_FILE=${configFile}
+        $sudo ${lib.getExe' pythonEnv "pretalx-manage"} "$@"
+      '')
+    ];
+
+    services = {
+      nginx = lib.mkIf cfg.nginx.enable {
+        enable = true;
+        recommendedGzipSettings = lib.mkDefault true;
+        recommendedOptimisation = lib.mkDefault true;
+        recommendedProxySettings = lib.mkDefault true;
+        recommendedTlsSettings = lib.mkDefault true;
+        upstreams.pretalx.servers."unix:/run/pretalx/pretalx.sock" = { };
+        virtualHosts.${cfg.nginx.domain} = {
+          # https://docs.pretalx.org/administrator/installation.html#step-7-ssl
+          extraConfig = ''
+            more_set_headers Referrer-Policy same-origin;
+            more_set_headers X-Content-Type-Options nosniff;
+          '';
+          locations = {
+            "/".proxyPass = "http://pretalx";
+            "/media/" = {
+              alias = "${cfg.settings.filesystem.data}/data/media/";
+              extraConfig = ''
+                access_log off;
+                more_set_headers Content-Disposition 'attachment; filename="$1"';
+                expires 7d;
+              '';
+            };
+            "/static/" = {
+              alias = cfg.settings.filesystem.static;
+              extraConfig = ''
+                access_log off;
+                more_set_headers Cache-Control "public";
+                expires 365d;
+              '';
+            };
+          };
+        };
+      };
+
+      postgresql = lib.mkIf (cfg.database.createLocally && cfg.settings.database.backend == "postgresql") {
+        enable = true;
+        ensureUsers = [ {
+          name = cfg.settings.database.user;
+          ensureDBOwnership = true;
+        } ];
+        ensureDatabases = [ cfg.settings.database.name ];
+      };
+
+      redis.servers.pretalx.enable = true;
+    };
+
+    systemd.services = let
+      commonUnitConfig = {
+        environment.PRETALX_CONFIG_FILE = configFile;
+        serviceConfig = {
+          User = "pretalx";
+          Group = "pretalx";
+          StateDirectory = [ "pretalx" "pretalx/media" ];
+          LogsDirectory = "pretalx";
+          WorkingDirectory = cfg.settings.filesystem.data;
+          SupplementaryGroups = [ "redis-pretalx" ];
+        };
+      };
+    in {
+      pretalx-web = lib.recursiveUpdate commonUnitConfig {
+        description = "pretalx web service";
+        after = [
+          "network.target"
+          "redis-pretalx.service"
+        ] ++ lib.optionals (cfg.settings.database.backend == "postgresql") [
+          "postgresql.service"
+        ] ++ lib.optionals (cfg.settings.database.backend == "mysql") [
+          "mysql.service"
+        ];
+        wantedBy = [ "multi-user.target" ];
+        preStart = ''
+          versionFile="${cfg.settings.filesystem.data}/.version"
+          version=$(cat "$versionFile" 2>/dev/null || echo 0)
+
+          if [[ $version != ${cfg.package.version} ]]; then
+            ${lib.getExe' pythonEnv "pretalx-manage"} migrate
+
+            echo "${cfg.package.version}" > "$versionFile"
+          fi
+        '';
+        serviceConfig = {
+          ExecStart = "${lib.getExe' pythonEnv "gunicorn"} --bind unix:/run/pretalx/pretalx.sock ${cfg.gunicorn.extraArgs} pretalx.wsgi";
+          RuntimeDirectory = "pretalx";
+        };
+      };
+
+      pretalx-periodic = lib.recursiveUpdate commonUnitConfig {
+        description = "pretalx periodic task runner";
+        # every 15 minutes
+        startAt = [ "*:3,18,33,48" ];
+        serviceConfig = {
+          Type = "oneshot";
+          ExecStart = "${lib.getExe' pythonEnv "pretalx-manage"} runperiodic";
+        };
+      };
+
+      pretalx-clear-sessions = lib.recursiveUpdate commonUnitConfig {
+        description = "pretalx session pruning";
+        startAt = [ "monthly" ];
+        serviceConfig = {
+          Type = "oneshot";
+          ExecStart = "${lib.getExe' pythonEnv "pretalx-manage"} clearsessions";
+        };
+      };
+
+      pretalx-worker = lib.mkIf cfg.celery.enable (lib.recursiveUpdate commonUnitConfig {
+        description = "pretalx asynchronous job runner";
+        after = [
+          "network.target"
+          "redis-pretalx.service"
+        ] ++ lib.optionals (cfg.settings.database.backend == "postgresql") [
+          "postgresql.service"
+        ] ++ lib.optionals (cfg.settings.database.backend == "mysql") [
+          "mysql.service"
+        ];
+        wantedBy = [ "multi-user.target" ];
+        serviceConfig.ExecStart = "${lib.getExe' pythonEnv "celery"} -A pretalx.celery_app worker ${cfg.celery.extraArgs}";
+      });
+    };
+
+    systemd.sockets.pretalx-web.socketConfig = {
+      ListenStream = "/run/pretalx/pretalx.sock";
+      SocketUser = "nginx";
+    };
+
+    users = {
+      groups."${cfg.group}" = {};
+      users."${cfg.user}" = {
+        isSystemUser = true;
+        createHome = true;
+        home = cfg.settings.filesystem.data;
+        inherit (cfg) group;
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/pretix.nix b/nixos/modules/services/web-apps/pretix.nix
new file mode 100644
index 000000000000..500b2eb5416b
--- /dev/null
+++ b/nixos/modules/services/web-apps/pretix.nix
@@ -0,0 +1,580 @@
+{ config
+, lib
+, pkgs
+, utils
+, ...
+}:
+
+let
+  inherit (lib)
+    concatMapStringsSep
+    escapeShellArgs
+    filter
+    filterAttrs
+    getExe
+    getExe'
+    isAttrs
+    isList
+    literalExpression
+    mapAttrs
+    mkDefault
+    mkEnableOption
+    mkIf
+    mkOption
+    mkPackageOption
+    optionals
+    optionalString
+    recursiveUpdate
+    types
+  ;
+
+  filterRecursiveNull = o:
+    if isAttrs o then
+      mapAttrs (_: v: filterRecursiveNull v) (filterAttrs (_: v: v != null) o)
+    else if isList o then
+      map filterRecursiveNull (filter (v: v != null) o)
+    else
+      o;
+
+  cfg = config.services.pretix;
+  format = pkgs.formats.ini { };
+
+  configFile = format.generate "pretix.cfg" (filterRecursiveNull cfg.settings);
+
+  finalPackage = cfg.package.override {
+    inherit (cfg) plugins;
+  };
+
+  pythonEnv = cfg.package.python.buildEnv.override {
+    extraLibs = with cfg.package.python.pkgs; [
+      (toPythonModule finalPackage)
+      gunicorn
+    ]
+    ++ lib.optionals (cfg.settings.memcached.location != null)
+      cfg.package.optional-dependencies.memcached
+    ;
+  };
+
+  withRedis = cfg.settings.redis.location != null;
+in
+{
+  meta = with lib; {
+    maintainers = with maintainers; [ hexa ];
+  };
+
+  options.services.pretix = {
+    enable = mkEnableOption "pretix";
+
+    package = mkPackageOption pkgs "pretix" { };
+
+    group = mkOption {
+      type = types.str;
+      default = "pretix";
+      description = ''
+        Group under which pretix should run.
+      '';
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "pretix";
+      description = ''
+        User under which pretix should run.
+      '';
+    };
+
+    environmentFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/run/keys/pretix-secrets.env";
+      description = ''
+        Environment file to pass secret configuration values.
+
+        Each line must follow the `PRETIX_SECTION_KEY=value` pattern.
+      '';
+    };
+
+    plugins = mkOption {
+      type = types.listOf types.package;
+      default = [];
+      example = literalExpression ''
+        with config.services.pretix.package.plugins; [
+          passbook
+          pages
+        ];
+      '';
+      description = ''
+        Pretix plugins to install into the Python environment.
+      '';
+    };
+
+    gunicorn.extraArgs = mkOption {
+      type = with types; listOf str;
+      default = [
+        "--name=pretix"
+      ];
+      example = [
+        "--name=pretix"
+        "--workers=4"
+        "--max-requests=1200"
+        "--max-requests-jitter=50"
+        "--log-level=info"
+      ];
+      description = ''
+        Extra arguments to pass to gunicorn.
+        See <https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html#start-pretix-as-a-service> for details.
+      '';
+      apply = escapeShellArgs;
+    };
+
+    celery = {
+      extraArgs = mkOption {
+        type = with types; listOf str;
+        default = [ ];
+        description = ''
+          Extra arguments to pass to celery.
+
+          See <https://docs.celeryq.dev/en/stable/reference/cli.html#celery-worker> for more info.
+        '';
+        apply = utils.escapeSystemdExecArgs;
+      };
+    };
+
+    nginx = {
+      enable = mkOption {
+        type = types.bool;
+        default = true;
+        example = false;
+        description = ''
+          Whether to set up an nginx virtual host.
+        '';
+      };
+
+      domain = mkOption {
+        type = types.str;
+        example = "talks.example.com";
+        description = ''
+          The domain name under which to set up the virtual host.
+        '';
+      };
+    };
+
+    database.createLocally = mkOption {
+      type = types.bool;
+      default = true;
+      example = false;
+      description = ''
+        Whether to automatically set up the database on the local DBMS instance.
+
+        Only supported for PostgreSQL. Not required for sqlite.
+      '';
+    };
+
+    settings = mkOption {
+      type = types.submodule {
+        freeformType = format.type;
+        options = {
+          pretix = {
+            instance_name = mkOption {
+              type = types.str;
+              example = "tickets.example.com";
+              description = ''
+                The name of this installation.
+              '';
+            };
+
+            url = mkOption {
+              type = types.str;
+              example = "https://tickets.example.com";
+              description = ''
+                The installation’s full URL, without a trailing slash.
+              '';
+            };
+
+            cachedir = mkOption {
+              type = types.path;
+              default = "/var/cache/pretix";
+              description = ''
+                Directory for storing temporary files.
+              '';
+            };
+
+            datadir = mkOption {
+              type = types.path;
+              default = "/var/lib/pretix";
+              description = ''
+                Directory for storing user uploads and similar data.
+              '';
+            };
+
+            logdir = mkOption {
+              type = types.path;
+              default = "/var/log/pretix";
+              description = ''
+                Directory for storing log files.
+              '';
+            };
+
+            currency = mkOption {
+              type = types.str;
+              default = "EUR";
+              example = "USD";
+              description = ''
+                Default currency for events in its ISO 4217 three-letter code.
+              '';
+            };
+
+            registration = mkOption {
+              type = types.bool;
+              default = false;
+              example = true;
+              description = ''
+                Whether to allow registration of new admin users.
+              '';
+            };
+          };
+
+          database = {
+            backend = mkOption {
+              type = types.enum [
+                "sqlite3"
+                "postgresql"
+              ];
+              default = "postgresql";
+              description = ''
+                Database backend to use.
+
+                Only postgresql is recommended for production setups.
+              '';
+            };
+
+            host = mkOption {
+              type = with types; nullOr types.path;
+              default = if cfg.settings.database.backend == "postgresql" then "/run/postgresql" else null;
+              defaultText = literalExpression ''
+                if config.services.pretix.settings..database.backend == "postgresql" then "/run/postgresql"
+                else null
+              '';
+              description = ''
+                Database host or socket path.
+              '';
+            };
+
+            name = mkOption {
+              type = types.str;
+              default = "pretix";
+              description = ''
+                Database name.
+              '';
+            };
+
+            user = mkOption {
+              type = types.str;
+              default = "pretix";
+              description = ''
+                Database username.
+              '';
+            };
+          };
+
+          mail = {
+            from = mkOption {
+              type = types.str;
+              example = "tickets@example.com";
+              description = ''
+                E-Mail address used in the `FROM` header of outgoing mails.
+              '';
+            };
+
+            host = mkOption {
+              type = types.str;
+              default = "localhost";
+              example = "mail.example.com";
+              description = ''
+                Hostname of the SMTP server use for mail delivery.
+              '';
+            };
+
+            port = mkOption {
+              type = types.port;
+              default = 25;
+              example = 587;
+              description = ''
+                Port of the SMTP server to use for mail delivery.
+              '';
+            };
+          };
+
+          celery = {
+            backend = mkOption {
+              type = types.str;
+              default = "redis+socket://${config.services.redis.servers.pretix.unixSocket}?virtual_host=1";
+              defaultText = literalExpression ''
+                optionalString config.services.pretix.celery.enable "redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=1"
+              '';
+              description = ''
+                URI to the celery backend used for the asynchronous job queue.
+              '';
+            };
+
+            broker = mkOption {
+              type = types.str;
+              default = "redis+socket://${config.services.redis.servers.pretix.unixSocket}?virtual_host=2";
+              defaultText = literalExpression ''
+                optionalString config.services.pretix.celery.enable "redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=2"
+              '';
+              description = ''
+                URI to the celery broker used for the asynchronous job queue.
+              '';
+            };
+          };
+
+          redis = {
+            location = mkOption {
+              type = with types; nullOr str;
+              default = "unix://${config.services.redis.servers.pretix.unixSocket}?db=0";
+              defaultText = literalExpression ''
+                "unix://''${config.services.redis.servers.pretix.unixSocket}?db=0"
+              '';
+              description = ''
+                URI to the redis server, used to speed up locking, caching and session storage.
+              '';
+            };
+
+            sessions = mkOption {
+              type = types.bool;
+              default = true;
+              example = false;
+              description = ''
+                Whether to use redis as the session storage.
+              '';
+            };
+          };
+
+          memcached = {
+            location = mkOption {
+              type = with types; nullOr str;
+              default = null;
+              example = "127.0.0.1:11211";
+              description = ''
+                The `host:port` combination or the path to the UNIX socket of a memcached instance.
+
+                Can be used instead of Redis for caching.
+              '';
+            };
+          };
+
+          tools = {
+            pdftk = mkOption {
+              type = types.path;
+              default = getExe pkgs.pdftk;
+              defaultText = literalExpression ''
+                lib.getExe pkgs.pdftk
+              '';
+              description = ''
+                Path to the pdftk executable.
+              '';
+            };
+          };
+        };
+      };
+      default = { };
+      description = ''
+        pretix configuration as a Nix attribute set. All settings can also be passed
+        from the environment.
+
+        See <https://docs.pretix.eu/en/latest/admin/config.html> for possible options.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # https://docs.pretix.eu/en/latest/admin/installation/index.html
+
+    environment.systemPackages = [
+      (pkgs.writeScriptBin "pretix-manage" ''
+        cd ${cfg.settings.pretix.datadir}
+        sudo=exec
+        if [[ "$USER" != ${cfg.user} ]]; then
+          sudo='exec /run/wrappers/bin/sudo -u ${cfg.user} ${optionalString withRedis "-g redis-pretix"} --preserve-env=PRETIX_CONFIG_FILE'
+        fi
+        export PRETIX_CONFIG_FILE=${configFile}
+        $sudo ${getExe' pythonEnv "pretix-manage"} "$@"
+      '')
+    ];
+
+    services = {
+      nginx = mkIf cfg.nginx.enable {
+        enable = true;
+        recommendedGzipSettings = mkDefault true;
+        recommendedOptimisation = mkDefault true;
+        recommendedProxySettings = mkDefault true;
+        recommendedTlsSettings = mkDefault true;
+        upstreams.pretix.servers."unix:/run/pretix/pretix.sock" = { };
+        virtualHosts.${cfg.nginx.domain} = {
+          # https://docs.pretix.eu/en/latest/admin/installation/manual_smallscale.html#ssl
+          extraConfig = ''
+            more_set_headers Referrer-Policy same-origin;
+            more_set_headers X-Content-Type-Options nosniff;
+          '';
+          locations = {
+            "/".proxyPass = "http://pretix";
+            "/media/" = {
+              alias = "${cfg.settings.pretix.datadir}/media/";
+              extraConfig = ''
+                access_log off;
+                expires 7d;
+              '';
+            };
+            "^~ /media/(cachedfiles|invoices)" = {
+              extraConfig = ''
+                deny all;
+                return 404;
+              '';
+            };
+            "/static/" = {
+              alias = "${finalPackage}/${cfg.package.python.sitePackages}/pretix/static.dist/";
+              extraConfig = ''
+                access_log off;
+                more_set_headers Cache-Control "public";
+                expires 365d;
+              '';
+            };
+          };
+        };
+      };
+
+      postgresql = mkIf (cfg.database.createLocally && cfg.settings.database.backend == "postgresql") {
+        enable = true;
+        ensureUsers = [ {
+          name = cfg.settings.database.user;
+          ensureDBOwnership = true;
+        } ];
+        ensureDatabases = [ cfg.settings.database.name ];
+      };
+
+      redis.servers.pretix.enable = withRedis;
+    };
+
+    systemd.services = let
+      commonUnitConfig = {
+        environment.PRETIX_CONFIG_FILE = configFile;
+        serviceConfig = {
+          User = "pretix";
+          Group = "pretix";
+          EnvironmentFile = optionals (cfg.environmentFile != null) [
+            cfg.environmentFile
+          ];
+          StateDirectory = [
+            "pretix"
+          ];
+          StateDirectoryMode = "0755";
+          CacheDirectory = "pretix";
+          LogsDirectory = "pretix";
+          WorkingDirectory = cfg.settings.pretix.datadir;
+          SupplementaryGroups = optionals withRedis [
+            "redis-pretix"
+          ];
+          AmbientCapabilities = "";
+          CapabilityBoundingSet = [ "" ];
+          DevicePolicy = "closed";
+          LockPersonality = true;
+          MemoryDenyWriteExecute = true;
+          NoNewPrivileges = true;
+          PrivateDevices = true;
+          PrivateTmp = true;
+          ProcSubset = "pid";
+          ProtectControlGroups = true;
+          ProtectHome = true;
+          ProtectHostname = true;
+          ProtectKernelLogs = true;
+          ProtectKernelModules = true;
+          ProtectKernelTunables = true;
+          ProtectProc = "invisible";
+          ProtectSystem = "strict";
+          RemoveIPC = true;
+          RestrictAddressFamilies = [
+            "AF_INET"
+            "AF_INET6"
+            "AF_UNIX"
+          ];
+          RestrictNamespaces = true;
+          RestrictRealtime = true;
+          RestrictSUIDSGID = true;
+          SystemCallArchitectures = "native";
+          SystemCallFilter = [
+            "@system-service"
+            "~@privileged"
+            "@chown"
+          ];
+          UMask = "0022";
+        };
+      };
+    in {
+      pretix-web = recursiveUpdate commonUnitConfig {
+        description = "pretix web service";
+        after = [
+          "network.target"
+          "redis-pretix.service"
+          "postgresql.service"
+        ];
+        wantedBy = [ "multi-user.target" ];
+        preStart = ''
+          versionFile="${cfg.settings.pretix.datadir}/.version"
+          version=$(cat "$versionFile" 2>/dev/null || echo 0)
+
+          pluginsFile="${cfg.settings.pretix.datadir}/.plugins"
+          plugins=$(cat "$pluginsFile" 2>/dev/null || echo "")
+          configuredPlugins="${concatMapStringsSep "|" (package: package.name) cfg.plugins}"
+
+          if [[ $version != ${cfg.package.version} || $plugins != $configuredPlugins ]]; then
+            ${getExe' pythonEnv "pretix-manage"} migrate
+
+            echo "${cfg.package.version}" > "$versionFile"
+            echo "$configuredPlugins" > "$pluginsFile"
+          fi
+        '';
+        serviceConfig = {
+          ExecStart = "${getExe' pythonEnv "gunicorn"} --bind unix:/run/pretix/pretix.sock ${cfg.gunicorn.extraArgs} pretix.wsgi";
+          RuntimeDirectory = "pretix";
+        };
+      };
+
+      pretix-periodic = recursiveUpdate commonUnitConfig {
+        description = "pretix periodic task runner";
+        # every 15 minutes
+        startAt = [ "*:3,18,33,48" ];
+        serviceConfig = {
+          Type = "oneshot";
+          ExecStart = "${getExe' pythonEnv "pretix-manage"} runperiodic";
+        };
+      };
+
+      pretix-worker = recursiveUpdate commonUnitConfig {
+        description = "pretix asynchronous job runner";
+        after = [
+          "network.target"
+          "redis-pretix.service"
+          "postgresql.service"
+        ];
+        wantedBy = [ "multi-user.target" ];
+        serviceConfig.ExecStart = "${getExe' pythonEnv "celery"} -A pretix.celery_app worker ${cfg.celery.extraArgs}";
+      };
+    };
+
+    systemd.sockets.pretix-web.socketConfig = {
+      ListenStream = "/run/pretix/pretix.sock";
+      SocketUser = "nginx";
+    };
+
+    users = {
+      groups."${cfg.group}" = {};
+      users."${cfg.user}" = {
+        isSystemUser = true;
+        createHome = true;
+        home = cfg.settings.pretix.datadir;
+        inherit (cfg) group;
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/restya-board.nix b/nixos/modules/services/web-apps/restya-board.nix
deleted file mode 100644
index 959bcbc5c9f1..000000000000
--- a/nixos/modules/services/web-apps/restya-board.nix
+++ /dev/null
@@ -1,380 +0,0 @@
-{ 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 (lib.mdDoc "restya-board");
-
-      dataDir = mkOption {
-        type = types.path;
-        default = "/var/lib/restya-board";
-        description = lib.mdDoc ''
-          Data of the application.
-        '';
-      };
-
-      user = mkOption {
-        type = types.str;
-        default = "restya-board";
-        description = lib.mdDoc ''
-          User account under which the web-application runs.
-        '';
-      };
-
-      group = mkOption {
-        type = types.str;
-        default = "nginx";
-        description = lib.mdDoc ''
-          Group account under which the web-application runs.
-        '';
-      };
-
-      virtualHost = {
-        serverName = mkOption {
-          type = types.str;
-          default = "restya.board";
-          description = lib.mdDoc ''
-            Name of the nginx virtualhost to use.
-          '';
-        };
-
-        listenHost = mkOption {
-          type = types.str;
-          default = "localhost";
-          description = lib.mdDoc ''
-            Listen address for the virtualhost to use.
-          '';
-        };
-
-        listenPort = mkOption {
-          type = types.port;
-          default = 3000;
-          description = lib.mdDoc ''
-            Listen port for the virtualhost to use.
-          '';
-        };
-      };
-
-      database = {
-        host = mkOption {
-          type = types.nullOr types.str;
-          default = null;
-          description = lib.mdDoc ''
-            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 = lib.mdDoc ''
-            The database's port.
-          '';
-        };
-
-        name = mkOption {
-          type = types.str;
-          default = "restya_board";
-          description = lib.mdDoc ''
-            Name of the database. The database must exist.
-          '';
-        };
-
-        user = mkOption {
-          type = types.str;
-          default = "restya_board";
-          description = lib.mdDoc ''
-            The database user. The user must exist and have access to
-            the specified database.
-          '';
-        };
-
-        passwordFile = mkOption {
-          type = types.nullOr types.path;
-          default = null;
-          description = lib.mdDoc ''
-            The database user's password. 'null' if no password is set.
-          '';
-        };
-      };
-
-      email = {
-        server = mkOption {
-          type = types.nullOr types.str;
-          default = null;
-          example = "localhost";
-          description = lib.mdDoc ''
-            Hostname to send outgoing mail. Null to use the system MTA.
-          '';
-        };
-
-        port = mkOption {
-          type = types.port;
-          default = 25;
-          description = lib.mdDoc ''
-            Port used to connect to SMTP server.
-          '';
-        };
-
-        login = mkOption {
-          type = types.str;
-          default = "";
-          description = lib.mdDoc ''
-            SMTP authentication login used when sending outgoing mail.
-          '';
-        };
-
-        password = mkOption {
-          type = types.str;
-          default = "";
-          description = lib.mdDoc ''
-            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 = lib.mdDoc ''
-          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 = lib.optional (cfg.database.host != null) "postgresql.service";
-      after = [ "network.target" ] ++ (lib.optional (cfg.database.host != null) "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 "'$(cat ${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/suwayomi-server.nix b/nixos/modules/services/web-apps/suwayomi-server.nix
index c4c1540edbee..94dbe6f99356 100644
--- a/nixos/modules/services/web-apps/suwayomi-server.nix
+++ b/nixos/modules/services/web-apps/suwayomi-server.nix
@@ -3,6 +3,8 @@
 let
   cfg = config.services.suwayomi-server;
   inherit (lib) mkOption mdDoc mkEnableOption mkIf types;
+
+  format = pkgs.formats.hocon { };
 in
 {
   options = {
@@ -48,19 +50,7 @@ in
 
       settings = mkOption {
         type = types.submodule {
-          freeformType =
-            let
-              recursiveAttrsType = with types; attrsOf (nullOr (oneOf [
-                str
-                path
-                int
-                float
-                bool
-                (listOf str)
-                (recursiveAttrsType // { description = "instances of this type recursively"; })
-              ]));
-            in
-            recursiveAttrsType;
+          freeformType = format.type;
           options = {
             server = {
               ip = mkOption {
@@ -180,38 +170,7 @@ in
 
     systemd.services.suwayomi-server =
       let
-        flattenConfig = prefix: config:
-          lib.foldl'
-            lib.mergeAttrs
-            { }
-            (lib.attrValues
-              (lib.mapAttrs
-                (k: v:
-                  if !(lib.isAttrs v)
-                  then { "${prefix}${k}" = v; }
-                  else flattenConfig "${prefix}${k}." v
-                )
-                config
-              )
-            );
-
-        #  HOCON is a JSON superset that suwayomi-server use for configuration
-        toHOCON = attr:
-          let
-            attrType = builtins.typeOf attr;
-          in
-          if builtins.elem attrType [ "string" "path" "int" "float" ]
-          then ''"${toString attr}"''
-          else if attrType == "bool"
-          then lib.boolToString attr
-          else if attrType == "list"
-          then "[\n${lib.concatMapStringsSep ",\n" toHOCON attr}\n]"
-          else # attrs, lambda, null
-            throw ''
-              [suwayomi-server]: invalid config value type '${attrType}'.
-            '';
-
-        configFile = pkgs.writeText "server.conf" (lib.pipe cfg.settings [
+        configFile = format.generate "server.conf" (lib.pipe cfg.settings [
           (settings: lib.recursiveUpdate settings {
             server.basicAuthPasswordFile = null;
             server.basicAuthPassword =
@@ -219,12 +178,8 @@ in
               then "$TACHIDESK_SERVER_BASIC_AUTH_PASSWORD"
               else null;
           })
-          (flattenConfig "")
-          (lib.filterAttrs (_: x: x != null))
-          (lib.mapAttrsToList (name: value: ''${name} = ${toHOCON value}''))
-          lib.concatLines
+          (lib.filterAttrsRecursive (_: x: x != null))
         ]);
-
       in
       {
         description = "A free and open source manga reader server that runs extensions built for Tachiyomi.";
diff --git a/nixos/modules/services/web-apps/tt-rss.nix b/nixos/modules/services/web-apps/tt-rss.nix
index a8fb37d2c5ec..84342165c9c0 100644
--- a/nixos/modules/services/web-apps/tt-rss.nix
+++ b/nixos/modules/services/web-apps/tt-rss.nix
@@ -4,6 +4,8 @@ with lib;
 let
   cfg = config.services.tt-rss;
 
+  inherit (cfg) phpPackage;
+
   configVersion = 26;
 
   dbPort = if cfg.database.port == null
@@ -26,7 +28,7 @@ let
       ;
   in pkgs.writeText "config.php" ''
     <?php
-      putenv('TTRSS_PHP_EXECUTABLE=${pkgs.php}/bin/php');
+      putenv('TTRSS_PHP_EXECUTABLE=${phpPackage}/bin/php');
 
       putenv('TTRSS_LOCK_DIRECTORY=${cfg.root}/lock');
       putenv('TTRSS_CACHE_DIR=${cfg.root}/cache');
@@ -456,6 +458,15 @@ let
         '';
       };
 
+      phpPackage = lib.mkOption {
+        type = lib.types.package;
+        default = pkgs.php;
+        defaultText = "pkgs.php";
+        description = lib.mdDoc ''
+          php package to use for php fpm and update daemon.
+        '';
+      };
+
       plugins = mkOption {
         type = types.listOf types.str;
         default = ["auth_internal" "note"];
@@ -543,7 +554,7 @@ let
     services.phpfpm.pools = mkIf (cfg.pool == "${poolName}") {
       ${poolName} = {
         inherit (cfg) user;
-        phpPackage = pkgs.php81;
+        inherit phpPackage;
         settings = mapAttrs (name: mkDefault) {
           "listen.owner" = "nginx";
           "listen.group" = "nginx";
@@ -605,13 +616,13 @@ let
         description = "Tiny Tiny RSS feeds update daemon";
 
         preStart = ''
-          ${pkgs.php81}/bin/php ${cfg.root}/www/update.php --update-schema
+          ${phpPackage}/bin/php ${cfg.root}/www/update.php --update-schema --force-yes
         '';
 
         serviceConfig = {
           User = "${cfg.user}";
           Group = "tt_rss";
-          ExecStart = "${pkgs.php}/bin/php ${cfg.root}/www/update.php --daemon --quiet";
+          ExecStart = "${phpPackage}/bin/php ${cfg.root}/www/update.php --daemon --quiet";
           Restart = "on-failure";
           RestartSec = "60";
           SyslogIdentifier = "tt-rss";
diff --git a/nixos/modules/services/web-apps/vikunja.nix b/nixos/modules/services/web-apps/vikunja.nix
index b893f2c1f33c..efa9c676d9a5 100644
--- a/nixos/modules/services/web-apps/vikunja.nix
+++ b/nixos/modules/services/web-apps/vikunja.nix
@@ -9,10 +9,13 @@ let
   useMysql = cfg.database.type == "mysql";
   usePostgresql = cfg.database.type == "postgres";
 in {
+  imports = [
+    (mkRemovedOptionModule [ "services" "vikunja" "setupNginx" ] "services.vikunja no longer supports the automatic set up of a nginx virtual host. Set up your own webserver config with a proxy pass to the vikunja service.")
+  ];
+
   options.services.vikunja = with lib; {
     enable = mkEnableOption (lib.mdDoc "vikunja service");
-    package-api = mkPackageOption pkgs "vikunja-api" { };
-    package-frontend = mkPackageOption pkgs "vikunja-frontend" { };
+    package = mkPackageOption pkgs "vikunja" { };
     environmentFiles = mkOption {
       type = types.listOf types.path;
       default = [ ];
@@ -21,25 +24,10 @@ in {
         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 = lib.mdDoc ''
-        Whether to setup NGINX.
-        Further nginx configuration can be done by changing
-        {option}`services.nginx.virtualHosts.<frontendHostname>`.
-        This does not enable TLS or ACME by default. To enable this, set the
-        {option}`services.nginx.virtualHosts.<frontendHostname>.enableACME` to
-        `true` and if appropriate do the same for
-        {option}`services.nginx.virtualHosts.<frontendHostname>.forceSSL`.
-      '';
-    };
     frontendScheme = mkOption {
       type = types.enum [ "http" "https" ];
       description = lib.mdDoc ''
         Whether the site is available via http or https.
-        This does not configure https or ACME in nginx!
       '';
     };
     frontendHostname = mkOption {
@@ -104,42 +92,27 @@ in {
       };
     };
 
-    systemd.services.vikunja-api = {
-      description = "vikunja-api";
+    systemd.services.vikunja = {
+      description = "vikunja";
       after = [ "network.target" ] ++ lib.optional usePostgresql "postgresql.service" ++ lib.optional useMysql "mysql.service";
       wantedBy = [ "multi-user.target" ];
-      path = [ cfg.package-api ];
+      path = [ cfg.package ];
       restartTriggers = [ configFile ];
 
       serviceConfig = {
         Type = "simple";
         DynamicUser = true;
         StateDirectory = "vikunja";
-        ExecStart = "${cfg.package-api}/bin/vikunja";
+        ExecStart = "${cfg.package}/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:${toString cfg.port}";
-          extraConfig = ''
-            client_max_body_size 20M;
-          '';
-        };
-      };
-    };
-
     environment.etc."vikunja/config.yaml".source = configFile;
 
     environment.systemPackages = [
-      cfg.package-api # for admin `vikunja` CLI
+      cfg.package # for admin `vikunja` CLI
     ];
   };
 }
diff --git a/nixos/modules/services/web-apps/writefreely.nix b/nixos/modules/services/web-apps/writefreely.nix
index f92afa9276e3..2e9a34897909 100644
--- a/nixos/modules/services/web-apps/writefreely.nix
+++ b/nixos/modules/services/web-apps/writefreely.nix
@@ -334,8 +334,10 @@ in {
         optionalAttrs (cfg.group == "writefreely") { writefreely = { }; };
     };
 
-    systemd.tmpfiles.rules =
-      [ "d '${cfg.stateDir}' 0750 ${cfg.user} ${cfg.group} - -" ];
+    systemd.tmpfiles.settings."10-writefreely".${cfg.stateDir}.d = {
+      inherit (cfg) user group;
+      mode = "0750";
+    };
 
     systemd.services.writefreely = {
       after = [ "network.target" ]
diff --git a/nixos/modules/services/web-apps/youtrack.md b/nixos/modules/services/web-apps/youtrack.md
new file mode 100644
index 000000000000..f33f482ff970
--- /dev/null
+++ b/nixos/modules/services/web-apps/youtrack.md
@@ -0,0 +1,30 @@
+# YouTrack {#module-services-youtrack}
+
+YouTrack is a browser-based bug tracker, issue tracking system and project management software.
+
+## Installation {#module-services-youtrack-installation}
+
+YouTrack exposes a web GUI installer on first login.
+You need a token to access it.
+You can find this token in the log of the `youtrack` service. The log line looks like
+```
+* JetBrains YouTrack 2023.3 Configuration Wizard will be available on [http://127.0.0.1:8090/?wizard_token=somelongtoken] after start
+```
+
+## Upgrade from 2022.3 to 2023.x {#module-services-youtrack-upgrade-2022_3-2023_1}
+
+Starting with YouTrack 2023.1, JetBrains no longer distributes it as as JAR.
+The new distribution with the JetBrains Launcher as a ZIP changed the basic data structure and also some configuration parameters.
+Check out https://www.jetbrains.com/help/youtrack/server/YouTrack-Java-Start-Parameters.html for more information on the new configuration options.
+When upgrading to YouTrack 2023.1 or higher, a migration script will move the old state directory to `/var/lib/youtrack/2022_3` as a backup.
+A one-time manual update is required:
+
+1. Before you update take a backup of your YouTrack instance!
+2. Migrate the options you set in `services.youtrack.extraParams` and `services.youtrack.jvmOpts` to `services.youtrack.generalParameters` and `services.youtrack.environmentalParameters` (see the examples and [the YouTrack docs](https://www.jetbrains.com/help/youtrack/server/2023.3/YouTrack-Java-Start-Parameters.html))
+2. To start the upgrade set `services.youtrack.package = pkgs.youtrack`
+3. YouTrack then starts in upgrade mode, meaning you need to obtain the wizard token as above
+4. Select you want to **Upgrade** YouTrack
+5. As source you select `/var/lib/youtrack/2022_3/teamsysdata/` (adopt if you have a different state path)
+6. Change the data directory location to `/var/lib/youtrack/data/`. The other paths should already be right.
+
+If you migrate a larger YouTrack instance, it might be useful to set `-Dexodus.entityStore.refactoring.forceAll=true` in `services.youtrack.generalParameters` for the first startup of YouTrack 2023.x.
diff --git a/nixos/modules/services/web-apps/youtrack.nix b/nixos/modules/services/web-apps/youtrack.nix
index 79e1d12e0abb..08e180b520f0 100644
--- a/nixos/modules/services/web-apps/youtrack.nix
+++ b/nixos/modules/services/web-apps/youtrack.nix
@@ -1,130 +1,224 @@
 { 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 = {
+  imports = [
+    (lib.mkRenamedOptionModule [ "services" "youtrack" "baseUrl" ] [ "services" "youtrack" "environmentalParameters" "base-url" ])
+    (lib.mkRenamedOptionModule [ "services" "youtrack" "port" ] [ "services" "youtrack" "environmentalParameters" "listen-port" ])
+    (lib.mkRemovedOptionModule [ "services" "youtrack" "maxMemory" ] "Please instead use `services.youtrack.generalParameters`.")
+    (lib.mkRemovedOptionModule [ "services" "youtrack" "maxMetaspaceSize" ] "Please instead use `services.youtrack.generalParameters`.")
+  ];
 
-    enable = mkEnableOption (lib.mdDoc "YouTrack service");
+  options.services.youtrack = {
+    enable = lib.mkEnableOption (lib.mdDoc "YouTrack service");
 
-    address = mkOption {
+    address = lib.mkOption {
       description = lib.mdDoc ''
         The interface youtrack will listen on.
       '';
       default = "127.0.0.1";
-      type = types.str;
+      type = lib.types.str;
     };
 
-    baseUrl = mkOption {
-      description = lib.mdDoc ''
-        Base URL for youtrack. Will be auto-detected and stored in database.
-      '';
-      type = types.nullOr types.str;
-      default = null;
-    };
-
-    extraParams = mkOption {
+    extraParams = lib.mkOption {
       default = {};
       description = lib.mdDoc ''
-        Extra parameters to pass to youtrack. See
+        Extra parameters to pass to youtrack.
+        Use to configure YouTrack 2022.x, deprecated with YouTrack 2023.x. Use `services.youtrack.generalParameters`.
         https://www.jetbrains.com/help/youtrack/standalone/YouTrack-Java-Start-Parameters.html
         for more information.
       '';
-      example = literalExpression ''
+      example = lib.literalExpression ''
         {
           "jetbrains.youtrack.overrideRootPassword" = "tortuga";
         }
       '';
-      type = types.attrsOf types.str;
+      type = lib.types.attrsOf lib.types.str;
+      visible = false;
     };
 
-    package = mkPackageOption pkgs "youtrack" { };
-
-    port = mkOption {
+    package = lib.mkOption {
       description = lib.mdDoc ''
-        The port youtrack will listen on.
+        Package to use.
       '';
-      default = 8080;
-      type = types.port;
+      type = lib.types.package;
+      default = null;
+      relatedPackages = [ "youtrack_2022_3" "youtrack" ];
     };
 
-    statePath = mkOption {
+
+    statePath = lib.mkOption {
       description = lib.mdDoc ''
-        Where to keep the youtrack database.
+        Path were the YouTrack state is stored.
+        To this path the base version (e.g. 2023_1) of the used package will be appended.
       '';
-      type = types.path;
+      type = lib.types.path;
       default = "/var/lib/youtrack";
     };
 
-    virtualHost = mkOption {
+    virtualHost = lib.mkOption {
       description = lib.mdDoc ''
         Name of the nginx virtual host to use and setup.
         If null, do not setup anything.
       '';
       default = null;
-      type = types.nullOr types.str;
+      type = lib.types.nullOr lib.types.str;
     };
 
-    jvmOpts = mkOption {
+    jvmOpts = lib.mkOption {
       description = lib.mdDoc ''
         Extra options to pass to the JVM.
+        Only has a use with YouTrack 2022.x, deprecated with YouTrack 2023.x. Use `serivces.youtrack.generalParameters`.
         See https://www.jetbrains.com/help/youtrack/standalone/Configure-JVM-Options.html
         for more information.
       '';
-      type = types.separatedString " ";
-      example = "-XX:MetaspaceSize=250m";
+      type = lib.types.separatedString " ";
+      example = "--J-XX:MetaspaceSize=250m";
       default = "";
+      visible = false;
     };
 
-    maxMemory = mkOption {
+    autoUpgrade = lib.mkOption {
+      type = lib.types.bool;
+      default = true;
+      description = lib.mdDoc "Whether YouTrack should auto upgrade it without showing the upgrade dialog.";
+    };
+
+    generalParameters = lib.mkOption {
+      type = with lib.types; listOf str;
       description = lib.mdDoc ''
-        Maximum Java heap size
+        General configuration parameters and other JVM options.
+        Only has an effect for YouTrack 2023.x.
+        See https://www.jetbrains.com/help/youtrack/server/2023.3/youtrack-java-start-parameters.html#general-parameters
+        for more information.
       '';
-      type = types.str;
-      default = "1g";
+      example = lib.literalExpression ''
+        [
+          "-Djetbrains.youtrack.admin.restore=true"
+          "-Xmx1024m"
+        ];
+      '';
+      default = [];
     };
 
-    maxMetaspaceSize = mkOption {
+    environmentalParameters = lib.mkOption {
+      type = lib.types.submodule {
+        freeformType = with lib.types; attrsOf (oneOf [ int str port ]);
+        options = {
+          listen-address = lib.mkOption {
+            type = lib.types.str;
+            default = "0.0.0.0";
+            description = lib.mdDoc "The interface YouTrack will listen on.";
+          };
+          listen-port = lib.mkOption {
+            type = lib.types.port;
+            default = 8080;
+            description = lib.mdDoc "The port YouTrack will listen on.";
+          };
+        };
+      };
       description = lib.mdDoc ''
-        Maximum java Metaspace memory.
+        Environmental configuration parameters, set imperatively. The values doesn't get removed, when removed in Nix.
+        Only has an effect for YouTrack 2023.x.
+        See https://www.jetbrains.com/help/youtrack/server/2023.3/youtrack-java-start-parameters.html#environmental-parameters
+        for more information.
+      '';
+      example = lib.literalExpression ''
+        {
+          secure-mode = "tls";
+        }
       '';
-      type = types.str;
-      default = "350m";
+      default = {};
     };
   };
 
-  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}'';
+  config = lib.mkIf cfg.enable {
+    warnings = lib.optional (lib.versions.major cfg.package.version <= "2022")
+      "YouTrack 2022.x is deprecated. See https://nixos.org/manual/nixos/unstable/index.html#module-services-youtrack for details on how to upgrade."
+    ++ lib.optional (cfg.extraParams != {} && (lib.versions.major cfg.package.version >= "2023"))
+      "'services.youtrack.extraParams' is deprecated and has no effect on YouTrack 2023.x and newer. Please migrate to 'services.youtrack.generalParameters'"
+    ++ lib.optional (cfg.jvmOpts != "" && (lib.versions.major cfg.package.version >= "2023"))
+      "'services.youtrack.jvmOpts' is deprecated and has no effect on YouTrack 2023.x and newer. Please migrate to 'services.youtrack.generalParameters'";
+
+    # XXX: Drop all version feature switches at the point when we consider YT 2022.3 as outdated.
+    services.youtrack.package = lib.mkDefault (
+      if lib.versionAtLeast config.system.stateVersion "24.11" then pkgs.youtrack
+      else pkgs.youtrack_2022_3
+    );
+
+    services.youtrack.generalParameters = lib.optional (lib.versions.major cfg.package.version >= "2023")
+      "-Ddisable.configuration.wizard.on.upgrade=${lib.boolToString cfg.autoUpgrade}"
+      ++ (lib.mapAttrsToList (k: v: "-D${k}=${v}") cfg.extraParams);
+
+    systemd.services.youtrack = let
+      service_jar = let
+        mergeAttrList = lib.foldl' lib.mergeAttrs {};
+        stdParams = mergeAttrList [
+          (lib.optionalAttrs (cfg.environmentalParameters ? base-url && cfg.environmentalParameters.base-url != null) {
+            "jetbrains.youtrack.baseUrl" = cfg.environmentalParameters.base-url;
+          })
+          {
+          "java.aws.headless" = "true";
+          "jetbrains.youtrack.disableBrowser" = "true";
+          }
+        ];
+        extraAttr = lib.concatStringsSep " " (lib.mapAttrsToList (k: v: "-D${k}=${v}") (stdParams // cfg.extraParams));
+      in {
+        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 ${cfg.jvmOpts} ${cfg.environmentalParameters.listen-address}:${toString cfg.environmentalParameters.listen-port}'';
+        };
       };
-    };
+      service_zip = let
+        jvmoptions = pkgs.writeTextFile {
+          name = "youtrack.jvmoptions";
+          text = (lib.concatStringsSep "\n" cfg.generalParameters);
+        };
+
+        package = cfg.package.override {
+          statePath = cfg.statePath;
+        };
+      in {
+        after = [ "network.target" ];
+        wantedBy = [ "multi-user.target" ];
+        path = with pkgs; [ unixtools.hostname ];
+        preStart = ''
+          # This detects old (i.e. <= 2022.3) installations that were not migrated yet
+          # and migrates them to the new state directory style
+          if [[ -d ${cfg.statePath}/teamsysdata ]] && [[ ! -d ${cfg.statePath}/2022_3 ]]
+          then
+            mkdir -p ${cfg.statePath}/2022_3
+            mv ${cfg.statePath}/teamsysdata ${cfg.statePath}/2022_3
+            mv ${cfg.statePath}/.youtrack ${cfg.statePath}/2022_3
+          fi
+          mkdir -p ${cfg.statePath}/{backups,conf,data,logs,temp}
+          ${pkgs.coreutils}/bin/ln -fs ${jvmoptions} ${cfg.statePath}/conf/youtrack.jvmoptions
+          ${package}/bin/youtrack configure ${lib.concatStringsSep " " (lib.mapAttrsToList (name: value: "--${name}=${toString value}") cfg.environmentalParameters )}
+        '';
+        serviceConfig = lib.mkMerge [
+          {
+            Type = "simple";
+            User = "youtrack";
+            Group = "youtrack";
+            Restart = "on-failure";
+            ExecStart = "${package}/bin/youtrack run";
+          }
+          (lib.mkIf (cfg.statePath == "/var/lib/youtrack") {
+            StateDirectory = "youtrack";
+          })
+        ];
+      };
+    in if (lib.versions.major cfg.package.version >= "2023") then service_zip else service_jar;
 
     users.users.youtrack = {
       description = "Youtrack service user";
@@ -136,8 +230,8 @@ in
 
     users.groups.youtrack = {};
 
-    services.nginx = mkIf (cfg.virtualHost != null) {
-      upstreams.youtrack.servers."${cfg.address}:${toString cfg.port}" = {};
+    services.nginx = lib.mkIf (cfg.virtualHost != null) {
+      upstreams.youtrack.servers."${cfg.address}:${toString cfg.environmentalParameters.listen-port}" = {};
       virtualHosts.${cfg.virtualHost}.locations = {
         "/" = {
           proxyPass = "http://youtrack";
@@ -166,9 +260,10 @@ in
             proxy_set_header X-Forwarded-Proto $scheme;
           '';
         };
-
       };
     };
-
   };
+
+  meta.doc = ./youtrack.md;
+  meta.maintainers = [ lib.maintainers.leona ];
 }