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/akkoma.nix16
-rw-r--r--nixos/modules/services/web-apps/artalk.nix131
-rw-r--r--nixos/modules/services/web-apps/audiobookshelf.nix2
-rw-r--r--nixos/modules/services/web-apps/commafeed.nix114
-rw-r--r--nixos/modules/services/web-apps/filesender.md49
-rw-r--r--nixos/modules/services/web-apps/filesender.nix253
-rw-r--r--nixos/modules/services/web-apps/firefly-iii.nix98
-rw-r--r--nixos/modules/services/web-apps/flarum.nix210
-rw-r--r--nixos/modules/services/web-apps/freshrss.nix15
-rw-r--r--nixos/modules/services/web-apps/invoiceplane.nix34
-rw-r--r--nixos/modules/services/web-apps/jitsi-meet.nix2
-rw-r--r--nixos/modules/services/web-apps/keycloak.md12
-rw-r--r--nixos/modules/services/web-apps/keycloak.nix35
-rw-r--r--nixos/modules/services/web-apps/mastodon.nix67
-rw-r--r--nixos/modules/services/web-apps/mealie.nix3
-rw-r--r--nixos/modules/services/web-apps/miniflux.nix15
-rw-r--r--nixos/modules/services/web-apps/nextcloud-notify_push.nix3
-rw-r--r--nixos/modules/services/web-apps/nextcloud.md5
-rw-r--r--nixos/modules/services/web-apps/nextcloud.nix68
-rw-r--r--nixos/modules/services/web-apps/nextjs-ollama-llm-ui.nix87
-rw-r--r--nixos/modules/services/web-apps/node-red.nix12
-rw-r--r--nixos/modules/services/web-apps/plausible.nix9
-rw-r--r--nixos/modules/services/web-apps/pretalx.nix84
-rw-r--r--nixos/modules/services/web-apps/pretix.nix18
-rw-r--r--nixos/modules/services/web-apps/silverbullet.nix2
-rw-r--r--nixos/modules/services/web-apps/simplesamlphp.nix128
-rw-r--r--nixos/modules/services/web-apps/slskd.nix2
-rw-r--r--nixos/modules/services/web-apps/suwayomi-server.nix4
-rw-r--r--nixos/modules/services/web-apps/your_spotify.nix191
-rw-r--r--nixos/modules/services/web-apps/zitadel.nix2
30 files changed, 1463 insertions, 208 deletions
diff --git a/nixos/modules/services/web-apps/akkoma.nix b/nixos/modules/services/web-apps/akkoma.nix
index 7c9bf6c465164..8ba3c7eaa1e6a 100644
--- a/nixos/modules/services/web-apps/akkoma.nix
+++ b/nixos/modules/services/web-apps/akkoma.nix
@@ -119,7 +119,7 @@ let
         -o ${escapeShellArg cfg.user } \
         -g ${escapeShellArg cfg.group} \
         <(hexdump -n 16 -e '"%02x"' /dev/urandom) \
-        "$RUNTIME_DIRECTORY/cookie"
+        "''${RUNTIME_DIRECTORY%%:*}/cookie"
     '';
   };
 
@@ -131,7 +131,7 @@ let
         -o ${escapeShellArg cfg.user} \
         -g ${escapeShellArg cfg.group} \
         ${escapeShellArg cfg.dist.cookie._secret} \
-        "$RUNTIME_DIRECTORY/cookie"
+        "''${RUNTIME_DIRECTORY%%:*}/cookie"
     '';
   };
 
@@ -181,7 +181,7 @@ let
     name = "akkoma-config";
     runtimeInputs = with pkgs; [ coreutils replace-secret ];
     text = ''
-      cd "$RUNTIME_DIRECTORY"
+      cd "''${RUNTIME_DIRECTORY%%:*}"
       tmp="$(mktemp config.exs.XXXXXXXXXX)"
       trap 'rm -f "$tmp"' EXIT TERM
 
@@ -279,7 +279,7 @@ let
         cd "${cfg.package}"
 
         RUNTIME_DIRECTORY="''${RUNTIME_DIRECTORY:-/run/akkoma}"
-        AKKOMA_CONFIG_PATH="$RUNTIME_DIRECTORY/config.exs" \
+        AKKOMA_CONFIG_PATH="''${RUNTIME_DIRECTORY%%:*}/config.exs" \
         ERL_EPMD_ADDRESS="${cfg.dist.address}" \
         ERL_EPMD_PORT="${toString cfg.dist.epmdPort}" \
         ERL_FLAGS=${lib.escapeShellArg (lib.escapeShellArgs ([
@@ -287,7 +287,7 @@ let
           "-kernel" "inet_dist_listen_min" (toString cfg.dist.portMin)
           "-kernel" "inet_dist_listen_max" (toString cfg.dist.portMax)
         ] ++ cfg.dist.extraFlags))} \
-        RELEASE_COOKIE="$(<"$RUNTIME_DIRECTORY/cookie")" \
+        RELEASE_COOKIE="$(<"''${RUNTIME_DIRECTORY%%:*}/cookie")" \
         RELEASE_NAME="akkoma" \
           exec "${cfg.package}/bin/$(basename "$0")" "$@"
       '';
@@ -984,7 +984,7 @@ in {
         RemainAfterExit = true;
         UMask = "0077";
 
-        RuntimeDirectory = "akkoma";
+        RuntimeDirectory = mkBefore "akkoma";
 
         ExecStart = mkMerge [
           (mkIf (cfg.dist.cookie == null) [ genScript ])
@@ -1072,7 +1072,7 @@ in {
 
         ProtectProc = "noaccess";
         ProcSubset = "pid";
-        ProtectSystem = mkIf (!isConfined) "strict";
+        ProtectSystem = "strict";
         ProtectHome = true;
         PrivateTmp = true;
         PrivateDevices = true;
@@ -1136,6 +1136,6 @@ in {
     };
   };
 
-  meta.maintainers = with maintainers; [ mvs tcmal ];
+  meta.maintainers = with maintainers; [ mvs ];
   meta.doc = ./akkoma.md;
 }
diff --git a/nixos/modules/services/web-apps/artalk.nix b/nixos/modules/services/web-apps/artalk.nix
new file mode 100644
index 0000000000000..d3d06f1521b6a
--- /dev/null
+++ b/nixos/modules/services/web-apps/artalk.nix
@@ -0,0 +1,131 @@
+{
+  config,
+  lib,
+  pkgs,
+  utils,
+  ...
+}:
+let
+  cfg = config.services.artalk;
+  settingsFormat = pkgs.formats.json { };
+in
+{
+
+  meta = {
+    maintainers = with lib.maintainers; [ moraxyc ];
+  };
+
+  options = {
+    services.artalk = {
+      enable = lib.mkEnableOption "artalk, a comment system";
+      configFile = lib.mkOption {
+        type = lib.types.str;
+        default = "/etc/artalk/config.yml";
+        description = "Artalk config file path. If it is not exist, Artalk will generate one.";
+      };
+      allowModify = lib.mkOption {
+        type = lib.types.bool;
+        default = true;
+        description = "allow Artalk store the settings to config file persistently";
+      };
+      workdir = lib.mkOption {
+        type = lib.types.str;
+        default = "/var/lib/artalk";
+        description = "Artalk working directory";
+      };
+      user = lib.mkOption {
+        type = lib.types.str;
+        default = "artalk";
+        description = "Artalk user name.";
+      };
+
+      group = lib.mkOption {
+        type = lib.types.str;
+        default = "artalk";
+        description = "Artalk group name.";
+      };
+
+      package = lib.mkPackageOption pkgs "artalk" { };
+      settings = lib.mkOption {
+        type = lib.types.submodule {
+          freeformType = settingsFormat.type;
+          options = {
+            host = lib.mkOption {
+              type = lib.types.str;
+              default = "0.0.0.0";
+              description = ''
+                Artalk server listen host
+              '';
+            };
+            port = lib.mkOption {
+              type = lib.types.port;
+              default = 23366;
+              description = ''
+                Artalk server listen port
+              '';
+            };
+          };
+        };
+        default = { };
+        description = ''
+          The artalk configuration.
+
+          If you set allowModify to true, Artalk will be able to store the settings in the config file persistently. This section's content will update in the config file after the service restarts.
+
+          Options containing secret data should be set to an attribute set
+          containing the attribute `_secret` - a string pointing to a file
+          containing the value the option should be set to.
+        '';
+      };
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    users.users.artalk = lib.optionalAttrs (cfg.user == "artalk") {
+      description = "artalk user";
+      isSystemUser = true;
+      group = cfg.group;
+    };
+    users.groups.artalk = lib.optionalAttrs (cfg.group == "artalk") { };
+
+    environment.systemPackages = [ cfg.package ];
+
+    systemd.services.artalk = {
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+      preStart =
+        ''
+          umask 0077
+          ${utils.genJqSecretsReplacementSnippet cfg.settings "/run/artalk/new"}
+        ''
+        + (
+          if cfg.allowModify then
+            ''
+              [ -e "${cfg.configFile}" ] || ${lib.getExe cfg.package} gen config "${cfg.configFile}"
+              cat "${cfg.configFile}" | ${lib.getExe pkgs.yj} > "/run/artalk/old"
+              ${lib.getExe pkgs.jq} -s '.[0] * .[1]' "/run/artalk/old" "/run/artalk/new" > "/run/artalk/result"
+              cat "/run/artalk/result" | ${lib.getExe pkgs.yj} -r > "${cfg.configFile}"
+              rm /run/artalk/{old,new,result}
+            ''
+          else
+            ''
+              cat /run/artalk/new | ${lib.getExe pkgs.yj} -r > "${cfg.configFile}"
+              rm /run/artalk/new
+            ''
+        );
+      serviceConfig = {
+        User = cfg.user;
+        Group = cfg.group;
+        Type = "simple";
+        ExecStart = "${lib.getExe cfg.package} server --config ${cfg.configFile} --workdir ${cfg.workdir} --host ${cfg.settings.host} --port ${builtins.toString cfg.settings.port}";
+        Restart = "on-failure";
+        RestartSec = "5s";
+        ConfigurationDirectory = [ "artalk" ];
+        StateDirectory = [ "artalk" ];
+        RuntimeDirectory = [ "artalk" ];
+        AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
+        ProtectHome = "yes";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/audiobookshelf.nix b/nixos/modules/services/web-apps/audiobookshelf.nix
index 84dffc5f9d3c5..2f00c852ac8fe 100644
--- a/nixos/modules/services/web-apps/audiobookshelf.nix
+++ b/nixos/modules/services/web-apps/audiobookshelf.nix
@@ -8,7 +8,7 @@ in
 {
   options = {
     services.audiobookshelf = {
-      enable = mkEnableOption "Audiobookshelf, self-hosted audiobook and podcast server.";
+      enable = mkEnableOption "Audiobookshelf, self-hosted audiobook and podcast server";
 
       package = mkPackageOption pkgs "audiobookshelf" { };
 
diff --git a/nixos/modules/services/web-apps/commafeed.nix b/nixos/modules/services/web-apps/commafeed.nix
new file mode 100644
index 0000000000000..354e3625bb999
--- /dev/null
+++ b/nixos/modules/services/web-apps/commafeed.nix
@@ -0,0 +1,114 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+let
+  cfg = config.services.commafeed;
+in
+{
+  options.services.commafeed = {
+    enable = lib.mkEnableOption "CommaFeed";
+
+    package = lib.mkPackageOption pkgs "commafeed" { };
+
+    user = lib.mkOption {
+      type = lib.types.str;
+      description = "User under which CommaFeed runs.";
+      default = "commafeed";
+    };
+
+    group = lib.mkOption {
+      type = lib.types.str;
+      description = "Group under which CommaFeed runs.";
+      default = "commafeed";
+    };
+
+    stateDir = lib.mkOption {
+      type = lib.types.path;
+      description = "Directory holding all state for CommaFeed to run.";
+      default = "/var/lib/commafeed";
+    };
+
+    environment = lib.mkOption {
+      type = lib.types.attrsOf (
+        lib.types.oneOf [
+          lib.types.bool
+          lib.types.int
+          lib.types.str
+        ]
+      );
+      description = ''
+        Extra environment variables passed to CommaFeed, refer to
+        <https://github.com/Athou/commafeed/blob/master/commafeed-server/config.yml.example>
+        for supported values. The default user is `admin` and the default password is `admin`.
+        Correct configuration for H2 database is already provided.
+      '';
+      default = { };
+      example = {
+        CF_SERVER_APPLICATIONCONNECTORS_0_TYPE = "http";
+        CF_SERVER_APPLICATIONCONNECTORS_0_PORT = 9090;
+      };
+    };
+
+    environmentFile = lib.mkOption {
+      type = lib.types.nullOr lib.types.path;
+      description = ''
+        Environment file as defined in {manpage}`systemd.exec(5)`.
+      '';
+      default = null;
+      example = "/var/lib/commafeed/commafeed.env";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.commafeed = {
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+      environment = lib.mapAttrs (
+        _: v: if lib.isBool v then lib.boolToString v else toString v
+      ) cfg.environment;
+      serviceConfig = {
+        ExecStart = "${lib.getExe cfg.package} server ${cfg.package}/share/config.yml";
+        User = cfg.user;
+        Group = cfg.group;
+        StateDirectory = baseNameOf cfg.stateDir;
+        WorkingDirectory = cfg.stateDir;
+        # Hardening
+        CapabilityBoundingSet = [ "" ];
+        DevicePolicy = "closed";
+        DynamicUser = true;
+        LockPersonality = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        PrivateUsers = true;
+        ProcSubset = "pid";
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        ProtectSystem = true;
+        RestrictAddressFamilies = [
+          "AF_INET"
+          "AF_INET6"
+        ];
+        RestrictNamespaces = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [
+          "@system-service"
+          "~@privileged"
+        ];
+        UMask = "0077";
+      } // lib.optionalAttrs (cfg.environmentFile != null) { EnvironmentFile = cfg.environmentFile; };
+    };
+  };
+
+  meta.maintainers = [ lib.maintainers.raroh73 ];
+}
diff --git a/nixos/modules/services/web-apps/filesender.md b/nixos/modules/services/web-apps/filesender.md
new file mode 100644
index 0000000000000..44d066761b9a4
--- /dev/null
+++ b/nixos/modules/services/web-apps/filesender.md
@@ -0,0 +1,49 @@
+# FileSender {#module-services-filesender}
+
+[FileSender](https://filesender.org/software/) is a software that makes it easy to send and receive big files.
+
+## Quickstart {#module-services-filesender-quickstart}
+
+FileSender uses [SimpleSAMLphp](https://simplesamlphp.org/) for authentication, which needs to be configured separately.
+
+Minimal working instance of FileSender that uses password-authentication would look like this:
+
+```nix
+{
+  networking.firewall.allowedTCPPorts = [ 80 443 ];
+  services.filesender = {
+    enable = true;
+    localDomain = "filesender.example.com";
+    configureNginx = true;
+    database.createLocally = true;
+
+    settings = {
+      auth_sp_saml_authentication_source = "default";
+      auth_sp_saml_uid_attribute = "uid";
+      storage_filesystem_path = "<STORAGE PATH FOR UPLOADED FILES>";
+      admin = "admin";
+      admin_email = "admin@example.com";
+      email_reply_to = "noreply@example.com";
+    };
+  };
+  services.simplesamlphp.filesender = {
+    settings = {
+      "module.enable".exampleauth = true;
+    };
+    authSources = {
+      admin = [ "core:AdminPassword" ];
+      default = format.lib.mkMixedArray [ "exampleauth:UserPass" ] {
+        "admin:admin123" = {
+          uid = [ "admin" ];
+          cn = [ "admin" ];
+          mail = [ "admin@example.com" ];
+        };
+      };
+    };
+  };
+}
+```
+
+::: {.warning}
+Example above uses hardcoded clear-text password, in production you should use other authentication method like LDAP. You can check supported authentication methods [in SimpleSAMLphp documentation](https://simplesamlphp.org/docs/stable/simplesamlphp-idp.html).
+:::
diff --git a/nixos/modules/services/web-apps/filesender.nix b/nixos/modules/services/web-apps/filesender.nix
new file mode 100644
index 0000000000000..bc8d465643f2f
--- /dev/null
+++ b/nixos/modules/services/web-apps/filesender.nix
@@ -0,0 +1,253 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+let
+  format = pkgs.formats.php { finalVariable = "config"; };
+
+  cfg = config.services.filesender;
+  simpleSamlCfg = config.services.simplesamlphp.filesender;
+  fpm = config.services.phpfpm.pools.filesender;
+
+  filesenderConfigDirectory = pkgs.runCommand "filesender-config" { } ''
+    mkdir $out
+    cp ${format.generate "config.php" cfg.settings} $out/config.php
+  '';
+in
+{
+  meta = {
+    maintainers = with lib.maintainers; [ nhnn ];
+    doc = ./filesender.md;
+  };
+
+  options.services.filesender = with lib; {
+    enable = mkEnableOption "FileSender";
+    package = mkPackageOption pkgs "filesender" { };
+    user = mkOption {
+      description = "User under which filesender runs.";
+      type = types.str;
+      default = "filesender";
+    };
+    database = {
+      createLocally = mkOption {
+        type = types.bool;
+        default = true;
+        description = ''
+          Create the PostgreSQL database and database user locally.
+        '';
+      };
+      hostname = mkOption {
+        type = types.str;
+        default = "/run/postgresql";
+        description = "Database hostname.";
+      };
+      port = mkOption {
+        type = types.port;
+        default = 5432;
+        description = "Database port.";
+      };
+      name = mkOption {
+        type = types.str;
+        default = "filesender";
+        description = "Database name.";
+      };
+      user = mkOption {
+        type = types.str;
+        default = "filesender";
+        description = "Database user.";
+      };
+      passwordFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        example = "/run/keys/filesender-dbpassword";
+        description = ''
+          A file containing the password corresponding to
+          [](#opt-services.filesender.database.user).
+        '';
+      };
+    };
+    settings = mkOption {
+      type = types.submodule {
+        freeformType = format.type;
+        options = {
+          site_url = mkOption {
+            type = types.str;
+            description = "Site URL. Used in emails, to build URLs for logging in, logging out, build URL for upload endpoint for web workers, to include scripts etc.";
+          };
+          admin = mkOption {
+            type = types.commas;
+            description = ''
+              UIDs (as per the configured saml_uid_attribute) of FileSender administrators.
+              Accounts with these UIDs can access the Admin page through the web UI.
+            '';
+          };
+          admin_email = mkOption {
+            type = types.commas;
+            description = ''
+              Email address of FileSender administrator(s).
+              Emails regarding disk full etc. are sent here.
+              You should use a role-address here.
+            '';
+          };
+          storage_filesystem_path = mkOption {
+            type = types.nullOr types.str;
+            description = "When using storage type filesystem this is the absolute path to the file system where uploaded files are stored until they expire. Your FileSender storage root.";
+          };
+          log_facilities = mkOption {
+            type = format.type;
+            default = [ { type = "error_log"; } ];
+            description = "Defines where FileSender logging is sent. You can sent logging to a file, to syslog or to the default PHP log facility (as configured through your webserver's PHP module). The directive takes an array of one or more logging targets. Logging can be sent to multiple targets simultaneously. Each logging target is a list containing the name of the logging target and a number of attributes which vary per log target. See below for the exact definiation of each log target.";
+          };
+        };
+      };
+      default = { };
+      description = ''
+        Configuration options used by FileSender.
+        See [](https://docs.filesender.org/filesender/v2.0/admin/configuration/)
+        for available options.
+      '';
+    };
+    configureNginx = mkOption {
+      type = types.bool;
+      default = true;
+      description = "Configure nginx as a reverse proxy for FileSender.";
+    };
+    localDomain = mkOption {
+      type = types.str;
+      example = "filesender.example.org";
+      description = "The domain serving your FileSender instance.";
+    };
+    poolSettings = mkOption {
+      type =
+        with types;
+        attrsOf (oneOf [
+          str
+          int
+          bool
+        ]);
+      default = {
+        "pm" = "dynamic";
+        "pm.max_children" = "32";
+        "pm.start_servers" = "2";
+        "pm.min_spare_servers" = "2";
+        "pm.max_spare_servers" = "4";
+        "pm.max_requests" = "500";
+      };
+      description = ''
+        Options for FileSender's PHP pool. See the documentation on `php-fpm.conf` for details on configuration directives.
+      '';
+    };
+  };
+  config = lib.mkIf cfg.enable {
+    services.simplesamlphp.filesender = {
+      phpfpmPool = "filesender";
+      localDomain = cfg.localDomain;
+      settings.baseurlpath = lib.mkDefault "https://${cfg.localDomain}/saml";
+    };
+
+    services.phpfpm = {
+      pools.filesender = {
+        user = cfg.user;
+        group = config.services.nginx.group;
+        phpEnv = {
+          FILESENDER_CONFIG_DIR = toString filesenderConfigDirectory;
+          SIMPLESAMLPHP_CONFIG_DIR = toString simpleSamlCfg.configDir;
+        };
+        settings = {
+          "listen.owner" = config.services.nginx.user;
+          "listen.group" = config.services.nginx.group;
+        } // cfg.poolSettings;
+      };
+    };
+
+    services.nginx = lib.mkIf cfg.configureNginx {
+      enable = true;
+      virtualHosts.${cfg.localDomain} = {
+        root = "${cfg.package}/www";
+        extraConfig = ''
+          index index.php;
+        '';
+        locations = {
+          "/".extraConfig = ''
+            try_files $uri $uri/ /index.php?args;
+          '';
+          "~ [^/]\\.php(/|$)" = {
+            extraConfig = ''
+              fastcgi_split_path_info  ^(.+\.php)(/.+)$;
+              fastcgi_pass  unix:${fpm.socket};
+              include ${pkgs.nginx}/conf/fastcgi.conf;
+              fastcgi_intercept_errors on;
+              fastcgi_param PATH_INFO       $fastcgi_path_info;
+              fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+            '';
+          };
+          "~ /\\.".extraConfig = "deny all;";
+        };
+      };
+    };
+
+    services.postgresql = lib.mkIf cfg.database.createLocally {
+      enable = true;
+      ensureDatabases = [ cfg.database.name ];
+      ensureUsers = [
+        {
+          name = cfg.database.user;
+          ensureDBOwnership = true;
+        }
+      ];
+    };
+
+    services.filesender.settings = lib.mkMerge [
+      (lib.mkIf cfg.database.createLocally {
+        db_host = "/run/postgresql";
+        db_port = "5432";
+        db_password = "."; # FileSender requires it even when on UNIX socket auth.
+      })
+      (lib.mkIf (!cfg.database.createLocally) {
+        db_host = cfg.database.hostname;
+        db_port = toString cfg.database.port;
+        db_password = format.lib.mkRaw "file_get_contents('${cfg.database.passwordFile}')";
+      })
+      {
+        site_url = lib.mkDefault "https://${cfg.localDomain}";
+        db_type = "pgsql";
+        db_username = cfg.database.user;
+        db_database = cfg.database.name;
+        "auth_sp_saml_simplesamlphp_url" = "/saml";
+        "auth_sp_saml_simplesamlphp_location" = "${simpleSamlCfg.libDir}";
+      }
+    ];
+
+    systemd.services.filesender-initdb = {
+      description = "Init filesender DB";
+
+      wantedBy = [
+        "multi-user.target"
+        "phpfpm-filesender.service"
+      ];
+      after = [ "postgresql.service" ];
+
+      restartIfChanged = true;
+
+      serviceConfig = {
+        Environment = [
+          "FILESENDER_CONFIG_DIR=${toString filesenderConfigDirectory}"
+          "SIMPLESAMLPHP_CONFIG_DIR=${toString simpleSamlCfg.configDir}"
+        ];
+        Type = "oneshot";
+        Group = config.services.nginx.group;
+        User = "filesender";
+        ExecStart = "${fpm.phpPackage}/bin/php ${cfg.package}/scripts/upgrade/database.php";
+      };
+    };
+
+    users.extraUsers.filesender = lib.mkIf (cfg.user == "filesender") {
+      home = "/var/lib/filesender";
+      group = config.services.nginx.group;
+      createHome = true;
+      isSystemUser = true;
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/firefly-iii.nix b/nixos/modules/services/web-apps/firefly-iii.nix
index b0024ce09c38e..338f049093202 100644
--- a/nixos/modules/services/web-apps/firefly-iii.nix
+++ b/nixos/modules/services/web-apps/firefly-iii.nix
@@ -3,8 +3,8 @@
 let
   inherit (lib) optionalString mkDefault mkIf mkOption mkEnableOption literalExpression;
   inherit (lib.types) nullOr attrsOf oneOf str int bool path package enum submodule;
-  inherit (lib.strings) concatMapStringsSep removePrefix toShellVars removeSuffix hasSuffix;
-  inherit (lib.attrsets) attrValues genAttrs filterAttrs mapAttrs' nameValuePair;
+  inherit (lib.strings) concatLines removePrefix toShellVars removeSuffix hasSuffix;
+  inherit (lib.attrsets) mapAttrsToList attrValues genAttrs filterAttrs mapAttrs' nameValuePair;
   inherit (builtins) isInt isString toString typeOf;
 
   cfg = config.services.firefly-iii;
@@ -21,18 +21,10 @@ let
     (filterAttrs (n: v: hasSuffix "_FILE" n) cfg.settings);
   env-nonfile-values = filterAttrs (n: v: ! hasSuffix "_FILE" n) cfg.settings;
 
-  envfile = pkgs.writeText "firefly-iii-env" ''
-    ${toShellVars env-file-values}
-    ${toShellVars env-nonfile-values}
-  '';
-
   fileenv-func = ''
-    cp --no-preserve=mode ${envfile} /tmp/firefly-iii-env
-    ${concatMapStringsSep "\n"
-      (n: "${pkgs.replace-secret}/bin/replace-secret ${n} ${n} /tmp/firefly-iii-env")
-      (attrValues env-file-values)}
     set -a
-    . /tmp/firefly-iii-env
+    ${toShellVars env-nonfile-values}
+    ${concatLines (mapAttrsToList (n: v: "${n}=\"$(< ${v})\"") env-file-values)}
     set +a
   '';
 
@@ -41,22 +33,21 @@ let
 
     ${optionalString (cfg.settings.DB_CONNECTION == "sqlite")
       "touch ${cfg.dataDir}/storage/database/database.sqlite"}
-    ${artisan} migrate --seed --no-interaction --force
-    ${artisan} firefly-iii:decrypt-all
+    ${artisan} cache:clear
+    ${artisan} package:discover
     ${artisan} firefly-iii:upgrade-database
-    ${artisan} firefly-iii:correct-database
-    ${artisan} firefly-iii:report-integrity
     ${artisan} firefly-iii:laravel-passport-keys
-    ${artisan} cache:clear
-
-    mv /tmp/firefly-iii-env /run/phpfpm/firefly-iii-env
+    ${artisan} view:cache
+    ${artisan} route:cache
+    ${artisan} config:cache
   '';
 
   commonServiceConfig = {
     Type = "oneshot";
     User = user;
     Group = group;
-    StateDirectory = "${removePrefix "/var/lib/" cfg.dataDir}";
+    StateDirectory = "firefly-iii";
+    ReadWritePaths = [cfg.dataDir];
     WorkingDirectory = cfg.package;
     PrivateTmp = true;
     PrivateDevices = true;
@@ -146,6 +137,7 @@ in {
 
     virtualHost = mkOption {
       type = str;
+      default = "localhost";
       description = ''
         The hostname at which you wish firefly-iii to be served. If you have
         enabled nginx using `services.firefly-iii.enableNginx` then this will
@@ -170,14 +162,15 @@ in {
     };
 
     settings = mkOption {
+      default = {};
       description = ''
         Options for firefly-iii configuration. Refer to
         <https://github.com/firefly-iii/firefly-iii/blob/main/.env.example> for
         details on supported values. All <option>_FILE values supported by
         upstream are supported here.
 
-        APP_URL will be set by `services.firefly-iii.virtualHost`, do not
-        redefine it here.
+        APP_URL will be the same as `services.firefly-iii.virtualHost` if the
+        former is unset in `services.firefly-iii.settings`.
       '';
       example = literalExpression ''
         {
@@ -192,7 +185,6 @@ in {
           DB_PASSWORD_FILE = "/var/secrets/firefly-iii-mysql-password.txt;
         }
       '';
-      default = {};
       type = submodule {
         freeformType = attrsOf (oneOf [str int bool]);
         options = {
@@ -216,9 +208,9 @@ in {
           };
           DB_PORT = mkOption {
             type = nullOr int;
-            default = if cfg.settings.DB_CONNECTION == "sqlite" then null
+            default = if cfg.settings.DB_CONNECTION == "pgsql" then 5432
                       else if cfg.settings.DB_CONNECTION == "mysql" then 3306
-                      else 5432;
+                      else null;
             defaultText = ''
               `null` if DB_CONNECTION is "sqlite", `3306` if "mysql", `5432` if "pgsql"
             '';
@@ -227,6 +219,21 @@ in {
               this value to be filled.
             '';
           };
+          DB_HOST = mkOption {
+            type = str;
+            default = if cfg.settings.DB_CONNECTION == "pgsql" then "/run/postgresql"
+                      else "localhost";
+            defaultText = ''
+              "localhost" if DB_CONNECTION is "sqlite" or "mysql", "/run/postgresql" if "pgsql".
+            '';
+            description = ''
+              The machine which hosts your database. This is left at the
+              default value for "mysql" because we use the "DB_SOCKET" option
+              to connect to a unix socket instead. "pgsql" requires that the
+              unix socket location be specified here instead of at "DB_SOCKET".
+              This option does not affect "sqlite".
+            '';
+          };
           APP_KEY_FILE = mkOption {
             type = path;
             description = ''
@@ -235,6 +242,20 @@ in {
               /dev/urandom | base64)" > /path/to/key-file`.
             '';
           };
+          APP_URL = mkOption {
+            type = str;
+            default = if cfg.virtualHost == "localhost" then "http://${cfg.virtualHost}"
+                      else "https://${cfg.virtualHost}";
+            defaultText = ''
+              http(s)://''${config.services.firefly-iii.virtualHost}
+            '';
+            description = ''
+              The APP_URL used by firefly-iii internally. Please make sure this
+              URL matches the external URL of your Firefly III installation. It
+              is used to validate specific requests and to generate URLs in
+              emails.
+            '';
+          };
         };
       };
     };
@@ -242,12 +263,6 @@ in {
 
   config = mkIf cfg.enable {
 
-    services.firefly-iii = {
-      settings = {
-        APP_URL = cfg.virtualHost;
-      };
-    };
-
     services.phpfpm.pools.firefly-iii = {
       inherit user group;
       phpPackage = cfg.package.phpPackage;
@@ -262,29 +277,25 @@ in {
       } // cfg.poolConfig;
     };
 
-    systemd.services.phpfpm-firefly-iii.serviceConfig = {
-      EnvironmentFile = "/run/phpfpm/firefly-iii-env";
-      ExecStartPost = "${pkgs.coreutils}/bin/rm /run/phpfpm/firefly-iii-env";
-    };
-
     systemd.services.firefly-iii-setup = {
+      after = [ "postgresql.service" "mysql.service" ];
       requiredBy = [ "phpfpm-firefly-iii.service" ];
       before = [ "phpfpm-firefly-iii.service" ];
       serviceConfig = {
         ExecStart = firefly-iii-maintenance;
-        RuntimeDirectory = "phpfpm";
-        RuntimeDirectoryPreserve = true;
+        RemainAfterExit = true;
       } // commonServiceConfig;
       unitConfig.JoinsNamespaceOf = "phpfpm-firefly-iii.service";
+      restartTriggers = [ cfg.package ];
     };
 
     systemd.services.firefly-iii-cron = {
+      after = [ "firefly-iii-setup.service" "postgresql.service" "mysql.service" ];
+      wants = [ "firefly-iii-setup.service" ];
       description = "Daily Firefly III cron job";
-      script = ''
-        ${fileenv-func}
-        ${artisan} firefly-iii:cron
-      '';
-      serviceConfig = commonServiceConfig;
+      serviceConfig = {
+        ExecStart = "${artisan} firefly-iii:cron";
+      } // commonServiceConfig;
     };
 
     systemd.timers.firefly-iii-cron = {
@@ -295,6 +306,7 @@ in {
         Persistent = true;
       };
       wantedBy = [ "timers.target" ];
+      restartTriggers = [ cfg.package ];
     };
 
     services.nginx = mkIf cfg.enableNginx {
diff --git a/nixos/modules/services/web-apps/flarum.nix b/nixos/modules/services/web-apps/flarum.nix
new file mode 100644
index 0000000000000..a967c3b121bd6
--- /dev/null
+++ b/nixos/modules/services/web-apps/flarum.nix
@@ -0,0 +1,210 @@
+{ pkgs, lib, config, ... }:
+
+with lib;
+
+let
+  cfg = config.services.flarum;
+
+  flarumInstallConfig = pkgs.writeText "config.json" (builtins.toJSON {
+    debug = false;
+    offline = false;
+
+    baseUrl = cfg.baseUrl;
+    databaseConfiguration = cfg.database;
+    adminUser = {
+      username = cfg.adminUser;
+      password = cfg.initialAdminPassword;
+      email = cfg.adminEmail;
+    };
+    settings = {
+      forum_title = cfg.forumTitle;
+    };
+  });
+in {
+  options.services.flarum = {
+    enable = mkEnableOption "Flarum discussion platform";
+
+    package = mkPackageOption pkgs "flarum" { };
+
+    forumTitle = mkOption {
+      type = types.str;
+      default = "A Flarum Forum on NixOS";
+      description = "Title of the forum.";
+    };
+
+    domain = mkOption {
+      type = types.str;
+      default = "localhost";
+      example = "forum.example.com";
+      description = "Domain to serve on.";
+    };
+
+    baseUrl = mkOption {
+      type = types.str;
+      default = "http://localhost";
+      example = "https://forum.example.com";
+      description = "Change `domain` instead.";
+    };
+
+    adminUser = mkOption {
+      type = types.str;
+      default = "flarum";
+      description = "Username for first web application administrator";
+    };
+
+    adminEmail = mkOption {
+      type = types.str;
+      default = "admin@example.com";
+      description = "Email for first web application administrator";
+    };
+
+    initialAdminPassword = mkOption {
+      type = types.str;
+      default = "flarum";
+      description = "Initial password for the adminUser";
+    };
+
+    user = mkOption {
+      type = types.str;
+      default = "flarum";
+      description = "System user to run Flarum";
+    };
+
+    group = mkOption {
+      type = types.str;
+      default = "flarum";
+      description = "System group to run Flarum";
+    };
+
+    stateDir = mkOption {
+      type = types.path;
+      default = "/var/lib/flarum";
+      description = "Home directory for writable storage";
+    };
+
+    database = mkOption rec {
+      type = with types; attrsOf (oneOf [str bool int]);
+      description = "MySQL database parameters";
+      default = {
+        # the database driver; i.e. MySQL; MariaDB...
+        driver = "mysql";
+        # the host of the connection; localhost in most cases unless using an external service
+        host = "localhost";
+        # the name of the database in the instance
+        database = "flarum";
+        # database username
+        username = "flarum";
+        # database password
+        password = "";
+        # the prefix for the tables; useful if you are sharing the same database with another service
+        prefix = "";
+        # the port of the connection; defaults to 3306 with MySQL
+        port = 3306;
+        strict = false;
+      };
+    };
+
+    createDatabaseLocally = mkOption {
+      type = types.bool;
+      default = true;
+      description = "Create the database and database user locally, and run installation.";
+    };
+  };
+
+  config = mkIf cfg.enable {
+    users.users.${cfg.user} = {
+      isSystemUser = true;
+      home = cfg.stateDir;
+      createHome = true;
+      group = cfg.group;
+    };
+    users.groups.${cfg.group} = {};
+
+    services.phpfpm.pools.flarum = {
+      user = cfg.user;
+      settings = {
+        "listen.owner" = config.services.nginx.user;
+        "listen.group" = config.services.nginx.group;
+        "listen.mode" = "0600";
+        "pm" = mkDefault "dynamic";
+        "pm.max_children" = mkDefault 10;
+        "pm.max_requests" = mkDefault 500;
+        "pm.start_servers" = mkDefault 2;
+        "pm.min_spare_servers" = mkDefault 1;
+        "pm.max_spare_servers" = mkDefault 3;
+      };
+      phpOptions = ''
+        error_log = syslog
+        log_errors = on
+      '';
+    };
+
+    services.nginx = {
+      enable = true;
+      virtualHosts."${cfg.domain}" = {
+        root = "${cfg.stateDir}/public";
+        locations."~ \.php$".extraConfig = ''
+          fastcgi_pass unix:${config.services.phpfpm.pools.flarum.socket};
+          fastcgi_index site.php;
+        '';
+        extraConfig = ''
+          index index.php;
+          include ${cfg.package}/share/php/flarum/.nginx.conf;
+        '';
+      };
+    };
+
+    services.mysql = mkIf cfg.enable {
+      enable = true;
+      package = pkgs.mysql;
+      ensureDatabases = [cfg.database.database];
+      ensureUsers = [
+        {
+          name = cfg.database.username;
+          ensurePermissions = {
+            "${cfg.database.database}.*" = "ALL PRIVILEGES";
+          };
+        }
+      ];
+    };
+
+    assertions = [
+      {
+        assertion = !cfg.createDatabaseLocally || cfg.database.driver == "mysql";
+        message = "Flarum can only be automatically installed in MySQL/MariaDB.";
+      }
+    ];
+
+    systemd.services.flarum-install = {
+      description = "Flarum installation";
+      requiredBy = ["phpfpm-flarum.service"];
+      before = ["phpfpm-flarum.service"];
+      requires = ["mysql.service"];
+      after = ["mysql.service"];
+      serviceConfig = {
+        Type = "oneshot";
+        User = cfg.user;
+        Group = cfg.group;
+      };
+      path = [config.services.phpfpm.phpPackage];
+      script = ''
+        mkdir -p ${cfg.stateDir}/{extensions,public/assets/avatars}
+        mkdir -p ${cfg.stateDir}/storage/{cache,formatter,sessions,views}
+        cd ${cfg.stateDir}
+        cp -f ${cfg.package}/share/php/flarum/{extend.php,site.php,flarum} .
+        ln -sf ${cfg.package}/share/php/flarum/vendor .
+        ln -sf ${cfg.package}/share/php/flarum/public/index.php public/
+        chmod a+x . public
+        chmod +x site.php extend.php flarum
+      '' + optionalString (cfg.createDatabaseLocally && cfg.database.driver == "mysql") ''
+        if [ ! -f config.php ]; then
+            php flarum install --file=${flarumInstallConfig}
+        fi
+        php flarum migrate
+        php flarum cache:clear
+      '';
+    };
+  };
+
+  meta.maintainers = with lib.maintainers; [ fsagbuya jasonodoom ];
+}
diff --git a/nixos/modules/services/web-apps/freshrss.nix b/nixos/modules/services/web-apps/freshrss.nix
index 77c5ecb246171..021101fecaa48 100644
--- a/nixos/modules/services/web-apps/freshrss.nix
+++ b/nixos/modules/services/web-apps/freshrss.nix
@@ -10,7 +10,7 @@ in
   meta.maintainers = with maintainers; [ etu stunkymonkey mattchrist ];
 
   options.services.freshrss = {
-    enable = mkEnableOption "FreshRSS feed reader";
+    enable = mkEnableOption "FreshRSS RSS aggregator and reader with php-fpm backend.";
 
     package = mkPackageOption pkgs "freshrss" { };
 
@@ -108,7 +108,7 @@ in
       type = types.str;
       default = poolName;
       description = ''
-        Name of the phpfpm pool to use and setup. If not specified, a pool will be created
+        Name of the php-fpm pool to use and setup. If not specified, a pool will be created
         with default values.
       '';
     };
@@ -255,13 +255,10 @@ in
         {
           description = "Set up the state directory for FreshRSS before use";
           wantedBy = [ "multi-user.target" ];
-          serviceConfig = defaultServiceConfig //{
-            Type = "oneshot";
-            User = "freshrss";
-            Group = "freshrss";
-            StateDirectory = "freshrss";
-            WorkingDirectory = cfg.package;
+          serviceConfig = defaultServiceConfig // {
+            RemainAfterExit = true;
           };
+          restartIfChanged = true;
           environment = {
             DATA_PATH = cfg.dataDir;
           };
@@ -299,7 +296,7 @@ in
         environment = {
           DATA_PATH = cfg.dataDir;
         };
-        serviceConfig = defaultServiceConfig //{
+        serviceConfig = defaultServiceConfig // {
           ExecStart = "${cfg.package}/app/actualize_script.php";
         };
       };
diff --git a/nixos/modules/services/web-apps/invoiceplane.nix b/nixos/modules/services/web-apps/invoiceplane.nix
index 4d0e25958e35a..9a9f180b21021 100644
--- a/nixos/modules/services/web-apps/invoiceplane.nix
+++ b/nixos/modules/services/web-apps/invoiceplane.nix
@@ -39,20 +39,17 @@ let
 
   extraConfig = hostName: cfg: let
     settings = mapAttrsToList (k: v: "${k}=${mkPhpValue v}") cfg.settings;
-  in pkgs.writeText "extraConfig.php" ''
-    ${concatStringsSep "\n" settings}
-    ${toString cfg.extraConfig}
-  '';
+  in pkgs.writeText "extraConfig.php" (concatStringsSep "\n" settings);
 
   pkg = hostName: cfg: pkgs.stdenv.mkDerivation rec {
     pname = "invoiceplane-${hostName}";
     version = src.version;
     src = pkgs.invoiceplane;
 
-    postPhase = ''
+    postPatch = ''
       # Patch index.php file to load additional config file
       substituteInPlace index.php \
-        --replace "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = Dotenv\Dotenv::createImmutable(__DIR__, 'extraConfig.php'); \$dotenv->load();";
+        --replace-fail "require('vendor/autoload.php');" "require('vendor/autoload.php'); \$dotenv = Dotenv\Dotenv::createImmutable(__DIR__, 'extraConfig.php'); \$dotenv->load();";
     '';
 
     installPhase = ''
@@ -182,25 +179,6 @@ let
           '';
         };
 
-        extraConfig = mkOption {
-          type = types.nullOr types.lines;
-          default = null;
-          example = ''
-            SETUP_COMPLETED=true
-            DISABLE_SETUP=true
-            IP_URL=https://invoice.example.com
-          '';
-          description = ''
-            InvoicePlane configuration. Refer to
-            <https://github.com/InvoicePlane/InvoicePlane/blob/master/ipconfig.php.example>
-            for details on supported values.
-
-            **Note**: Please pass structured settings via
-            `services.invoiceplane.sites.${name}.settings` instead, this option
-            will get deprecated in the future.
-          '';
-        };
-
         settings = mkOption {
           type = types.attrsOf types.anything;
           default = {};
@@ -269,12 +247,6 @@ in
   # implementation
   config = mkIf (eachSite != {}) (mkMerge [{
 
-    warnings = flatten (mapAttrsToList (hostName: cfg: [
-      (optional (cfg.extraConfig != null) ''
-        services.invoiceplane.sites."${hostName}".extraConfig will be deprecated in future releases, please use the settings option now.
-      '')
-    ]) eachSite);
-
     assertions = flatten (mapAttrsToList (hostName: cfg: [
       { assertion = cfg.database.createLocally -> cfg.database.user == user;
         message = ''services.invoiceplane.sites."${hostName}".database.user must be ${user} if the database is to be automatically provisioned'';
diff --git a/nixos/modules/services/web-apps/jitsi-meet.nix b/nixos/modules/services/web-apps/jitsi-meet.nix
index 76753b89ec9ea..247b65c786636 100644
--- a/nixos/modules/services/web-apps/jitsi-meet.nix
+++ b/nixos/modules/services/web-apps/jitsi-meet.nix
@@ -170,7 +170,7 @@ in
       '';
     };
 
-    caddy.enable = mkEnableOption "Whether to enable caddy reverse proxy to expose jitsi-meet";
+    caddy.enable = mkEnableOption "caddy reverse proxy to expose jitsi-meet";
 
     prosody.enable = mkOption {
       type = bool;
diff --git a/nixos/modules/services/web-apps/keycloak.md b/nixos/modules/services/web-apps/keycloak.md
index 020bee4003489..4036885ce151c 100644
--- a/nixos/modules/services/web-apps/keycloak.md
+++ b/nixos/modules/services/web-apps/keycloak.md
@@ -68,13 +68,11 @@ to `/auth`. See the option description
 for more details.
 :::
 
-[](#opt-services.keycloak.settings.hostname-strict-backchannel)
-determines whether Keycloak should force all requests to go
-through the frontend URL. By default,
-Keycloak allows backend requests to
-instead use its local hostname or IP address and may also
-advertise it to clients through its OpenID Connect Discovery
-endpoint.
+[](#opt-services.keycloak.settings.hostname-backchannel-dynamic)
+Keycloak has the capability to offer a separate URL for backchannel requests,
+enabling internal communication while maintaining the use of a public URL
+for frontchannel requests. Moreover, the backchannel is dynamically
+resolved based on incoming headers endpoint.
 
 For more information on hostname configuration, see the [Hostname
 section of the Keycloak Server Installation and Configuration
diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix
index cf1282b3d4cf4..36bae2575974e 100644
--- a/nixos/modules/services/web-apps/keycloak.nix
+++ b/nixos/modules/services/web-apps/keycloak.nix
@@ -328,8 +328,7 @@ in
             };
 
             hostname = mkOption {
-              type = nullOr str;
-              default = null;
+              type = str;
               example = "keycloak.example.com";
               description = ''
                 The hostname part of the public URL used as base for
@@ -340,16 +339,13 @@ in
               '';
             };
 
-            hostname-strict-backchannel = mkOption {
+            hostname-backchannel-dynamic = mkOption {
               type = bool;
               default = false;
               example = true;
               description = ''
-                Whether Keycloak should force all requests to go
-                through the frontend URL. By default, Keycloak allows
-                backend requests to instead use its local hostname or
-                IP address and may also advertise it to clients
-                through its OpenID Connect Discovery endpoint.
+                Enables dynamic resolving of backchannel URLs,
+                including hostname, scheme, port and context path.
 
                 See <https://www.keycloak.org/server/hostname>
                 for more information about hostname configuration.
@@ -466,7 +462,8 @@ in
       confFile = pkgs.writeText "keycloak.conf" (keycloakConfig filteredConfig);
       keycloakBuild = cfg.package.override {
         inherit confFile;
-        plugins = cfg.package.enabledPlugins ++ cfg.plugins;
+        plugins = cfg.package.enabledPlugins ++ cfg.plugins ++
+                  (with cfg.package.plugins; [quarkus-systemd-notify quarkus-systemd-notify-deployment]);
       };
     in
     mkIf cfg.enable
@@ -481,12 +478,20 @@ in
             message = "Setting up a local PostgreSQL db for Keycloak requires `standard_conforming_strings` turned on to work reliably";
           }
           {
-            assertion = cfg.settings.hostname != null || cfg.settings.hostname-url or null != null;
-            message = "Setting the Keycloak hostname is required, see `services.keycloak.settings.hostname`";
+            assertion = cfg.settings.hostname-url or null == null;
+            message = ''
+              The option `services.keycloak.settings.hostname-url' has been removed.
+              Set `services.keycloak.settings.hostname' instead.
+              See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
+            '';
           }
           {
-            assertion = !(cfg.settings.hostname != null && cfg.settings.hostname-url or null != null);
-            message = "`services.keycloak.settings.hostname` and `services.keycloak.settings.hostname-url` are mutually exclusive";
+            assertion = cfg.settings.hostname-strict-backchannel or null == null;
+            message = ''
+              The option `services.keycloak.settings.hostname-strict-backchannel' has been removed.
+              Set `services.keycloak.settings.hostname-backchannel-dynamic' instead.
+              See [New Hostname options](https://www.keycloak.org/docs/25.0.0/upgrading/#new-hostname-options) for details.
+            '';
           }
         ];
 
@@ -638,6 +643,8 @@ in
               RuntimeDirectory = "keycloak";
               RuntimeDirectoryMode = "0700";
               AmbientCapabilities = "CAP_NET_BIND_SERVICE";
+              Type = "notify";  # Requires quarkus-systemd-notify plugin
+              NotifyAccess = "all";
             };
             script = ''
               set -o errexit -o pipefail -o nounset -o errtrace
@@ -663,7 +670,7 @@ in
             '' + ''
               export KEYCLOAK_ADMIN=admin
               export KEYCLOAK_ADMIN_PASSWORD=${escapeShellArg cfg.initialAdminPassword}
-              kc.sh start --optimized
+              kc.sh --verbose start --optimized
             '';
           };
 
diff --git a/nixos/modules/services/web-apps/mastodon.nix b/nixos/modules/services/web-apps/mastodon.nix
index 570f2770fb291..daebd6441cb5d 100644
--- a/nixos/modules/services/web-apps/mastodon.nix
+++ b/nixos/modules/services/web-apps/mastodon.nix
@@ -20,8 +20,6 @@ let
 
     DB_USER = cfg.database.user;
 
-    REDIS_HOST = cfg.redis.host;
-    REDIS_PORT = toString(cfg.redis.port);
     DB_HOST = cfg.database.host;
     DB_NAME = cfg.database.name;
     LOCAL_DOMAIN = cfg.localDomain;
@@ -34,6 +32,8 @@ let
 
     TRUSTED_PROXY_IP = cfg.trustedProxy;
   }
+  // lib.optionalAttrs (cfg.redis.host != null) { REDIS_HOST = cfg.redis.host; }
+  // lib.optionalAttrs (cfg.redis.port != null) { REDIS_PORT = toString(cfg.redis.port); }
   // 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; }
@@ -90,6 +90,11 @@ let
     SystemCallArchitectures = "native";
   };
 
+  # Services that all Mastodon units After= and Requires= on
+  commonServices = lib.optional redisActuallyCreateLocally "redis-mastodon.service"
+    ++ lib.optional databaseActuallyCreateLocally "postgresql.service"
+    ++ lib.optional cfg.automaticMigrations "mastodon-init-db.service";
+
   envFile = pkgs.writeText "mastodon.env" (lib.concatMapStrings (s: s + "\n") (
     (lib.concatLists (lib.mapAttrsToList (name: value:
       lib.optional (value != null) ''${name}="${toString value}"''
@@ -117,14 +122,8 @@ let
       jobClassLabel = toString ([""] ++ processCfg.jobClasses);
       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";
+      after = [ "network.target" "mastodon-init-dirs.service" ] ++ commonServices;
+      requires = [ "mastodon-init-dirs.service" ] ++ commonServices;
       description = "Mastodon sidekiq${jobClassLabel}";
       wantedBy = [ "mastodon.target" ];
       environment = env // {
@@ -149,14 +148,8 @@ let
       (map (i: {
         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";
+          after = [ "network.target" "mastodon-init-dirs.service" ] ++ commonServices;
+          requires = [ "mastodon-init-dirs.service" ] ++ commonServices;
           wantedBy = [ "mastodon.target" "mastodon-streaming.target" ];
           description = "Mastodon streaming ${toString i}";
           environment = env // { SOCKET = "/run/mastodon-streaming/streaming-${toString i}.socket"; };
@@ -401,14 +394,20 @@ in {
 
         host = lib.mkOption {
           description = "Redis host.";
-          type = lib.types.str;
-          default = "127.0.0.1";
+          type = lib.types.nullOr lib.types.str;
+          default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then "127.0.0.1" else null;
+          defaultText = lib.literalExpression ''
+            if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket} then "127.0.0.1" else null
+          '';
         };
 
         port = lib.mkOption {
           description = "Redis port.";
-          type = lib.types.port;
-          default = 31637;
+          type = lib.types.nullOr lib.types.port;
+          default = if cfg.redis.createLocally && !cfg.redis.enableUnixSocket then 31637 else null;
+          defaultText = lib.literalExpression ''
+            if config.${opt.redis.createLocally} && !config.${opt.redis.enableUnixSocket} then 31637 else null
+          '';
         };
 
         passwordFile = lib.mkOption {
@@ -632,6 +631,20 @@ in {
   config = lib.mkIf cfg.enable (lib.mkMerge [{
     assertions = [
       {
+        assertion = !redisActuallyCreateLocally -> (cfg.redis.host != "127.0.0.1" && cfg.redis.port != null);
+        message = ''
+          `services.mastodon.redis.host` and `services.mastodon.redis.port` need to be set if
+            `services.mastodon.redis.createLocally` is not enabled.
+        '';
+      }
+      {
+        assertion = redisActuallyCreateLocally -> (!cfg.redis.enableUnixSocket || (cfg.redis.host == null && cfg.redis.port == null));
+        message = ''
+          `services.mastodon.redis.enableUnixSocket` needs to be disabled if
+            `services.mastodon.redis.host` and `services.mastodon.redis.port` is used.
+        '';
+      }
+      {
         assertion = redisActuallyCreateLocally -> (!cfg.redis.enableUnixSocket || cfg.redis.passwordFile == null);
         message = ''
           <option>services.mastodon.redis.enableUnixSocket</option> needs to be disabled if
@@ -783,14 +796,8 @@ 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";
+      after = [ "network.target" "mastodon-init-dirs.service" ] ++ commonServices;
+      requires = [ "mastodon-init-dirs.service" ] ++ commonServices;
       wantedBy = [ "mastodon.target" ];
       description = "Mastodon web";
       environment = env // (if cfg.enableUnixSocket
diff --git a/nixos/modules/services/web-apps/mealie.nix b/nixos/modules/services/web-apps/mealie.nix
index 8f68828e7a0be..2484b2489c0d0 100644
--- a/nixos/modules/services/web-apps/mealie.nix
+++ b/nixos/modules/services/web-apps/mealie.nix
@@ -28,8 +28,6 @@ in
         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";
@@ -61,6 +59,7 @@ in
         PRODUCTION = "true";
         ALEMBIC_CONFIG_FILE="${pkg}/config/alembic.ini";
         API_PORT = toString cfg.port;
+        BASE_URL = "http://localhost:${toString cfg.port}";
         DATA_DIR = "/var/lib/mealie";
         CRF_MODEL_PATH = "/var/lib/mealie/model.crfmodel";
       } // (builtins.mapAttrs (_: val: toString val) cfg.settings);
diff --git a/nixos/modules/services/web-apps/miniflux.nix b/nixos/modules/services/web-apps/miniflux.nix
index d65d6db3cdaaa..61243a63c582e 100644
--- a/nixos/modules/services/web-apps/miniflux.nix
+++ b/nixos/modules/services/web-apps/miniflux.nix
@@ -1,7 +1,7 @@
 { config, lib, pkgs, ... }:
 
-with lib;
 let
+  inherit (lib) mkEnableOption mkPackageOption mkOption types literalExpression mkIf mkDefault;
   cfg = config.services.miniflux;
 
   defaultAddress = "localhost:8080";
@@ -20,8 +20,8 @@ in
 
       package = mkPackageOption pkgs "miniflux" { };
 
-      createDatabaseLocally = lib.mkOption {
-        type = lib.types.bool;
+      createDatabaseLocally = mkOption {
+        type = types.bool;
         default = true;
         description = ''
           Whether a PostgreSQL database should be automatically created and
@@ -66,6 +66,7 @@ in
       DATABASE_URL = lib.mkIf cfg.createDatabaseLocally "user=miniflux host=/run/postgresql dbname=miniflux";
       RUN_MIGRATIONS = 1;
       CREATE_ADMIN = 1;
+      WATCHDOG = 1;
     };
 
     services.postgresql = lib.mkIf cfg.createDatabaseLocally {
@@ -96,12 +97,18 @@ in
         ++ lib.optionals cfg.createDatabaseLocally [ "postgresql.service" "miniflux-dbsetup.service" ];
 
       serviceConfig = {
-        ExecStart = "${cfg.package}/bin/miniflux";
+        Type = "notify";
+        ExecStart = lib.getExe cfg.package;
         User = "miniflux";
         DynamicUser = true;
         RuntimeDirectory = "miniflux";
         RuntimeDirectoryMode = "0750";
         EnvironmentFile = cfg.adminCredentialsFile;
+        WatchdogSec = 60;
+        WatchdogSignal = "SIGKILL";
+        Restart = "always";
+        RestartSec = 5;
+
         # Hardening
         CapabilityBoundingSet = [ "" ];
         DeviceAllow = [ "" ];
diff --git a/nixos/modules/services/web-apps/nextcloud-notify_push.nix b/nixos/modules/services/web-apps/nextcloud-notify_push.nix
index d6d17158a5590..4da5aff0c83e2 100644
--- a/nixos/modules/services/web-apps/nextcloud-notify_push.nix
+++ b/nixos/modules/services/web-apps/nextcloud-notify_push.nix
@@ -90,7 +90,7 @@ in
         export DATABASE_PASSWORD="$(<"${cfg.dbpassFile}")"
       '' + ''
         export DATABASE_URL="${dbUrl}"
-        ${cfg.package}/bin/notify_push '${cfgN.datadir}/config/config.php'
+        exec ${cfg.package}/bin/notify_push '${cfgN.datadir}/config/config.php'
       '';
       serviceConfig = {
         User = "nextcloud";
@@ -98,6 +98,7 @@ in
         RuntimeDirectory = [ "nextcloud-notify_push" ];
         Restart = "on-failure";
         RestartSec = "5s";
+        Type = "notify";
       };
     };
 
diff --git a/nixos/modules/services/web-apps/nextcloud.md b/nixos/modules/services/web-apps/nextcloud.md
index ec860d307b381..0b615deae44be 100644
--- a/nixos/modules/services/web-apps/nextcloud.md
+++ b/nixos/modules/services/web-apps/nextcloud.md
@@ -205,11 +205,6 @@ it complains loudly now. So nothing actionable here by default. Alternatively yo
 * set [](#opt-services.nextcloud.settings.log_type) to "file" to be able to view logs
   from the admin panel.
 
-### Your web server is not properly set up to resolve `.well-known` URLs, failed on: `/.well-known/caldav` {#module-services-nextcloud-warning-wellknown-caldav}
-
-This warning appearing seems to be an upstream issue and is being sorted out
-in [nextcloud/server#45033](https://github.com/nextcloud/server/issues/45033).
-
 ## Maintainer information {#module-services-nextcloud-maintainer-info}
 
 As stated in the previous paragraph, we must provide a clean upgrade-path for Nextcloud
diff --git a/nixos/modules/services/web-apps/nextcloud.nix b/nixos/modules/services/web-apps/nextcloud.nix
index 21f76938f20c5..a4a1f399f4e22 100644
--- a/nixos/modules/services/web-apps/nextcloud.nix
+++ b/nixos/modules/services/web-apps/nextcloud.nix
@@ -80,6 +80,12 @@ let
     mkKeyValue = generators.mkKeyValueDefault {} " = ";
   };
 
+  phpCli = concatStringsSep " " ([
+    "${getExe phpPackage}"
+  ] ++ optionals (cfg.cli.memoryLimit != null) [
+    "-dmemory_limit=${cfg.cli.memoryLimit}"
+  ]);
+
   occ = pkgs.writeScriptBin "nextcloud-occ" ''
     #! ${pkgs.runtimeShell}
     cd ${webroot}
@@ -89,7 +95,7 @@ let
     fi
     export NEXTCLOUD_CONFIG_DIR="${datadir}/config"
     $sudo \
-      ${phpPackage}/bin/php \
+      ${phpCli} \
       occ "$@"
   '';
 
@@ -196,6 +202,9 @@ let
 in {
 
   imports = [
+    (mkRenamedOptionModule
+      [ "services" "nextcloud" "cron" "memoryLimit" ]
+      [ "services" "nextcloud" "cli" "memoryLimit" ])
     (mkRemovedOptionModule [ "services" "nextcloud" "enableBrokenCiphersForSSE" ] ''
       This option has no effect since there's no supported Nextcloud version packaged here
       using OpenSSL for RC4 SSE.
@@ -446,7 +455,13 @@ in {
       dbtableprefix = mkOption {
         type = types.nullOr types.str;
         default = null;
-        description = "Table prefix in Nextcloud's database.";
+        description = ''
+          Table prefix in Nextcloud's database.
+
+          __Note:__ since Nextcloud 20 it's not an option anymore to create a database
+          schema with a custom table prefix. This option only exists for backwards compatibility
+          with installations that were originally provisioned with Nextcloud <20.
+        '';
       };
       adminuser = mkOption {
         type = types.str;
@@ -474,7 +489,7 @@ in {
             implementation into the virtual filesystem.
 
             Further details about this feature can be found in the
-            [upstream documentation](https://docs.nextcloud.com/server/22/admin_manual/configuration_files/primary_storage.html).
+            [upstream documentation](https://docs.nextcloud.com/server/22/admin_manual/configuration_files/primary_storage.html)
           '';
           bucket = mkOption {
             type = types.str;
@@ -576,7 +591,7 @@ in {
         This is used by the theming app and for generating previews of certain images (e.g. SVG and HEIF).
         You may want to disable it for increased security. In that case, previews will still be available
         for some images (e.g. JPEG and PNG).
-        See <https://github.com/nextcloud/server/issues/13099>.
+        See <https://github.com/nextcloud/server/issues/13099>
     '' // {
       default = true;
     };
@@ -642,7 +657,6 @@ in {
       type = types.package;
       default = occ;
       defaultText = literalMD "generated script";
-      internal = true;
       description = ''
         The nextcloud-occ program preconfigured to target this Nextcloud instance.
       '';
@@ -793,11 +807,21 @@ in {
         '';
       };
     };
+
+    cli.memoryLimit = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      example = "1G";
+      description = ''
+        The `memory_limit` of PHP is equal to [](#opt-services.nextcloud.maxUploadSize).
+        The value can be customized for `nextcloud-cron.service` using this option.
+      '';
+    };
   };
 
   config = mkIf cfg.enable (mkMerge [
     { warnings = let
-        latest = 28;
+        latest = 29;
         upgradeWarning = major: nixos:
           ''
             A legacy Nextcloud install (from before NixOS ${nixos}) may be installed.
@@ -816,6 +840,13 @@ in {
           Using config.services.nextcloud.poolConfig is deprecated and will become unsupported in a future release.
           Please migrate your configuration to config.services.nextcloud.poolSettings.
         '')
+        ++ (optional (cfg.config.dbtableprefix != null) ''
+          Using `services.nextcloud.config.dbtableprefix` is deprecated. Fresh installations with this
+          option set are not allowed anymore since v20.
+
+          If you have an existing installation with a custom table prefix, make sure it is
+          set correctly in `config.php` and remove the option from your NixOS config.
+        '')
         ++ (optional (versionOlder cfg.package.version "25") (upgradeWarning 24 "22.11"))
         ++ (optional (versionOlder cfg.package.version "26") (upgradeWarning 25 "23.05"))
         ++ (optional (versionOlder cfg.package.version "27") (upgradeWarning 26 "23.11"))
@@ -939,6 +970,7 @@ in {
 
         in {
           wantedBy = [ "multi-user.target" ];
+          wants = [ "nextcloud-update-db.service" ];
           before = [ "phpfpm-nextcloud.service" ];
           after = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
           requires = optional mysqlLocal "mysql.service" ++ optional pgsqlLocal "postgresql.service";
@@ -997,10 +1029,10 @@ in {
           after = [ "nextcloud-setup.service" ];
           environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
           serviceConfig = {
-            Type = "oneshot";
+            Type = "exec";
             User = "nextcloud";
-            ExecCondition = "${lib.getExe phpPackage} -f ${webroot}/occ status -e";
-            ExecStart = "${lib.getExe phpPackage} -f ${webroot}/cron.php";
+            ExecCondition = "${phpCli} -f ${webroot}/occ status -e";
+            ExecStart = "${phpCli} -f ${webroot}/cron.php";
             KillMode = "process";
           };
         };
@@ -1013,6 +1045,20 @@ in {
           };
           startAt = cfg.autoUpdateApps.startAt;
         };
+        nextcloud-update-db = {
+          after = [ "nextcloud-setup.service" ];
+          environment.NEXTCLOUD_CONFIG_DIR = "${datadir}/config";
+          script = ''
+            ${occ}/bin/nextcloud-occ db:add-missing-columns
+            ${occ}/bin/nextcloud-occ db:add-missing-indices
+            ${occ}/bin/nextcloud-occ db:add-missing-primary-keys
+          '';
+          serviceConfig = {
+            Type = "exec";
+            User = "nextcloud";
+            ExecCondition = "${phpCli} -f ${webroot}/occ status -e";
+          };
+        };
       };
 
       services.phpfpm = {
@@ -1105,10 +1151,10 @@ in {
             extraConfig = ''
               absolute_redirect off;
               location = /.well-known/carddav {
-                return 301 /remote.php/dav;
+                return 301 /remote.php/dav/;
               }
               location = /.well-known/caldav {
-                return 301 /remote.php/dav;
+                return 301 /remote.php/dav/;
               }
               location ~ ^/\.well-known/(?!acme-challenge|pki-validation) {
                 return 301 /index.php$request_uri;
diff --git a/nixos/modules/services/web-apps/nextjs-ollama-llm-ui.nix b/nixos/modules/services/web-apps/nextjs-ollama-llm-ui.nix
new file mode 100644
index 0000000000000..9bd2cf310c0af
--- /dev/null
+++ b/nixos/modules/services/web-apps/nextjs-ollama-llm-ui.nix
@@ -0,0 +1,87 @@
+{
+  config,
+  pkgs,
+  lib,
+  ...
+}:
+let
+  cfg = config.services.nextjs-ollama-llm-ui;
+  # we have to override the URL to a Ollama service here, because it gets baked into the web app.
+  nextjs-ollama-llm-ui = cfg.package.override { inherit (cfg) ollamaUrl; };
+in
+{
+  options = {
+    services.nextjs-ollama-llm-ui = {
+      enable = lib.mkEnableOption ''
+        Simple Ollama web UI service; an easy to use web frontend for a Ollama backend service.
+        Run state-of-the-art AI large language models (LLM) similar to ChatGPT locally with privacy
+        on your personal computer.
+        This service is stateless and doesn't store any data on the server; all data is kept
+        locally in your web browser.
+        See https://github.com/jakobhoeg/nextjs-ollama-llm-ui.
+
+        Required: You need the Ollama backend service running by having
+        "services.nextjs-ollama-llm-ui.ollamaUrl" point to the correct url.
+        You can host such a backend service with NixOS through "services.ollama".
+      '';
+      package = lib.mkPackageOption pkgs "nextjs-ollama-llm-ui" { };
+
+      hostname = lib.mkOption {
+        type = lib.types.str;
+        default = "127.0.0.1";
+        example = "ui.example.org";
+        description = ''
+          The hostname under which the Ollama UI interface should be accessible.
+          By default it uses localhost/127.0.0.1 to be accessible only from the local machine.
+          Change to "0.0.0.0" to make it directly accessible from the local network.
+
+          Note: You should keep it at 127.0.0.1 and only serve to the local
+          network or internet from a (home) server behind a reverse-proxy and secured encryption.
+          See https://wiki.nixos.org/wiki/Nginx for instructions on how to set up a reverse-proxy.
+        '';
+      };
+
+      port = lib.mkOption {
+        type = lib.types.port;
+        default = 3000;
+        example = 3000;
+        description = ''
+          The port under which the Ollama UI interface should be accessible.
+        '';
+      };
+
+      ollamaUrl = lib.mkOption {
+        type = lib.types.str;
+        default = "127.0.0.1:11434";
+        example = "https://ollama.example.org";
+        description = ''
+          The address (including host and port) under which we can access the Ollama backend server.
+          !Note that if the the UI service is running under a domain "https://ui.example.org",
+          the Ollama backend service must allow "CORS" requests from this domain, e.g. by adding
+          "services.ollama.environment.OLLAMA_ORIGINS = [ ... "https://ui.example.org" ];"!
+        '';
+      };
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services = {
+
+      nextjs-ollama-llm-ui = {
+        wantedBy = [ "multi-user.target" ];
+        description = "Nextjs Ollama LLM Ui.";
+        after = [ "network.target" ];
+        environment = {
+          HOSTNAME = cfg.hostname;
+          PORT = toString cfg.port;
+          NEXT_PUBLIC_OLLAMA_URL = cfg.ollamaUrl;
+        };
+        serviceConfig = {
+          ExecStart = "${lib.getExe nextjs-ollama-llm-ui}";
+          DynamicUser = true;
+        };
+      };
+    };
+  };
+  meta.maintainers = with lib.maintainers; [ malteneuss ];
+}
diff --git a/nixos/modules/services/web-apps/node-red.nix b/nixos/modules/services/web-apps/node-red.nix
index 7c8a2a6687b9c..4c095ea79bbde 100644
--- a/nixos/modules/services/web-apps/node-red.nix
+++ b/nixos/modules/services/web-apps/node-red.nix
@@ -5,15 +5,6 @@ with lib;
 let
   cfg = config.services.node-red;
   defaultUser = "node-red";
-  finalPackage = if cfg.withNpmAndGcc then node-red_withNpmAndGcc else cfg.package;
-  node-red_withNpmAndGcc = pkgs.runCommand "node-red" {
-    nativeBuildInputs = [ pkgs.makeWrapper ];
-  }
-  ''
-    mkdir -p $out/bin
-    makeWrapper ${pkgs.nodePackages.node-red}/bin/node-red $out/bin/node-red \
-      --set PATH '${lib.makeBinPath [ pkgs.nodePackages.npm pkgs.gcc ]}:$PATH' \
-  '';
 in
 {
   options.services.node-red = {
@@ -127,11 +118,12 @@ in
       environment = {
         HOME = cfg.userDir;
       };
+      path = lib.optionals cfg.withNpmAndGcc [ pkgs.nodePackages.npm pkgs.gcc ];
       serviceConfig = mkMerge [
         {
           User = cfg.user;
           Group = cfg.group;
-          ExecStart = "${finalPackage}/bin/node-red ${pkgs.lib.optionalString cfg.safe "--safe"} --settings ${cfg.configFile} --port ${toString cfg.port} --userDir ${cfg.userDir} ${concatStringsSep " " (mapAttrsToList (name: value: "-D ${name}=${value}") cfg.define)}";
+          ExecStart = "${cfg.package}/bin/node-red ${pkgs.lib.optionalString cfg.safe "--safe"} --settings ${cfg.configFile} --port ${toString cfg.port} --userDir ${cfg.userDir} ${concatStringsSep " " (mapAttrsToList (name: value: "-D ${name}=${value}") cfg.define)}";
           PrivateTmp = true;
           Restart = "always";
           WorkingDirectory = cfg.userDir;
diff --git a/nixos/modules/services/web-apps/plausible.nix b/nixos/modules/services/web-apps/plausible.nix
index 8e49e591f75cd..1f909bbd67a36 100644
--- a/nixos/modules/services/web-apps/plausible.nix
+++ b/nixos/modules/services/web-apps/plausible.nix
@@ -276,8 +276,11 @@ in {
             ${lib.optionalString (cfg.mail.smtp.passwordFile != null)
               ''export SMTP_USER_PWD="$(< $CREDENTIALS_DIRECTORY/SMTP_USER_PWD )"''}
 
-            # setup
-            ${cfg.package}/createdb.sh
+            ${lib.optionalString cfg.database.postgres.setup ''
+              # setup
+              ${cfg.package}/createdb.sh
+            ''}
+
             ${cfg.package}/migrate.sh
             export IP_GEOLOCATION_DB=${pkgs.dbip-country-lite}/share/dbip/dbip-country-lite.mmdb
             ${cfg.package}/bin/plausible eval "(Plausible.Release.prepare() ; Plausible.Auth.create_user(\"$ADMIN_USER_NAME\", \"$ADMIN_USER_EMAIL\", \"$ADMIN_USER_PWD\"))"
@@ -326,6 +329,6 @@ in {
     ];
   };
 
-  meta.maintainers = with maintainers; [ ];
+  meta.maintainers = with maintainers; [ xanderio ];
   meta.doc = ./plausible.md;
 }
diff --git a/nixos/modules/services/web-apps/pretalx.nix b/nixos/modules/services/web-apps/pretalx.nix
index b062a8b7eeeac..1411d11982c87 100644
--- a/nixos/modules/services/web-apps/pretalx.nix
+++ b/nixos/modules/services/web-apps/pretalx.nix
@@ -11,20 +11,25 @@ let
 
   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;
+  finalPackage = cfg.package.override {
+    inherit (cfg) plugins;
+  };
+
+  pythonEnv = finalPackage.python.buildEnv.override {
+    extraLibs = with finalPackage.python.pkgs; [
+      (toPythonModule finalPackage)
+      gunicorn
+    ]
+    ++ finalPackage.optional-dependencies.redis
+    ++ lib.optionals cfg.celery.enable [ celery ]
+    ++ lib.optionals (cfg.settings.database.backend == "mysql") finalPackage.optional-dependencies.mysql
+    ++ lib.optionals (cfg.settings.database.backend == "postgresql") finalPackage.optional-dependencies.postgres;
   };
 in
 
 {
   meta = with lib; {
-    maintainers = teams.c3d2.members;
+    maintainers = with maintainers; [ hexa] ++ teams.c3d2.members;
   };
 
   options.services.pretalx = {
@@ -44,6 +49,20 @@ in
       description = "User under which pretalx should run.";
     };
 
+    plugins = lib.mkOption {
+      type = with lib.types; listOf package;
+      default = [];
+      example = lib.literalExpression ''
+        with config.services.pretalx.package.plugins; [
+          pages
+          youtube
+        ];
+      '';
+      description = ''
+        Pretalx plugins to install into the Python environment.
+      '';
+    };
+
     gunicorn.extraArgs = lib.mkOption {
       type = with lib.types; listOf str;
       default = [
@@ -329,10 +348,47 @@ in
         serviceConfig = {
           User = "pretalx";
           Group = "pretalx";
-          StateDirectory = [ "pretalx" "pretalx/media" ];
+          StateDirectory = [
+            "pretalx"
+            "pretalx/media"
+          ];
+          StateDirectoryMode = "0750";
           LogsDirectory = "pretalx";
           WorkingDirectory = cfg.settings.filesystem.data;
           SupplementaryGroups = [ "redis-pretalx" ];
+          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 = "0027";
         };
       };
     in {
@@ -395,6 +451,8 @@ in
         wantedBy = [ "multi-user.target" ];
         serviceConfig.ExecStart = "${lib.getExe' pythonEnv "celery"} -A pretalx.celery_app worker ${cfg.celery.extraArgs}";
       });
+
+      nginx.serviceConfig.SupplementaryGroups = lib.mkIf cfg.nginx.enable [ "pretalx" ];
     };
 
     systemd.sockets.pretalx-web.socketConfig = {
@@ -403,11 +461,9 @@ in
     };
 
     users = {
-      groups."${cfg.group}" = {};
-      users."${cfg.user}" = {
+      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
index 22ee9769aa923..0fb635964fe65 100644
--- a/nixos/modules/services/web-apps/pretix.nix
+++ b/nixos/modules/services/web-apps/pretix.nix
@@ -63,7 +63,7 @@ in
   };
 
   options.services.pretix = {
-    enable = mkEnableOption "Pretix, a ticket shop application for conferences, festivals, concerts, etc.";
+    enable = mkEnableOption "Pretix, a ticket shop application for conferences, festivals, concerts, etc";
 
     package = mkPackageOption pkgs "pretix" { };
 
@@ -310,7 +310,7 @@ in
               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"
+                redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=1
               '';
               description = ''
                 URI to the celery backend used for the asynchronous job queue.
@@ -321,7 +321,7 @@ in
               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"
+                redis+socket://''${config.services.redis.servers.pretix.unixSocket}?virtual_host=2
               '';
               description = ''
                 URI to the celery broker used for the asynchronous job queue.
@@ -468,7 +468,7 @@ in
           StateDirectory = [
             "pretix"
           ];
-          StateDirectoryMode = "0755";
+          StateDirectoryMode = "0750";
           CacheDirectory = "pretix";
           LogsDirectory = "pretix";
           WorkingDirectory = cfg.settings.pretix.datadir;
@@ -507,7 +507,7 @@ in
             "~@privileged"
             "@chown"
           ];
-          UMask = "0022";
+          UMask = "0027";
         };
       };
     in {
@@ -561,6 +561,8 @@ in
         wantedBy = [ "multi-user.target" ];
         serviceConfig.ExecStart = "${getExe' pythonEnv "celery"} -A pretix.celery_app worker ${cfg.celery.extraArgs}";
       };
+
+      nginx.serviceConfig.SupplementaryGroups = mkIf cfg.nginx.enable [ "pretix" ];
     };
 
     systemd.sockets.pretix-web.socketConfig = {
@@ -569,11 +571,9 @@ in
     };
 
     users = {
-      groups."${cfg.group}" = {};
-      users."${cfg.user}" = {
+      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/silverbullet.nix b/nixos/modules/services/web-apps/silverbullet.nix
index c316d074cbaab..5d5f950a9a661 100644
--- a/nixos/modules/services/web-apps/silverbullet.nix
+++ b/nixos/modules/services/web-apps/silverbullet.nix
@@ -12,7 +12,7 @@ in
 {
   options = {
     services.silverbullet = {
-      enable = lib.mkEnableOption "Silverbullet, an open-source, self-hosted, offline-capable Personal Knowledge Management (PKM) web application.";
+      enable = lib.mkEnableOption "Silverbullet, an open-source, self-hosted, offline-capable Personal Knowledge Management (PKM) web application";
 
       package = lib.mkPackageOptionMD pkgs "silverbullet" { };
 
diff --git a/nixos/modules/services/web-apps/simplesamlphp.nix b/nixos/modules/services/web-apps/simplesamlphp.nix
new file mode 100644
index 0000000000000..e970266fc17dd
--- /dev/null
+++ b/nixos/modules/services/web-apps/simplesamlphp.nix
@@ -0,0 +1,128 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+let
+  cfg = config.services.simplesamlphp;
+
+  format = pkgs.formats.php { finalVariable = "config"; };
+
+  generateConfig =
+    opts:
+    pkgs.runCommand "simplesamlphp-config" { } ''
+      mkdir $out
+      cp ${format.generate "config.php" opts.settings} $out/config.php
+      cp ${format.generate "authsources.php" opts.authSources} $out/authsources.php
+    '';
+in
+{
+  meta = {
+    maintainers = with lib.maintainers; [ nhnn ];
+  };
+
+  options.services.simplesamlphp =
+    with lib;
+    mkOption {
+      type = types.attrsOf (
+        types.submodule (
+          { config, ... }:
+          {
+            options = {
+              package = mkPackageOption pkgs "simplesamlphp" { };
+              configureNginx = mkOption {
+                type = types.bool;
+                default = true;
+                description = "Configure nginx as a reverse proxy for SimpleSAMLphp.";
+              };
+              phpfpmPool = mkOption {
+                type = types.str;
+                description = "The PHP-FPM pool that serves SimpleSAMLphp instance.";
+              };
+              localDomain = mkOption {
+                type = types.str;
+                description = "The domain serving your SimpleSAMLphp instance. This option modifies only /saml route.";
+              };
+              settings = mkOption {
+                type = types.submodule {
+                  freeformType = format.type;
+                  options = {
+                    baseurlpath = mkOption {
+                      type = types.str;
+                      example = "https://filesender.example.com/saml/";
+                      description = "URL where SimpleSAMLphp can be reached.";
+                    };
+                  };
+                };
+                default = { };
+                description = ''
+                  Configuration options used by SimpleSAMLphp.
+                  See [](https://simplesamlphp.org/docs/stable/simplesamlphp-install)
+                  for available options.
+                '';
+              };
+
+              authSources = mkOption {
+                type = format.type;
+                default = { };
+                description = ''
+                  Auth sources options used by SimpleSAMLphp.
+                '';
+              };
+
+              libDir = mkOption {
+                type = types.str;
+                readOnly = true;
+                description = ''
+                  Path to the SimpleSAMLphp library directory.
+                '';
+              };
+              configDir = mkOption {
+                type = types.str;
+                readOnly = true;
+                description = ''
+                  Path to the SimpleSAMLphp config directory.
+                '';
+              };
+            };
+            config = {
+              libDir = "${config.package}/share/php/simplesamlphp/";
+              configDir = "${generateConfig config}";
+            };
+          }
+        )
+      );
+      default = { };
+      description = "Instances of SimpleSAMLphp. This module is designed to work with already existing PHP-FPM pool and NGINX virtualHost.";
+    };
+
+  config = {
+    services.phpfpm.pools = lib.mapAttrs' (
+      phpfpmName: opts:
+      lib.nameValuePair opts.phpfpmPool { phpEnv.SIMPLESAMLPHP_CONFIG_DIR = "${generateConfig opts}"; }
+    ) cfg;
+
+    services.nginx.virtualHosts = lib.mapAttrs' (
+      phpfpmName: opts:
+      lib.nameValuePair opts.localDomain (
+        lib.mkIf opts.configureNginx {
+          locations."^~ /saml/" = {
+            alias = "${opts.package}/share/php/simplesamlphp/www/";
+            extraConfig = ''
+                location ~ ^(?<prefix>/saml)(?<phpfile>.+?\.php)(?<pathinfo>/.*)?$ {
+                  include ${pkgs.nginx}/conf/fastcgi.conf;
+                  fastcgi_split_path_info  ^(.+\.php)(/.+)$;
+                  fastcgi_pass  unix:${config.services.phpfpm.pools.${phpfpmName}.socket};
+                  fastcgi_intercept_errors on;
+                  fastcgi_param SCRIPT_FILENAME $document_root$phpfile;
+                  fastcgi_param SCRIPT_NAME /saml$phpfile;
+                  fastcgi_param PATH_INFO $pathinfo if_not_empty;
+              }
+            '';
+          };
+        }
+      )
+    ) cfg;
+  };
+}
diff --git a/nixos/modules/services/web-apps/slskd.nix b/nixos/modules/services/web-apps/slskd.nix
index 15a5fd1177adf..6254fe294eeed 100644
--- a/nixos/modules/services/web-apps/slskd.nix
+++ b/nixos/modules/services/web-apps/slskd.nix
@@ -5,7 +5,7 @@ let
   defaultUser = "slskd";
 in {
   options.services.slskd = with lib; with types; {
-    enable = mkEnableOption "enable slskd";
+    enable = mkEnableOption "slskd";
 
     package = mkPackageOptionMD pkgs "slskd" { };
 
diff --git a/nixos/modules/services/web-apps/suwayomi-server.nix b/nixos/modules/services/web-apps/suwayomi-server.nix
index 5b61852a534dc..ba2352d0e693f 100644
--- a/nixos/modules/services/web-apps/suwayomi-server.nix
+++ b/nixos/modules/services/web-apps/suwayomi-server.nix
@@ -9,7 +9,7 @@ in
 {
   options = {
     services.suwayomi-server = {
-      enable = mkEnableOption "Suwayomi, a free and open source manga reader server that runs extensions built for Tachiyomi.";
+      enable = mkEnableOption "Suwayomi, a free and open source manga reader server that runs extensions built for Tachiyomi";
 
       package = lib.mkPackageOptionMD pkgs "suwayomi-server" { };
 
@@ -72,7 +72,7 @@ in
               };
 
               basicAuthEnabled = mkEnableOption ''
-                Add basic access authentication to Suwayomi-Server.
+                basic access authentication for Suwayomi-Server.
                 Enabling this option is useful when hosting on a public network/the Internet
               '';
 
diff --git a/nixos/modules/services/web-apps/your_spotify.nix b/nixos/modules/services/web-apps/your_spotify.nix
new file mode 100644
index 0000000000000..3eb2ffef4f933
--- /dev/null
+++ b/nixos/modules/services/web-apps/your_spotify.nix
@@ -0,0 +1,191 @@
+{
+  pkgs,
+  config,
+  lib,
+  ...
+}: let
+  inherit
+    (lib)
+    boolToString
+    concatMapAttrs
+    concatStrings
+    isBool
+    mapAttrsToList
+    mkEnableOption
+    mkIf
+    mkOption
+    mkPackageOption
+    optionalAttrs
+    types
+    mkDefault
+    ;
+  cfg = config.services.your_spotify;
+
+  configEnv = concatMapAttrs (name: value:
+    optionalAttrs (value != null) {
+      ${name} =
+        if isBool value
+        then boolToString value
+        else toString value;
+    })
+  cfg.settings;
+
+  configFile = pkgs.writeText "your_spotify.env" (concatStrings (mapAttrsToList (name: value: "${name}=${value}\n") configEnv));
+in {
+  options.services.your_spotify = let
+    inherit (types) nullOr port str path package;
+  in {
+    enable = mkEnableOption "your_spotify";
+
+    enableLocalDB = mkEnableOption "a local mongodb instance";
+    nginxVirtualHost = mkOption {
+      type = nullOr str;
+      default = null;
+      description = ''
+        If set creates an nginx virtual host for the client.
+        In most cases this should be the CLIENT_ENDPOINT without
+        protocol prefix.
+      '';
+    };
+
+    package = mkPackageOption pkgs "your_spotify" {};
+
+    clientPackage = mkOption {
+      type = package;
+      description = "Client package to use.";
+    };
+
+    spotifySecretFile = mkOption {
+      type = path;
+      description = ''
+        A file containing the secret key of your Spotify application.
+        Refer to: [Creating the Spotify Application](https://github.com/Yooooomi/your_spotify#creating-the-spotify-application).
+      '';
+    };
+
+    settings = mkOption {
+      description = ''
+        Your Spotify Configuration. Refer to [Your Spotify](https://github.com/Yooooomi/your_spotify) for definitions and values.
+      '';
+      example = lib.literalExpression ''
+        {
+          CLIENT_ENDPOINT = "https://example.com";
+          API_ENDPOINT = "https://api.example.com";
+          SPOTIFY_PUBLIC = "spotify_client_id";
+        }
+      '';
+      type = types.submodule {
+        freeformType = types.attrsOf types.str;
+        options = {
+          CLIENT_ENDPOINT = mkOption {
+            type = str;
+            description = ''
+              The endpoint of your web application.
+              Has to include a protocol Prefix (e.g. `http://`)
+            '';
+            example = "https://your_spotify.example.org";
+          };
+          API_ENDPOINT = mkOption {
+            type = str;
+            description = ''
+              The endpoint of your server
+              This api has to be reachable from the device you use the website from not from the server.
+              This means that for example you may need two nginx virtual hosts if you want to expose this on the
+              internet.
+              Has to include a protocol Prefix (e.g. `http://`)
+            '';
+            example = "https://localhost:3000";
+          };
+          SPOTIFY_PUBLIC = mkOption {
+            type = str;
+            description = ''
+              The public client ID of your Spotify application.
+              Refer to: [Creating the Spotify Application](https://github.com/Yooooomi/your_spotify#creating-the-spotify-application)
+            '';
+          };
+          MONGO_ENDPOINT = mkOption {
+            type = str;
+            description = ''The endpoint of the Mongo database.'';
+            default = "mongodb://localhost:27017/your_spotify";
+          };
+          PORT = mkOption {
+            type = port;
+            description = "The port of the api server";
+            default = 3000;
+          };
+        };
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.your_spotify.clientPackage = mkDefault (cfg.package.client.override {apiEndpoint = cfg.settings.API_ENDPOINT;});
+    systemd.services.your_spotify = {
+      after = ["network.target"];
+      script = ''
+        export SPOTIFY_SECRET=$(< "$CREDENTIALS_DIRECTORY/SPOTIFY_SECRET")
+        ${lib.getExe' cfg.package "your_spotify_migrate"}
+        exec ${lib.getExe cfg.package}
+      '';
+      serviceConfig = {
+        User = "your_spotify";
+        Group = "your_spotify";
+        DynamicUser = true;
+        EnvironmentFile = [configFile];
+        StateDirectory = "your_spotify";
+        LimitNOFILE = "1048576";
+        PrivateTmp = true;
+        PrivateDevices = true;
+        StateDirectoryMode = "0700";
+        Restart = "always";
+
+        LoadCredential = ["SPOTIFY_SECRET:${cfg.spotifySecretFile}"];
+
+        # Hardening
+        CapabilityBoundingSet = "";
+        LockPersonality = true;
+        #MemoryDenyWriteExecute = true; # Leads to coredump because V8 does JIT
+        PrivateUsers = true;
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = true;
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "invisible";
+        ProcSubset = "pid";
+        ProtectSystem = "strict";
+        RestrictAddressFamilies = [
+          "AF_INET"
+          "AF_INET6"
+          "AF_NETLINK"
+        ];
+        RestrictNamespaces = true;
+        RestrictRealtime = true;
+        SystemCallArchitectures = "native";
+        SystemCallFilter = [
+          "@system-service"
+          "@pkey"
+        ];
+        UMask = "0077";
+      };
+      wantedBy = ["multi-user.target"];
+    };
+    services.nginx = mkIf (cfg.nginxVirtualHost != null) {
+      enable = true;
+      virtualHosts.${cfg.nginxVirtualHost} = {
+        root = cfg.clientPackage;
+        locations."/".extraConfig = ''
+          add_header Content-Security-Policy "frame-ancestors 'none';" ;
+          add_header X-Content-Type-Options "nosniff" ;
+          try_files = $uri $uri/ /index.html ;
+        '';
+      };
+    };
+    services.mongodb = mkIf cfg.enableLocalDB {
+      enable = true;
+    };
+  };
+  meta.maintainers = with lib.maintainers; [patrickdag];
+}
diff --git a/nixos/modules/services/web-apps/zitadel.nix b/nixos/modules/services/web-apps/zitadel.nix
index 99b0a0bc56f67..ed7fae8d9dda0 100644
--- a/nixos/modules/services/web-apps/zitadel.nix
+++ b/nixos/modules/services/web-apps/zitadel.nix
@@ -219,5 +219,5 @@ in
     users.groups.zitadel = lib.mkIf (cfg.group == "zitadel") { };
   };
 
-  meta.maintainers = with lib.maintainers; [ Sorixelle ];
+  meta.maintainers = [ ];
 }