summary refs log tree commit diff
path: root/nixos/modules/services
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules/services')
-rw-r--r--nixos/modules/services/backup/borgbackup.nix41
-rw-r--r--nixos/modules/services/backup/tsm.nix47
-rw-r--r--nixos/modules/services/development/rstudio-server/default.nix107
-rw-r--r--nixos/modules/services/misc/ananicy.nix2
-rw-r--r--nixos/modules/services/misc/bees.nix3
-rw-r--r--nixos/modules/services/misc/heisenbridge.nix38
-rw-r--r--nixos/modules/services/misc/mbpfan.nix112
-rw-r--r--nixos/modules/services/networking/adguardhome.nix18
-rw-r--r--nixos/modules/services/networking/ddclient.nix4
-rw-r--r--nixos/modules/services/networking/mosquitto.nix2
-rw-r--r--nixos/modules/services/networking/nix-serve.nix10
-rw-r--r--nixos/modules/services/networking/ssh/sshd.nix7
-rw-r--r--nixos/modules/services/networking/syncthing.nix4
-rw-r--r--nixos/modules/services/system/cachix-agent/default.nix57
-rw-r--r--nixos/modules/services/web-apps/bookstack.nix202
-rw-r--r--nixos/modules/services/web-apps/keycloak.nix875
-rw-r--r--nixos/modules/services/web-apps/keycloak.xml18
-rw-r--r--nixos/modules/services/web-apps/mattermost.nix2
-rw-r--r--nixos/modules/services/web-apps/prosody-filer.nix10
19 files changed, 990 insertions, 569 deletions
diff --git a/nixos/modules/services/backup/borgbackup.nix b/nixos/modules/services/backup/borgbackup.nix
index 220c571b927..2c307a701f3 100644
--- a/nixos/modules/services/backup/borgbackup.nix
+++ b/nixos/modules/services/backup/borgbackup.nix
@@ -30,7 +30,7 @@ let
     }
     trap 'on_exit' INT TERM QUIT EXIT
 
-    archiveName="${cfg.archiveBaseName}-$(date ${cfg.dateFormat})"
+    archiveName="${if cfg.archiveBaseName == null then "" else cfg.archiveBaseName + "-"}$(date ${cfg.dateFormat})"
     archiveSuffix="${optionalString cfg.appendFailedSuffix ".failed"}"
     ${cfg.preHook}
   '' + optionalString cfg.doInit ''
@@ -60,7 +60,7 @@ let
   '' + optionalString (cfg.prune.keep != { }) ''
     borg prune $extraArgs \
       ${mkKeepArgs cfg} \
-      --prefix ${escapeShellArg cfg.prune.prefix} \
+      ${optionalString (cfg.prune.prefix != null) "--prefix ${escapeShellArg cfg.prune.prefix} \\"}
       $extraPruneArgs
     ${cfg.postPrune}
   '';
@@ -99,7 +99,18 @@ let
         BORG_REPO = cfg.repo;
         inherit (cfg) extraArgs extraInitArgs extraCreateArgs extraPruneArgs;
       } // (mkPassEnv cfg) // cfg.environment;
-      inherit (cfg) startAt;
+    };
+
+  mkBackupTimers = name: cfg:
+    nameValuePair "borgbackup-job-${name}" {
+      description = "BorgBackup job ${name} timer";
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        Persistent = cfg.persistentTimer;
+        OnCalendar = cfg.startAt;
+      };
+      # if remote-backup wait for network
+      after = optional (cfg.persistentTimer && !isLocalPath cfg.repo) "network-online.target";
     };
 
   # utility function around makeWrapper
@@ -284,7 +295,7 @@ in {
           };
 
           archiveBaseName = mkOption {
-            type = types.strMatching "[^/{}]+";
+            type = types.nullOr (types.strMatching "[^/{}]+");
             default = "${globalConfig.networking.hostName}-${name}";
             defaultText = literalExpression ''"''${config.networking.hostName}-<name>"'';
             description = ''
@@ -292,6 +303,7 @@ in {
               determined by <option>dateFormat</option>, will be appended. The full
               name can be modified at runtime (<literal>$archiveName</literal>).
               Placeholders like <literal>{hostname}</literal> must not be used.
+              Use <literal>null</literal> for no base name.
             '';
           };
 
@@ -320,6 +332,19 @@ in {
             '';
           };
 
+          persistentTimer = mkOption {
+            default = false;
+            type = types.bool;
+            example = true;
+            description = literalDocBook ''
+              Set the <literal>persistentTimer</literal> option for the
+              <citerefentry><refentrytitle>systemd.timer</refentrytitle>
+              <manvolnum>5</manvolnum></citerefentry>
+              which triggers the backup immediately if the last trigger
+              was missed (e.g. if the system was powered down).
+            '';
+          };
+
           user = mkOption {
             type = types.str;
             description = ''
@@ -471,11 +496,11 @@ in {
           };
 
           prune.prefix = mkOption {
-            type = types.str;
+            type = types.nullOr (types.str);
             description = ''
               Only consider archive names starting with this prefix for pruning.
               By default, only archives created by this job are considered.
-              Use <literal>""</literal> to consider all archives.
+              Use <literal>""</literal> or <literal>null</literal> to consider all archives.
             '';
             default = config.archiveBaseName;
             defaultText = literalExpression "archiveBaseName";
@@ -694,6 +719,10 @@ in {
         # A repo named "foo" is mapped to systemd.services.borgbackup-repo-foo
         // mapAttrs' mkRepoService repos;
 
+      # A job named "foo" is mapped to systemd.timers.borgbackup-job-foo
+      # only generate the timer if interval (startAt) is set
+      systemd.timers = mapAttrs' mkBackupTimers (filterAttrs (_: cfg: cfg.startAt != []) jobs);
+
       users = mkMerge (mapAttrsToList mkUsersConfig repos);
 
       environment.systemPackages = with pkgs; [ borgbackup ] ++ (mapAttrsToList mkBorgWrapper jobs);
diff --git a/nixos/modules/services/backup/tsm.nix b/nixos/modules/services/backup/tsm.nix
index 6c238745797..4e690ac6ecd 100644
--- a/nixos/modules/services/backup/tsm.nix
+++ b/nixos/modules/services/backup/tsm.nix
@@ -5,7 +5,7 @@ let
   inherit (lib.attrsets) hasAttr;
   inherit (lib.modules) mkDefault mkIf;
   inherit (lib.options) mkEnableOption mkOption;
-  inherit (lib.types) nullOr strMatching;
+  inherit (lib.types) nonEmptyStr nullOr;
 
   options.services.tsmBackup = {
     enable = mkEnableOption ''
@@ -15,7 +15,7 @@ let
       <option>programs.tsmClient.enable</option>
     '';
     command = mkOption {
-      type = strMatching ".+";
+      type = nonEmptyStr;
       default = "backup";
       example = "incr";
       description = ''
@@ -24,7 +24,7 @@ let
       '';
     };
     servername = mkOption {
-      type = strMatching ".+";
+      type = nonEmptyStr;
       example = "mainTsmServer";
       description = ''
         Create a systemd system service
@@ -41,7 +41,7 @@ let
       '';
     };
     autoTime = mkOption {
-      type = nullOr (strMatching ".+");
+      type = nullOr nonEmptyStr;
       default = null;
       example = "12:00";
       description = ''
@@ -87,16 +87,35 @@ in
       environment.DSM_LOG = "/var/log/tsm-backup/";
       # TSM needs a HOME dir to store certificates.
       environment.HOME = "/var/lib/tsm-backup";
-      # for exit status description see
-      # https://www.ibm.com/support/knowledgecenter/en/SSEQVQ_8.1.8/client/c_sched_rtncode.html
-      serviceConfig.SuccessExitStatus = "4 8";
-      # The `-se` option must come after the command.
-      # The `-optfile` option suppresses a `dsm.opt`-not-found warning.
-      serviceConfig.ExecStart =
-        "${cfgPrg.wrappedPackage}/bin/dsmc ${cfg.command} -se='${cfg.servername}' -optfile=/dev/null";
-      serviceConfig.LogsDirectory = "tsm-backup";
-      serviceConfig.StateDirectory = "tsm-backup";
-      serviceConfig.StateDirectoryMode = "0750";
+      serviceConfig = {
+        # for exit status description see
+        # https://www.ibm.com/docs/en/spectrum-protect/8.1.13?topic=clients-client-return-codes
+        SuccessExitStatus = "4 8";
+        # The `-se` option must come after the command.
+        # The `-optfile` option suppresses a `dsm.opt`-not-found warning.
+        ExecStart =
+          "${cfgPrg.wrappedPackage}/bin/dsmc ${cfg.command} -se='${cfg.servername}' -optfile=/dev/null";
+        LogsDirectory = "tsm-backup";
+        StateDirectory = "tsm-backup";
+        StateDirectoryMode = "0750";
+        # systemd sandboxing
+        LockPersonality = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        #PrivateTmp = true;  # would break backup of {/var,}/tmp
+        #PrivateUsers = true;  # would block backup of /home/*
+        ProtectClock = true;
+        ProtectControlGroups = true;
+        ProtectHome = "read-only";
+        ProtectHostname = true;
+        ProtectKernelLogs = true;
+        ProtectKernelModules = true;
+        ProtectKernelTunables = true;
+        ProtectProc = "noaccess";
+        ProtectSystem = "strict";
+        RestrictNamespaces = true;
+        RestrictSUIDSGID = true;
+      };
       startAt = mkIf (cfg.autoTime!=null) cfg.autoTime;
     };
   };
diff --git a/nixos/modules/services/development/rstudio-server/default.nix b/nixos/modules/services/development/rstudio-server/default.nix
new file mode 100644
index 00000000000..cd903c7e55b
--- /dev/null
+++ b/nixos/modules/services/development/rstudio-server/default.nix
@@ -0,0 +1,107 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.rstudio-server;
+
+  rserver-conf = builtins.toFile "rserver.conf" ''
+    server-working-dir=${cfg.serverWorkingDir}
+    www-address=${cfg.listenAddr}
+    ${cfg.rserverExtraConfig}
+  '';
+
+  rsession-conf = builtins.toFile "rsession.conf" ''
+    ${cfg.rsessionExtraConfig}
+  '';
+
+in
+{
+  meta.maintainers = with maintainers; [ jbedo cfhammill ];
+
+  options.services.rstudio-server = {
+    enable = mkEnableOption "RStudio server";
+
+    serverWorkingDir = mkOption {
+      type = types.str;
+      default = "/var/lib/rstudio-server";
+      description = ''
+        Default working directory for server (server-working-dir in rserver.conf).
+      '';
+    };
+
+    listenAddr = mkOption {
+      type = types.str;
+      default = "127.0.0.1";
+      description = ''
+        Address to listen on (www-address in rserver.conf).
+      '';
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.rstudio-server;
+      defaultText = literalExpression "pkgs.rstudio-server";
+      example = literalExpression "pkgs.rstudioServerWrapper.override { packages = [ pkgs.rPackages.ggplot2 ]; }";
+      description = ''
+        Rstudio server package to use. Can be set to rstudioServerWrapper to provide packages.
+      '';
+    };
+
+    rserverExtraConfig = mkOption {
+      type = types.str;
+      default = "";
+      description = ''
+        Extra contents for rserver.conf.
+      '';
+    };
+
+    rsessionExtraConfig = mkOption {
+      type = types.str;
+      default = "";
+      description = ''
+        Extra contents for resssion.conf.
+      '';
+    };
+
+  };
+
+  config = mkIf cfg.enable
+    {
+      systemd.services.rstudio-server = {
+        description = "Rstudio server";
+
+        after = [ "network.target" ];
+        wantedBy = [ "multi-user.target" ];
+        restartTriggers = [ rserver-conf rsession-conf ];
+
+        serviceConfig = {
+          Restart = "on-failure";
+          Type = "forking";
+          ExecStart = "${cfg.package}/bin/rserver";
+          StateDirectory = "rstudio-server";
+          RuntimeDirectory = "rstudio-server";
+        };
+      };
+
+      environment.etc = {
+        "rstudio/rserver.conf".source = rserver-conf;
+        "rstudio/rsession.conf".source = rsession-conf;
+        "pam.d/rstudio".source = "/etc/pam.d/login";
+      };
+      environment.systemPackages = [ cfg.package ];
+
+      users = {
+        users.rstudio-server = {
+          uid = config.ids.uids.rstudio-server;
+          description = "rstudio-server";
+          group = "rstudio-server";
+        };
+        groups.rstudio-server = {
+          gid = config.ids.gids.rstudio-server;
+        };
+      };
+
+    };
+}
diff --git a/nixos/modules/services/misc/ananicy.nix b/nixos/modules/services/misc/ananicy.nix
index f76f534fb45..191666bc362 100644
--- a/nixos/modules/services/misc/ananicy.nix
+++ b/nixos/modules/services/misc/ananicy.nix
@@ -84,7 +84,7 @@ in
       } // (if ((lib.getName cfg.package) == (lib.getName pkgs.ananicy-cpp)) then {
         # https://gitlab.com/ananicy-cpp/ananicy-cpp/-/blob/master/src/config.cpp#L12
         loglevel = mkOD "warn"; # default is info but its spammy
-        cgroup_realtime_workaround = mkOD true;
+        cgroup_realtime_workaround = mkOD config.systemd.enableUnifiedCgroupHierarchy;
       } else {
         # https://github.com/Nefelim4ag/Ananicy/blob/master/ananicy.d/ananicy.conf
         check_disks_schedulers = mkOD true;
diff --git a/nixos/modules/services/misc/bees.nix b/nixos/modules/services/misc/bees.nix
index cb97a86b859..fa00d7e4f55 100644
--- a/nixos/modules/services/misc/bees.nix
+++ b/nixos/modules/services/misc/bees.nix
@@ -21,6 +21,8 @@ let
         <para>
         This must be in a format usable by findmnt; that could be a key=value
         pair, or a bare path to a mount point.
+        Using bare paths will allow systemd to start the beesd service only
+        after mounting the associated path.
       '';
       example = "LABEL=MyBulkDataDrive";
     };
@@ -122,6 +124,7 @@ in
             StartupIOWeight = 25;
             SyslogIdentifier = "beesd"; # would otherwise be "bees-service-wrapper"
           };
+        unitConfig.RequiresMountsFor = lib.mkIf (lib.hasPrefix "/" fs.spec) fs.spec;
         wantedBy = [ "multi-user.target" ];
       })
       cfg.filesystems;
diff --git a/nixos/modules/services/misc/heisenbridge.nix b/nixos/modules/services/misc/heisenbridge.nix
index 353a2781d28..7ce8a23d9af 100644
--- a/nixos/modules/services/misc/heisenbridge.nix
+++ b/nixos/modules/services/misc/heisenbridge.nix
@@ -23,7 +23,7 @@ let
 in
 {
   options.services.heisenbridge = {
-    enable = mkEnableOption "A bouncer-style Matrix IRC bridge";
+    enable = mkEnableOption "the Matrix to IRC bridge";
 
     package = mkOption {
       type = types.package;
@@ -172,25 +172,39 @@ in
           ++ (map (lib.escapeShellArg) cfg.extraArgs)
         );
 
-        ProtectHome = true;
-        PrivateDevices = true;
-        ProtectKernelTunables = true;
-        ProtectKernelModules = true;
-        ProtectControlGroups = true;
-        StateDirectory = "heisenbridge";
-        StateDirectoryMode = "755";
+        # Hardening options
 
         User = "heisenbridge";
         Group = "heisenbridge";
+        RuntimeDirectory = "heisenbridge";
+        RuntimeDirectoryMode = "0700";
+        StateDirectory = "heisenbridge";
+        StateDirectoryMode = "0755";
 
-        CapabilityBoundingSet = [ "CAP_CHOWN" ] ++ optional (cfg.port < 1024 || cfg.identd.port < 1024) "CAP_NET_BIND_SERVICE";
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        PrivateDevices = true;
+        ProtectKernelTunables = true;
+        ProtectControlGroups = true;
+        RestrictSUIDSGID = true;
+        PrivateMounts = true;
+        ProtectKernelModules = true;
+        ProtectKernelLogs = true;
+        ProtectHostname = true;
+        ProtectClock = true;
+        ProtectProc = "invisible";
+        ProcSubset = "pid";
+        RestrictNamespaces = true;
+        RemoveIPC = true;
+        UMask = "0077";
+
+        CapabilityBoundingSet = [ "CAP_CHOWN" ] ++ optional (cfg.port < 1024 || (cfg.identd.enable && cfg.identd.port < 1024)) "CAP_NET_BIND_SERVICE";
         AmbientCapabilities = CapabilityBoundingSet;
         NoNewPrivileges = true;
-
         LockPersonality = true;
         RestrictRealtime = true;
-        PrivateMounts = true;
-        SystemCallFilter = "~@aio @clock @cpu-emulation @debug @keyring @memlock @module @mount @obsolete @raw-io @setuid @swap";
+        SystemCallFilter = ["@system-service" "~@priviledged" "@chown"];
         SystemCallArchitectures = "native";
         RestrictAddressFamilies = "AF_INET AF_INET6";
       };
diff --git a/nixos/modules/services/misc/mbpfan.nix b/nixos/modules/services/misc/mbpfan.nix
index d80b6fafc2c..d2b0f0da2ad 100644
--- a/nixos/modules/services/misc/mbpfan.nix
+++ b/nixos/modules/services/misc/mbpfan.nix
@@ -5,6 +5,8 @@ with lib;
 let
   cfg = config.services.mbpfan;
   verbose = if cfg.verbose then "v" else "";
+  settingsFormat = pkgs.formats.ini {};
+  settingsFile = settingsFormat.generate "config.conf" cfg.settings;
 
 in {
   options.services.mbpfan = {
@@ -19,54 +21,6 @@ in {
       '';
     };
 
-    minFanSpeed = mkOption {
-      type = types.int;
-      default = 2000;
-      description = ''
-        The minimum fan speed.
-      '';
-    };
-
-    maxFanSpeed = mkOption {
-      type = types.int;
-      default = 6200;
-      description = ''
-        The maximum fan speed.
-      '';
-    };
-
-    lowTemp = mkOption {
-      type = types.int;
-      default = 63;
-      description = ''
-        The low temperature.
-      '';
-    };
-
-    highTemp = mkOption {
-      type = types.int;
-      default = 66;
-      description = ''
-        The high temperature.
-      '';
-    };
-
-    maxTemp = mkOption {
-      type = types.int;
-      default = 86;
-      description = ''
-        The maximum temperature.
-      '';
-    };
-
-    pollingInterval = mkOption {
-      type = types.int;
-      default = 7;
-      description = ''
-        The polling interval.
-      '';
-    };
-
     verbose = mkOption {
       type = types.bool;
       default = false;
@@ -74,23 +28,61 @@ in {
         If true, sets the log level to verbose.
       '';
     };
+
+    settings = mkOption {
+      default = {};
+      description = "The INI configuration for Mbpfan.";
+      type = types.submodule {
+        freeformType = settingsFormat.type;
+
+        options.general.min_fan1_speed = mkOption {
+          type = types.int;
+          default = 2000;
+          description = "The minimum fan speed.";
+        };
+        options.general.max_fan1_speed = mkOption {
+          type = types.int;
+          default = 6199;
+          description = "The maximum fan speed.";
+        };
+        options.general.low_temp = mkOption {
+          type = types.int;
+          default = 55;
+          description = "The low temperature.";
+        };
+        options.general.high_temp = mkOption {
+          type = types.int;
+          default = 58;
+          description = "The high temperature.";
+        };
+        options.general.max_temp = mkOption {
+          type = types.int;
+          default = 86;
+          description = "The maximum temperature.";
+        };
+        options.general.polling_interval = mkOption {
+          type = types.int;
+          default = 1;
+          description = "The polling interval.";
+        };
+      };
+    };
   };
 
+  imports = [
+    (mkRenamedOptionModule [ "services" "mbpfan" "pollingInterval" ] [ "services" "mbpfan" "settings" "general" "polling_interval" ])
+    (mkRenamedOptionModule [ "services" "mbpfan" "maxTemp" ] [ "services" "mbpfan" "settings" "general" "max_temp" ])
+    (mkRenamedOptionModule [ "services" "mbpfan" "lowTemp" ] [ "services" "mbpfan" "settings" "general" "low_temp" ])
+    (mkRenamedOptionModule [ "services" "mbpfan" "highTemp" ] [ "services" "mbpfan" "settings" "general" "high_temp" ])
+    (mkRenamedOptionModule [ "services" "mbpfan" "minFanSpeed" ] [ "services" "mbpfan" "settings" "general" "min_fan1_speed" ])
+    (mkRenamedOptionModule [ "services" "mbpfan" "maxFanSpeed" ] [ "services" "mbpfan" "settings" "general" "max_fan1_speed" ])
+  ];
+
   config = mkIf cfg.enable {
     boot.kernelModules = [ "coretemp" "applesmc" ];
 
-    environment = {
-      etc."mbpfan.conf".text = ''
-        [general]
-        min_fan_speed = ${toString cfg.minFanSpeed}
-        max_fan_speed = ${toString cfg.maxFanSpeed}
-        low_temp = ${toString cfg.lowTemp}
-        high_temp = ${toString cfg.highTemp}
-        max_temp = ${toString cfg.maxTemp}
-        polling_interval = ${toString cfg.pollingInterval}
-      '';
-      systemPackages = [ cfg.package ];
-    };
+    environment.etc."mbpfan.conf".source = settingsFile;
+    environment.systemPackages = [ cfg.package ];
 
     systemd.services.mbpfan = {
       description = "A fan manager daemon for MacBook Pro";
diff --git a/nixos/modules/services/networking/adguardhome.nix b/nixos/modules/services/networking/adguardhome.nix
index 05713adbd83..98ddf071608 100644
--- a/nixos/modules/services/networking/adguardhome.nix
+++ b/nixos/modules/services/networking/adguardhome.nix
@@ -87,6 +87,22 @@ in {
   };
 
   config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = cfg.settings != { }
+          -> (hasAttrByPath [ "dns" "bind_host" ] cfg.settings)
+          || (hasAttrByPath [ "dns" "bind_hosts" ] cfg.settings);
+        message =
+          "AdGuard setting dns.bind_host or dns.bind_hosts needs to be configured for a minimal working configuration";
+      }
+      {
+        assertion = cfg.settings != { }
+          -> hasAttrByPath [ "dns" "bootstrap_dns" ] cfg.settings;
+        message =
+          "AdGuard setting dns.bootstrap_dns needs to be configured for a minimal working configuration";
+      }
+    ];
+
     systemd.services.adguardhome = {
       description = "AdGuard Home: Network-level blocker";
       after = [ "network.target" ];
@@ -96,7 +112,7 @@ in {
         StartLimitBurst = 10;
       };
 
-      preStart = ''
+      preStart = optionalString (cfg.settings != { }) ''
         if    [ -e "$STATE_DIRECTORY/AdGuardHome.yaml" ] \
            && [ "${toString cfg.mutableSettings}" = "1" ]; then
           # Writing directly to AdGuardHome.yaml results in empty file
diff --git a/nixos/modules/services/networking/ddclient.nix b/nixos/modules/services/networking/ddclient.nix
index 0bb8c87b38e..d025c8f8177 100644
--- a/nixos/modules/services/networking/ddclient.nix
+++ b/nixos/modules/services/networking/ddclient.nix
@@ -29,9 +29,9 @@ let
   configFile = if (cfg.configFile != null) then cfg.configFile else configFile';
 
   preStart = ''
-    install --owner ddclient -m600 ${configFile} /run/${RuntimeDirectory}/ddclient.conf
+    install ${configFile} /run/${RuntimeDirectory}/ddclient.conf
     ${lib.optionalString (cfg.configFile == null) (if (cfg.protocol == "nsupdate") then ''
-      install --owner ddclient -m600 ${cfg.passwordFile} /run/${RuntimeDirectory}/ddclient.key
+      install ${cfg.passwordFile} /run/${RuntimeDirectory}/ddclient.key
     '' else if (cfg.passwordFile != null) then ''
       password=$(printf "%q" "$(head -n 1 "${cfg.passwordFile}")")
       sed -i "s|^password=$|password=$password|" /run/${RuntimeDirectory}/ddclient.conf
diff --git a/nixos/modules/services/networking/mosquitto.nix b/nixos/modules/services/networking/mosquitto.nix
index 2d498d4dbbc..85d3ea5bd75 100644
--- a/nixos/modules/services/networking/mosquitto.nix
+++ b/nixos/modules/services/networking/mosquitto.nix
@@ -556,7 +556,7 @@ in
     systemd.services.mosquitto = {
       description = "Mosquitto MQTT Broker Daemon";
       wantedBy = [ "multi-user.target" ];
-      after = [ "network.target" ];
+      after = [ "network-online.target" ];
       serviceConfig = {
         Type = "notify";
         NotifyAccess = "main";
diff --git a/nixos/modules/services/networking/nix-serve.nix b/nixos/modules/services/networking/nix-serve.nix
index 390f0ddaee8..432938d59d9 100644
--- a/nixos/modules/services/networking/nix-serve.nix
+++ b/nixos/modules/services/networking/nix-serve.nix
@@ -26,6 +26,12 @@ in
         '';
       };
 
+      openFirewall = mkOption {
+        type = types.bool;
+        default = false;
+        description = "Open ports in the firewall for nix-serve.";
+      };
+
       secretKeyFile = mkOption {
         type = types.nullOr types.str;
         default = null;
@@ -77,5 +83,9 @@ in
           "NIX_SECRET_KEY_FILE:${cfg.secretKeyFile}";
       };
     };
+
+    networking.firewall = mkIf cfg.openFirewall {
+      allowedTCPPorts = [ cfg.port ];
+    };
   };
 }
diff --git a/nixos/modules/services/networking/ssh/sshd.nix b/nixos/modules/services/networking/ssh/sshd.nix
index f19624aba02..7bfc7005099 100644
--- a/nixos/modules/services/networking/ssh/sshd.nix
+++ b/nixos/modules/services/networking/ssh/sshd.nix
@@ -81,6 +81,7 @@ in
   imports = [
     (mkAliasOptionModule [ "services" "sshd" "enable" ] [ "services" "openssh" "enable" ])
     (mkAliasOptionModule [ "services" "openssh" "knownHosts" ] [ "programs" "ssh" "knownHosts" ])
+    (mkRenamedOptionModule [ "services" "openssh" "challengeResponseAuthentication" ] [ "services" "openssh" "kbdInteractiveAuthentication" ])
   ];
 
   ###### interface
@@ -218,11 +219,11 @@ in
         '';
       };
 
-      challengeResponseAuthentication = mkOption {
+      kbdInteractiveAuthentication = mkOption {
         type = types.bool;
         default = true;
         description = ''
-          Specifies whether challenge/response authentication is allowed.
+          Specifies whether keyboard-interactive authentication is allowed.
         '';
       };
 
@@ -534,7 +535,7 @@ in
         PermitRootLogin ${cfg.permitRootLogin}
         GatewayPorts ${cfg.gatewayPorts}
         PasswordAuthentication ${if cfg.passwordAuthentication then "yes" else "no"}
-        ChallengeResponseAuthentication ${if cfg.challengeResponseAuthentication then "yes" else "no"}
+        KbdInteractiveAuthentication ${if cfg.kbdInteractiveAuthentication then "yes" else "no"}
 
         PrintMotd no # handled by pam_motd
 
diff --git a/nixos/modules/services/networking/syncthing.nix b/nixos/modules/services/networking/syncthing.nix
index e37e324019e..3a3d4c80ecf 100644
--- a/nixos/modules/services/networking/syncthing.nix
+++ b/nixos/modules/services/networking/syncthing.nix
@@ -468,7 +468,7 @@ in {
         default = false;
         example = true;
         description = ''
-          Whether to open the default ports in the firewall: TCP 22000 for transfers
+          Whether to open the default ports in the firewall: TCP/UDP 22000 for transfers
           and UDP 21027 for discovery.
 
           If multiple users are running Syncthing on this machine, you will need
@@ -504,7 +504,7 @@ in {
 
     networking.firewall = mkIf cfg.openDefaultPorts {
       allowedTCPPorts = [ 22000 ];
-      allowedUDPPorts = [ 21027 ];
+      allowedUDPPorts = [ 21027 22000 ];
     };
 
     systemd.packages = [ pkgs.syncthing ];
diff --git a/nixos/modules/services/system/cachix-agent/default.nix b/nixos/modules/services/system/cachix-agent/default.nix
new file mode 100644
index 00000000000..496e0b90355
--- /dev/null
+++ b/nixos/modules/services/system/cachix-agent/default.nix
@@ -0,0 +1,57 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  cfg = config.services.cachix-agent;
+in {
+  meta.maintainers = [ lib.maintainers.domenkozar ];
+
+  options.services.cachix-agent = {
+    enable = mkEnableOption "Cachix Deploy Agent: https://docs.cachix.org/deploy/";
+
+    name = mkOption {
+      type = types.str;
+      description = "Agent name, usually same as the hostname";
+      default = config.networking.hostName;
+      defaultText = "config.networking.hostName";
+    };
+
+    profile = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = "Profile name, defaults to 'system' (NixOS).";
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.cachix;
+      defaultText = literalExpression "pkgs.cachix";
+      description = "Cachix Client package to use.";
+    };
+
+    credentialsFile = mkOption {
+      type = types.path;
+      default = "/etc/cachix-agent.token";
+      description = ''
+        Required file that needs to contain CACHIX_AGENT_TOKEN=...
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.cachix-agent = {
+      description = "Cachix Deploy Agent";
+      after = ["network-online.target"];
+      path = [ config.nix.package ];
+      wantedBy = [ "multi-user.target" ];
+      # don't restart while changing
+      reloadIfChanged = true;
+      serviceConfig = {
+        Restart = "on-failure";
+        EnvironmentFile = cfg.credentialsFile;
+        ExecStart = "${cfg.package}/bin/cachix deploy agent ${cfg.name} ${if cfg.profile != null then profile else ""}";
+      };
+    };
+  };
+}
diff --git a/nixos/modules/services/web-apps/bookstack.nix b/nixos/modules/services/web-apps/bookstack.nix
index b509e4fff45..54eaea63b6e 100644
--- a/nixos/modules/services/web-apps/bookstack.nix
+++ b/nixos/modules/services/web-apps/bookstack.nix
@@ -24,8 +24,14 @@ let
     $sudo ${pkgs.php}/bin/php artisan $*
   '';
 
+  tlsEnabled = cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME;
 
 in {
+  imports = [
+    (mkRemovedOptionModule [ "services" "bookstack" "extraConfig" ] "Use services.bookstack.config instead.")
+    (mkRemovedOptionModule [ "services" "bookstack" "cacheDir" ] "The cache directory is now handled automatically.")
+  ];
+
   options.services.bookstack = {
 
     enable = mkEnableOption "BookStack";
@@ -44,28 +50,38 @@ in {
 
     appKeyFile = mkOption {
       description = ''
-        A file containing the AppKey.
-        Used for encryption where needed. Can be generated with <code>head -c 32 /dev/urandom| base64</code> and must be prefixed with <literal>base64:</literal>.
+        A file containing the Laravel APP_KEY - a 32 character long,
+        base64 encoded key used for encryption where needed. Can be
+        generated with <code>head -c 32 /dev/urandom | base64</code>.
       '';
       example = "/run/keys/bookstack-appkey";
       type = types.path;
     };
 
+    hostname = lib.mkOption {
+      type = lib.types.str;
+      default = if config.networking.domain != null then
+                  config.networking.fqdn
+                else
+                  config.networking.hostName;
+      defaultText = lib.literalExpression "config.networking.fqdn";
+      example = "bookstack.example.com";
+      description = ''
+        The hostname to serve BookStack on.
+      '';
+    };
+
     appURL = mkOption {
       description = ''
         The root URL that you want to host BookStack on. All URLs in BookStack will be generated using this value.
         If you change this in the future you may need to run a command to update stored URLs in the database. Command example: <code>php artisan bookstack:update-url https://old.example.com https://new.example.com</code>
       '';
+      default = "http${lib.optionalString tlsEnabled "s"}://${cfg.hostname}";
+      defaultText = ''http''${lib.optionalString tlsEnabled "s"}://''${cfg.hostname}'';
       example = "https://example.com";
       type = types.str;
     };
 
-    cacheDir = mkOption {
-      description = "BookStack cache directory";
-      default = "/var/cache/bookstack";
-      type = types.path;
-    };
-
     dataDir = mkOption {
       description = "BookStack data directory";
       default = "/var/lib/bookstack";
@@ -202,16 +218,59 @@ in {
       '';
     };
 
-    extraConfig = mkOption {
-      type = types.nullOr types.lines;
-      default = null;
-      example = ''
-        ALLOWED_IFRAME_HOSTS="https://example.com"
-        WKHTMLTOPDF=/home/user/bins/wkhtmltopdf
+    config = mkOption {
+      type = with types;
+        attrsOf
+          (nullOr
+            (either
+              (oneOf [
+                bool
+                int
+                port
+                path
+                str
+              ])
+              (submodule {
+                options = {
+                  _secret = mkOption {
+                    type = nullOr str;
+                    description = ''
+                      The path to a file containing the value the
+                      option should be set to in the final
+                      configuration file.
+                    '';
+                  };
+                };
+              })));
+      default = {};
+      example = literalExpression ''
+        {
+          ALLOWED_IFRAME_HOSTS = "https://example.com";
+          WKHTMLTOPDF = "/home/user/bins/wkhtmltopdf";
+          AUTH_METHOD = "oidc";
+          OIDC_NAME = "MyLogin";
+          OIDC_DISPLAY_NAME_CLAIMS = "name";
+          OIDC_CLIENT_ID = "bookstack";
+          OIDC_CLIENT_SECRET = {_secret = "/run/keys/oidc_secret"};
+          OIDC_ISSUER = "https://keycloak.example.com/auth/realms/My%20Realm";
+          OIDC_ISSUER_DISCOVER = true;
+        }
       '';
       description = ''
-        Lines to be appended verbatim to the BookStack configuration.
-        Refer to <link xlink:href="https://www.bookstackapp.com/docs/"/> for details on supported values.
+        BookStack configuration options to set in the
+        <filename>.env</filename> file.
+
+        Refer to <link xlink:href="https://www.bookstackapp.com/docs/"/>
+        for details on supported values.
+
+        Settings containing secret data should be set to an attribute
+        set containing the attribute <literal>_secret</literal> - a
+        string pointing to a file containing the value the option
+        should be set to. See the example to get a better picture of
+        this: in the resulting <filename>.env</filename> file, the
+        <literal>OIDC_CLIENT_SECRET</literal> key will be set to the
+        contents of the <filename>/run/keys/oidc_secret</filename>
+        file.
       '';
     };
 
@@ -228,6 +287,30 @@ in {
       }
     ];
 
+    services.bookstack.config = {
+      APP_KEY._secret = cfg.appKeyFile;
+      APP_URL = cfg.appURL;
+      DB_HOST = db.host;
+      DB_PORT = db.port;
+      DB_DATABASE = db.name;
+      DB_USERNAME = db.user;
+      MAIL_DRIVER = mail.driver;
+      MAIL_FROM_NAME = mail.fromName;
+      MAIL_FROM = mail.from;
+      MAIL_HOST = mail.host;
+      MAIL_PORT = mail.port;
+      MAIL_USERNAME = mail.user;
+      MAIL_ENCRYPTION = mail.encryption;
+      DB_PASSWORD._secret = db.passwordFile;
+      MAIL_PASSWORD._secret = mail.passwordFile;
+      APP_SERVICES_CACHE = "/run/bookstack/cache/services.php";
+      APP_PACKAGES_CACHE = "/run/bookstack/cache/packages.php";
+      APP_CONFIG_CACHE = "/run/bookstack/cache/config.php";
+      APP_ROUTES_CACHE = "/run/bookstack/cache/routes-v7.php";
+      APP_EVENTS_CACHE = "/run/bookstack/cache/events.php";
+      SESSION_SECURE_COOKIE = tlsEnabled;
+    };
+
     environment.systemPackages = [ artisan ];
 
     services.mysql = mkIf db.createLocally {
@@ -258,24 +341,19 @@ in {
 
     services.nginx = {
       enable = mkDefault true;
-      virtualHosts.bookstack = mkMerge [ cfg.nginx {
+      recommendedTlsSettings = true;
+      recommendedOptimisation = true;
+      recommendedGzipSettings = true;
+      virtualHosts.${cfg.hostname} = mkMerge [ cfg.nginx {
         root = mkForce "${bookstack}/public";
-        extraConfig = optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "fastcgi_param HTTPS on;";
         locations = {
           "/" = {
             index = "index.php";
-            extraConfig = ''try_files $uri $uri/ /index.php?$query_string;'';
-          };
-          "~ \.php$" = {
-            extraConfig = ''
-              try_files $uri $uri/ /index.php?$query_string;
-              include ${pkgs.nginx}/conf/fastcgi_params;
-              fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
-              fastcgi_param REDIRECT_STATUS 200;
-              fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket};
-              ${optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "fastcgi_param HTTPS on;"}
-            '';
+            tryFiles = "$uri $uri/ /index.php?$query_string";
           };
+          "~ \.php$".extraConfig = ''
+            fastcgi_pass unix:${config.services.phpfpm.pools."bookstack".socket};
+          '';
           "~ \.(js|css|gif|png|ico|jpg|jpeg)$" = {
             extraConfig = "expires 365d;";
           };
@@ -290,50 +368,54 @@ in {
       wantedBy = [ "multi-user.target" ];
       serviceConfig = {
         Type = "oneshot";
+        RemainAfterExit = true;
         User = user;
         WorkingDirectory = "${bookstack}";
+        RuntimeDirectory = "bookstack/cache";
+        RuntimeDirectoryMode = 0700;
       };
-      script = ''
+      path = [ pkgs.replace-secret ];
+      script =
+        let
+          isSecret = v: isAttrs v && v ? _secret && isString v._secret;
+          bookstackEnvVars = lib.generators.toKeyValue {
+            mkKeyValue = lib.flip lib.generators.mkKeyValueDefault "=" {
+              mkValueString = v: with builtins;
+                if isInt         v then toString v
+                else if isString v then v
+                else if true  == v then "true"
+                else if false == v then "false"
+                else if isSecret v then v._secret
+                else throw "unsupported type ${typeOf v}: ${(lib.generators.toPretty {}) v}";
+            };
+          };
+          secretPaths = lib.mapAttrsToList (_: v: v._secret) (lib.filterAttrs (_: isSecret) cfg.config);
+          mkSecretReplacement = file: ''
+            replace-secret ${escapeShellArgs [ file file "${cfg.dataDir}/.env" ]}
+          '';
+          secretReplacements = lib.concatMapStrings mkSecretReplacement secretPaths;
+          filteredConfig = lib.converge (lib.filterAttrsRecursive (_: v: ! elem v [ {} null ])) cfg.config;
+          bookstackEnv = pkgs.writeText "bookstack.env" (bookstackEnvVars filteredConfig);
+        in ''
+        # error handling
+        set -euo pipefail
+
         # set permissions
         umask 077
+
         # create .env file
-        echo "
-        APP_KEY=base64:$(head -n1 ${cfg.appKeyFile})
-        APP_URL=${cfg.appURL}
-        DB_HOST=${db.host}
-        DB_PORT=${toString db.port}
-        DB_DATABASE=${db.name}
-        DB_USERNAME=${db.user}
-        MAIL_DRIVER=${mail.driver}
-        MAIL_FROM_NAME=\"${mail.fromName}\"
-        MAIL_FROM=${mail.from}
-        MAIL_HOST=${mail.host}
-        MAIL_PORT=${toString mail.port}
-        ${optionalString (mail.user != null) "MAIL_USERNAME=${mail.user};"}
-        ${optionalString (mail.encryption != null) "MAIL_ENCRYPTION=${mail.encryption};"}
-        ${optionalString (db.passwordFile != null) "DB_PASSWORD=$(head -n1 ${db.passwordFile})"}
-        ${optionalString (mail.passwordFile != null) "MAIL_PASSWORD=$(head -n1 ${mail.passwordFile})"}
-        APP_SERVICES_CACHE=${cfg.cacheDir}/services.php
-        APP_PACKAGES_CACHE=${cfg.cacheDir}/packages.php
-        APP_CONFIG_CACHE=${cfg.cacheDir}/config.php
-        APP_ROUTES_CACHE=${cfg.cacheDir}/routes-v7.php
-        APP_EVENTS_CACHE=${cfg.cacheDir}/events.php
-        ${optionalString (cfg.nginx.addSSL || cfg.nginx.forceSSL || cfg.nginx.onlySSL || cfg.nginx.enableACME) "SESSION_SECURE_COOKIE=true"}
-        ${toString cfg.extraConfig}
-        " > "${cfg.dataDir}/.env"
+        install -T -m 0600 -o ${user} ${bookstackEnv} "${cfg.dataDir}/.env"
+        ${secretReplacements}
+        if ! grep 'APP_KEY=base64:' "${cfg.dataDir}/.env" >/dev/null; then
+            sed -i 's/APP_KEY=/APP_KEY=base64:/' "${cfg.dataDir}/.env"
+        fi
 
         # migrate db
         ${pkgs.php}/bin/php artisan migrate --force
-
-        # clear & create caches (needed in case of update)
-        ${pkgs.php}/bin/php artisan cache:clear
-        ${pkgs.php}/bin/php artisan config:clear
-        ${pkgs.php}/bin/php artisan view:clear
       '';
     };
 
     systemd.tmpfiles.rules = [
-      "d ${cfg.cacheDir}                           0700 ${user} ${group} - -"
       "d ${cfg.dataDir}                            0710 ${user} ${group} - -"
       "d ${cfg.dataDir}/public                     0750 ${user} ${group} - -"
       "d ${cfg.dataDir}/public/uploads             0750 ${user} ${group} - -"
diff --git a/nixos/modules/services/web-apps/keycloak.nix b/nixos/modules/services/web-apps/keycloak.nix
index e08f6dcabd2..a01f0049b2c 100644
--- a/nixos/modules/services/web-apps/keycloak.nix
+++ b/nixos/modules/services/web-apps/keycloak.nix
@@ -3,280 +3,311 @@
 let
   cfg = config.services.keycloak;
   opt = options.services.keycloak;
-in
-{
-  options.services.keycloak = {
-
-    enable = lib.mkOption {
-      type = lib.types.bool;
-      default = false;
-      example = true;
-      description = ''
-        Whether to enable the Keycloak identity and access management
-        server.
-      '';
-    };
 
-    bindAddress = lib.mkOption {
-      type = lib.types.str;
-      default = "\${jboss.bind.address:0.0.0.0}";
-      example = "127.0.0.1";
-      description = ''
-        On which address Keycloak should accept new connections.
+  inherit (lib) types mkOption concatStringsSep mapAttrsToList
+    escapeShellArg recursiveUpdate optionalAttrs boolToString mkOrder
+    sort filterAttrs concatMapStringsSep concatStrings mkIf
+    optionalString optionals mkDefault literalExpression hasSuffix
+    foldl' isAttrs filter attrNames elem literalDocBook
+    maintainers;
 
-        A special syntax can be used to allow command line Java system
-        properties to override the value: ''${property.name:value}
-      '';
-    };
-
-    httpPort = lib.mkOption {
-      type = lib.types.str;
-      default = "\${jboss.http.port:80}";
-      example = "8080";
-      description = ''
-        On which port Keycloak should listen for new HTTP connections.
+  inherit (builtins) match typeOf;
+in
+{
+  options.services.keycloak =
+    let
+      inherit (types) bool str nullOr attrsOf path enum anything
+        package port;
+    in
+    {
+      enable = mkOption {
+        type = bool;
+        default = false;
+        example = true;
+        description = ''
+          Whether to enable the Keycloak identity and access management
+          server.
+        '';
+      };
 
-        A special syntax can be used to allow command line Java system
-        properties to override the value: ''${property.name:value}
-      '';
-    };
+      bindAddress = mkOption {
+        type = str;
+        default = "\${jboss.bind.address:0.0.0.0}";
+        example = "127.0.0.1";
+        description = ''
+          On which address Keycloak should accept new connections.
 
-    httpsPort = lib.mkOption {
-      type = lib.types.str;
-      default = "\${jboss.https.port:443}";
-      example = "8443";
-      description = ''
-        On which port Keycloak should listen for new HTTPS connections.
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
 
-        A special syntax can be used to allow command line Java system
-        properties to override the value: ''${property.name:value}
-      '';
-    };
+      httpPort = mkOption {
+        type = str;
+        default = "\${jboss.http.port:80}";
+        example = "8080";
+        description = ''
+          On which port Keycloak should listen for new HTTP connections.
 
-    frontendUrl = lib.mkOption {
-      type = lib.types.str;
-      apply = x: if lib.hasSuffix "/" x then x else x + "/";
-      example = "keycloak.example.com/auth";
-      description = ''
-        The public URL used as base for all frontend requests. Should
-        normally include a trailing <literal>/auth</literal>.
-
-        See <link xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
-        Hostname section of the Keycloak server installation
-        manual</link> for more information.
-      '';
-    };
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
 
-    forceBackendUrlToFrontendUrl = lib.mkOption {
-      type = lib.types.bool;
-      default = false;
-      example = true;
-      description = ''
-        Whether Keycloak should force all requests to go through the
-        frontend URL configured in <xref
-        linkend="opt-services.keycloak.frontendUrl" />. By default,
-        Keycloak allows backend requests to instead use its local
-        hostname or IP address and may also advertise it to clients
-        through its OpenID Connect Discovery endpoint.
-
-        See <link
-        xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
-        Hostname section of the Keycloak server installation
-        manual</link> for more information.
-      '';
-    };
+      httpsPort = mkOption {
+        type = str;
+        default = "\${jboss.https.port:443}";
+        example = "8443";
+        description = ''
+          On which port Keycloak should listen for new HTTPS connections.
 
-    sslCertificate = lib.mkOption {
-      type = lib.types.nullOr lib.types.path;
-      default = null;
-      example = "/run/keys/ssl_cert";
-      description = ''
-        The path to a PEM formatted certificate to use for TLS/SSL
-        connections.
+          A special syntax can be used to allow command line Java system
+          properties to override the value: ''${property.name:value}
+        '';
+      };
 
-        This should be a string, not a Nix path, since Nix paths are
-        copied into the world-readable Nix store.
-      '';
-    };
+      frontendUrl = mkOption {
+        type = str;
+        apply = x:
+          if x == "" || hasSuffix "/" x then
+            x
+          else
+            x + "/";
+        example = "keycloak.example.com/auth";
+        description = ''
+          The public URL used as base for all frontend requests. Should
+          normally include a trailing <literal>/auth</literal>.
 
-    sslCertificateKey = lib.mkOption {
-      type = lib.types.nullOr lib.types.path;
-      default = null;
-      example = "/run/keys/ssl_key";
-      description = ''
-        The path to a PEM formatted private key to use for TLS/SSL
-        connections.
+          See <link xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
+          Hostname section of the Keycloak server installation
+          manual</link> for more information.
+        '';
+      };
 
-        This should be a string, not a Nix path, since Nix paths are
-        copied into the world-readable Nix store.
-      '';
-    };
+      forceBackendUrlToFrontendUrl = mkOption {
+        type = bool;
+        default = false;
+        example = true;
+        description = ''
+          Whether Keycloak should force all requests to go through the
+          frontend URL configured in <xref
+          linkend="opt-services.keycloak.frontendUrl" />. By default,
+          Keycloak allows backend requests to instead use its local
+          hostname or IP address and may also advertise it to clients
+          through its OpenID Connect Discovery endpoint.
+
+          See <link
+          xlink:href="https://www.keycloak.org/docs/latest/server_installation/#_hostname">the
+          Hostname section of the Keycloak server installation
+          manual</link> for more information.
+        '';
+      };
 
-    database = {
-      type = lib.mkOption {
-        type = lib.types.enum [ "mysql" "postgresql" ];
-        default = "postgresql";
-        example = "mysql";
+      sslCertificate = mkOption {
+        type = nullOr path;
+        default = null;
+        example = "/run/keys/ssl_cert";
         description = ''
-          The type of database Keycloak should connect to.
+          The path to a PEM formatted certificate to use for TLS/SSL
+          connections.
+
+          This should be a string, not a Nix path, since Nix paths are
+          copied into the world-readable Nix store.
         '';
       };
 
-      host = lib.mkOption {
-        type = lib.types.str;
-        default = "localhost";
+      sslCertificateKey = mkOption {
+        type = nullOr path;
+        default = null;
+        example = "/run/keys/ssl_key";
         description = ''
-          Hostname of the database to connect to.
+          The path to a PEM formatted private key to use for TLS/SSL
+          connections.
+
+          This should be a string, not a Nix path, since Nix paths are
+          copied into the world-readable Nix store.
         '';
       };
 
-      port =
-        let
-          dbPorts = {
-            postgresql = 5432;
-            mysql = 3306;
-          };
-        in
-          lib.mkOption {
-            type = lib.types.port;
+      database = {
+        type = mkOption {
+          type = enum [ "mysql" "postgresql" ];
+          default = "postgresql";
+          example = "mysql";
+          description = ''
+            The type of database Keycloak should connect to.
+          '';
+        };
+
+        host = mkOption {
+          type = str;
+          default = "localhost";
+          description = ''
+            Hostname of the database to connect to.
+          '';
+        };
+
+        port =
+          let
+            dbPorts = {
+              postgresql = 5432;
+              mysql = 3306;
+            };
+          in
+          mkOption {
+            type = port;
             default = dbPorts.${cfg.database.type};
-            defaultText = lib.literalDocBook "default port of selected database";
+            defaultText = literalDocBook "default port of selected database";
             description = ''
               Port of the database to connect to.
             '';
           };
 
-      useSSL = lib.mkOption {
-        type = lib.types.bool;
-        default = cfg.database.host != "localhost";
-        defaultText = lib.literalExpression ''config.${opt.database.host} != "localhost"'';
-        description = ''
-          Whether the database connection should be secured by SSL /
-          TLS.
-        '';
-      };
+        useSSL = mkOption {
+          type = bool;
+          default = cfg.database.host != "localhost";
+          defaultText = literalExpression ''config.${opt.database.host} != "localhost"'';
+          description = ''
+            Whether the database connection should be secured by SSL /
+            TLS.
+          '';
+        };
 
-      caCert = lib.mkOption {
-        type = lib.types.nullOr lib.types.path;
-        default = null;
-        description = ''
-          The SSL / TLS CA certificate that verifies the identity of the
-          database server.
+        caCert = mkOption {
+          type = nullOr path;
+          default = null;
+          description = ''
+            The SSL / TLS CA certificate that verifies the identity of the
+            database server.
 
-          Required when PostgreSQL is used and SSL is turned on.
+            Required when PostgreSQL is used and SSL is turned on.
 
-          For MySQL, if left at <literal>null</literal>, the default
-          Java keystore is used, which should suffice if the server
-          certificate is issued by an official CA.
-        '';
+            For MySQL, if left at <literal>null</literal>, the default
+            Java keystore is used, which should suffice if the server
+            certificate is issued by an official CA.
+          '';
+        };
+
+        createLocally = mkOption {
+          type = bool;
+          default = true;
+          description = ''
+            Whether a database should be automatically created on the
+            local host. Set this to false if you plan on provisioning a
+            local database yourself. This has no effect if
+            services.keycloak.database.host is customized.
+          '';
+        };
+
+        username = mkOption {
+          type = str;
+          default = "keycloak";
+          description = ''
+            Username to use when connecting to an external or manually
+            provisioned database; has no effect when a local database is
+            automatically provisioned.
+
+            To use this with a local database, set <xref
+            linkend="opt-services.keycloak.database.createLocally" /> to
+            <literal>false</literal> and create the database and user
+            manually. The database should be called
+            <literal>keycloak</literal>.
+          '';
+        };
+
+        passwordFile = mkOption {
+          type = path;
+          example = "/run/keys/db_password";
+          description = ''
+            File containing the database password.
+
+            This should be a string, not a Nix path, since Nix paths are
+            copied into the world-readable Nix store.
+          '';
+        };
       };
 
-      createLocally = lib.mkOption {
-        type = lib.types.bool;
-        default = true;
+      package = mkOption {
+        type = package;
+        default = pkgs.keycloak;
+        defaultText = literalExpression "pkgs.keycloak";
         description = ''
-          Whether a database should be automatically created on the
-          local host. Set this to false if you plan on provisioning a
-          local database yourself. This has no effect if
-          services.keycloak.database.host is customized.
+          Keycloak package to use.
         '';
       };
 
-      username = lib.mkOption {
-        type = lib.types.str;
-        default = "keycloak";
+      initialAdminPassword = mkOption {
+        type = str;
+        default = "changeme";
         description = ''
-          Username to use when connecting to an external or manually
-          provisioned database; has no effect when a local database is
-          automatically provisioned.
-
-          To use this with a local database, set <xref
-          linkend="opt-services.keycloak.database.createLocally" /> to
-          <literal>false</literal> and create the database and user
-          manually. The database should be called
-          <literal>keycloak</literal>.
+          Initial password set for the <literal>admin</literal>
+          user. The password is not stored safely and should be changed
+          immediately in the admin panel.
         '';
       };
 
-      passwordFile = lib.mkOption {
-        type = lib.types.path;
-        example = "/run/keys/db_password";
+      themes = mkOption {
+        type = attrsOf package;
+        default = { };
         description = ''
-          File containing the database password.
+          Additional theme packages for Keycloak. Each theme is linked into
+          subdirectory with a corresponding attribute name.
 
-          This should be a string, not a Nix path, since Nix paths are
-          copied into the world-readable Nix store.
+          Theme packages consist of several subdirectories which provide
+          different theme types: for example, <literal>account</literal>,
+          <literal>login</literal> etc. After adding a theme to this option you
+          can select it by its name in Keycloak administration console.
         '';
       };
-    };
-
-    package = lib.mkOption {
-      type = lib.types.package;
-      default = pkgs.keycloak;
-      defaultText = lib.literalExpression "pkgs.keycloak";
-      description = ''
-        Keycloak package to use.
-      '';
-    };
-
-    initialAdminPassword = lib.mkOption {
-      type = lib.types.str;
-      default = "changeme";
-      description = ''
-        Initial password set for the <literal>admin</literal>
-        user. The password is not stored safely and should be changed
-        immediately in the admin panel.
-      '';
-    };
 
-    extraConfig = lib.mkOption {
-      type = lib.types.attrs;
-      default = { };
-      example = lib.literalExpression ''
-        {
-          "subsystem=keycloak-server" = {
-            "spi=hostname" = {
-              "provider=default" = null;
-              "provider=fixed" = {
-                enabled = true;
-                properties.hostname = "keycloak.example.com";
+      extraConfig = mkOption {
+        type = attrsOf anything;
+        default = { };
+        example = literalExpression ''
+          {
+            "subsystem=keycloak-server" = {
+              "spi=hostname" = {
+                "provider=default" = null;
+                "provider=fixed" = {
+                  enabled = true;
+                  properties.hostname = "keycloak.example.com";
+                };
+                default-provider = "fixed";
               };
-              default-provider = "fixed";
             };
-          };
-        }
-      '';
-      description = ''
-        Additional Keycloak configuration options to set in
-        <literal>standalone.xml</literal>.
-
-        Options are expressed as a Nix attribute set which matches the
-        structure of the jboss-cli configuration. The configuration is
-        effectively overlayed on top of the default configuration
-        shipped with Keycloak. To remove existing nodes and undefine
-        attributes from the default configuration, set them to
-        <literal>null</literal>.
-
-        The example configuration does the equivalent of the following
-        script, which removes the hostname provider
-        <literal>default</literal>, adds the deprecated hostname
-        provider <literal>fixed</literal> and defines it the default:
-
-        <programlisting>
-        /subsystem=keycloak-server/spi=hostname/provider=default:remove()
-        /subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" })
-        /subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed")
-        </programlisting>
-
-        You can discover available options by using the <link
-        xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link>
-        program and by referring to the <link
-        xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak
-        Server Installation and Configuration Guide</link>.
-      '';
-    };
+          }
+        '';
+        description = ''
+          Additional Keycloak configuration options to set in
+          <literal>standalone.xml</literal>.
+
+          Options are expressed as a Nix attribute set which matches the
+          structure of the jboss-cli configuration. The configuration is
+          effectively overlayed on top of the default configuration
+          shipped with Keycloak. To remove existing nodes and undefine
+          attributes from the default configuration, set them to
+          <literal>null</literal>.
+
+          The example configuration does the equivalent of the following
+          script, which removes the hostname provider
+          <literal>default</literal>, adds the deprecated hostname
+          provider <literal>fixed</literal> and defines it the default:
+
+          <programlisting>
+          /subsystem=keycloak-server/spi=hostname/provider=default:remove()
+          /subsystem=keycloak-server/spi=hostname/provider=fixed:add(enabled = true, properties = { hostname = "keycloak.example.com" })
+          /subsystem=keycloak-server/spi=hostname:write-attribute(name=default-provider, value="fixed")
+          </programlisting>
+
+          You can discover available options by using the <link
+          xlink:href="http://docs.wildfly.org/21/Admin_Guide.html#Command_Line_Interface">jboss-cli.sh</link>
+          program and by referring to the <link
+          xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html">Keycloak
+          Server Installation and Configuration Guide</link>.
+        '';
+      };
 
-  };
+    };
 
   config =
     let
@@ -285,28 +316,58 @@ in
       createLocalPostgreSQL = databaseActuallyCreateLocally && cfg.database.type == "postgresql";
       createLocalMySQL = databaseActuallyCreateLocally && cfg.database.type == "mysql";
 
-      mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" {} ''
+      mySqlCaKeystore = pkgs.runCommand "mysql-ca-keystore" { } ''
         ${pkgs.jre}/bin/keytool -importcert -trustcacerts -alias MySQLCACert -file ${cfg.database.caCert} -keystore $out -storepass notsosecretpassword -noprompt
       '';
 
-      keycloakConfig' = builtins.foldl' lib.recursiveUpdate {
-        "interface=public".inet-address = cfg.bindAddress;
-        "socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort;
-        "subsystem=keycloak-server"."spi=hostname" = {
-          "provider=default" = {
-            enabled = true;
-            properties = {
-              inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl;
+      # Both theme and theme type directories need to be actual directories in one hierarchy to pass Keycloak checks.
+      themesBundle = pkgs.runCommand "keycloak-themes" { } ''
+        linkTheme() {
+          theme="$1"
+          name="$2"
+
+          mkdir "$out/$name"
+          for typeDir in "$theme"/*; do
+            if [ -d "$typeDir" ]; then
+              type="$(basename "$typeDir")"
+              mkdir "$out/$name/$type"
+              for file in "$typeDir"/*; do
+                ln -sn "$file" "$out/$name/$type/$(basename "$file")"
+              done
+            fi
+          done
+        }
+
+        mkdir -p "$out"
+        for theme in ${cfg.package}/themes/*; do
+          if [ -d "$theme" ]; then
+            linkTheme "$theme" "$(basename "$theme")"
+          fi
+        done
+
+        ${concatStringsSep "\n" (mapAttrsToList (name: theme: "linkTheme ${theme} ${escapeShellArg name}") cfg.themes)}
+      '';
+
+      keycloakConfig' = foldl' recursiveUpdate
+        {
+          "interface=public".inet-address = cfg.bindAddress;
+          "socket-binding-group=standard-sockets"."socket-binding=http".port = cfg.httpPort;
+          "subsystem=keycloak-server" = {
+            "spi=hostname"."provider=default" = {
+              enabled = true;
+              properties = {
+                inherit (cfg) frontendUrl forceBackendUrlToFrontendUrl;
+              };
             };
+            "theme=defaults".dir = toString themesBundle;
           };
-        };
-        "subsystem=datasources"."data-source=KeycloakDS" = {
-          max-pool-size = "20";
-          user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
-          password = "@db-password@";
-        };
-      } [
-        (lib.optionalAttrs (cfg.database.type == "postgresql") {
+          "subsystem=datasources"."data-source=KeycloakDS" = {
+            max-pool-size = "20";
+            user-name = if databaseActuallyCreateLocally then "keycloak" else cfg.database.username;
+            password = "@db-password@";
+          };
+        } [
+        (optionalAttrs (cfg.database.type == "postgresql") {
           "subsystem=datasources" = {
             "jdbc-driver=postgresql" = {
               driver-module-name = "org.postgresql";
@@ -314,16 +375,16 @@ in
               driver-xa-datasource-class-name = "org.postgresql.xa.PGXADataSource";
             };
             "data-source=KeycloakDS" = {
-              connection-url = "jdbc:postgresql://${cfg.database.host}:${builtins.toString cfg.database.port}/keycloak";
+              connection-url = "jdbc:postgresql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
               driver-name = "postgresql";
-              "connection-properties=ssl".value = lib.boolToString cfg.database.useSSL;
-            } // (lib.optionalAttrs (cfg.database.caCert != null) {
+              "connection-properties=ssl".value = boolToString cfg.database.useSSL;
+            } // (optionalAttrs (cfg.database.caCert != null) {
               "connection-properties=sslrootcert".value = cfg.database.caCert;
               "connection-properties=sslmode".value = "verify-ca";
             });
           };
         })
-        (lib.optionalAttrs (cfg.database.type == "mysql") {
+        (optionalAttrs (cfg.database.type == "mysql") {
           "subsystem=datasources" = {
             "jdbc-driver=mysql" = {
               driver-module-name = "com.mysql";
@@ -331,28 +392,40 @@ in
               driver-class-name = "com.mysql.jdbc.Driver";
             };
             "data-source=KeycloakDS" = {
-              connection-url = "jdbc:mysql://${cfg.database.host}:${builtins.toString cfg.database.port}/keycloak";
+              connection-url = "jdbc:mysql://${cfg.database.host}:${toString cfg.database.port}/keycloak";
               driver-name = "mysql";
-              "connection-properties=useSSL".value = lib.boolToString cfg.database.useSSL;
-              "connection-properties=requireSSL".value = lib.boolToString cfg.database.useSSL;
-              "connection-properties=verifyServerCertificate".value = lib.boolToString cfg.database.useSSL;
+              "connection-properties=useSSL".value = boolToString cfg.database.useSSL;
+              "connection-properties=requireSSL".value = boolToString cfg.database.useSSL;
+              "connection-properties=verifyServerCertificate".value = boolToString cfg.database.useSSL;
               "connection-properties=characterEncoding".value = "UTF-8";
               valid-connection-checker-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLValidConnectionChecker";
               validate-on-match = true;
               exception-sorter-class-name = "org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter";
-            } // (lib.optionalAttrs (cfg.database.caCert != null) {
+            } // (optionalAttrs (cfg.database.caCert != null) {
               "connection-properties=trustCertificateKeyStoreUrl".value = "file:${mySqlCaKeystore}";
               "connection-properties=trustCertificateKeyStorePassword".value = "notsosecretpassword";
             });
           };
         })
-        (lib.optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
+        (optionalAttrs (cfg.sslCertificate != null && cfg.sslCertificateKey != null) {
           "socket-binding-group=standard-sockets"."socket-binding=https".port = cfg.httpsPort;
-          "core-service=management"."security-realm=UndertowRealm"."server-identity=ssl" = {
-            keystore-path = "/run/keycloak/ssl/certificate_private_key_bundle.p12";
-            keystore-password = "notsosecretpassword";
+          "subsystem=elytron" = mkOrder 900 {
+            "key-store=httpsKS" = mkOrder 900 {
+              path = "/run/keycloak/ssl/certificate_private_key_bundle.p12";
+              credential-reference.clear-text = "notsosecretpassword";
+              type = "JKS";
+            };
+            "key-manager=httpsKM" = mkOrder 901 {
+              key-store = "httpsKS";
+              credential-reference.clear-text = "notsosecretpassword";
+            };
+            "server-ssl-context=httpsSSC" = mkOrder 902 {
+              key-manager = "httpsKM";
+            };
+          };
+          "subsystem=undertow" = mkOrder 901 {
+            "server=default-server"."https-listener=https".ssl-context = "httpsSSC";
           };
-          "subsystem=undertow"."server=default-server"."https-listener=https".security-realm = "UndertowRealm";
         })
         cfg.extraConfig
       ];
@@ -441,41 +514,42 @@ in
               # with `expression` to evaluate.
               prefixExpression = string:
                 let
-                  match = (builtins.match ''"\$\{.*}"'' string);
+                  matchResult = match ''"\$\{.*}"'' string;
                 in
-                  if match != null then
-                    "expression " + string
-                  else
-                    string;
+                if matchResult != null then
+                  "expression " + string
+                else
+                  string;
 
               writeAttribute = attribute: value:
                 let
-                  type = builtins.typeOf value;
+                  type = typeOf value;
                 in
-                  if type == "set" then
-                    let
-                      names = builtins.attrNames value;
-                    in
-                      builtins.foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names
-                  else if value == null then ''
-                    if (outcome == success) of ${path}:read-attribute(name="${attribute}")
-                        ${path}:undefine-attribute(name="${attribute}")
+                if type == "set" then
+                  let
+                    names = attrNames value;
+                  in
+                  foldl' (text: name: text + (writeAttribute "${attribute}.${name}" value.${name})) "" names
+                else if value == null then ''
+                  if (outcome == success) of ${path}:read-attribute(name="${attribute}")
+                      ${path}:undefine-attribute(name="${attribute}")
+                  end-if
+                ''
+                else if elem type [ "string" "path" "bool" ] then
+                  let
+                    value' = if type == "bool" then boolToString value else ''"${value}"'';
+                  in
+                  ''
+                    if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}")
+                      ${path}:write-attribute(name=${attribute}, value=${value'})
                     end-if
                   ''
-                  else if builtins.elem type [ "string" "path" "bool" ] then
-                    let
-                      value' = if type == "bool" then lib.boolToString value else ''"${value}"'';
-                    in ''
-                      if (result != ${prefixExpression value'}) of ${path}:read-attribute(name="${attribute}")
-                        ${path}:write-attribute(name=${attribute}, value=${value'})
-                      end-if
-                    ''
-                  else throw "Unsupported type '${type}' for path '${path}'!";
+                else throw "Unsupported type '${type}' for path '${path}'!";
             in
-              lib.concatStrings
-                (lib.mapAttrsToList
-                  (attribute: value: (writeAttribute attribute value))
-                  set);
+            concatStrings
+              (mapAttrsToList
+                (attribute: value: (writeAttribute attribute value))
+                set);
 
 
           /* Produces an argument list for the JBoss `add()` function,
@@ -498,98 +572,108 @@ in
             let
               makeArg = attribute: value:
                 let
-                  type = builtins.typeOf value;
+                  type = typeOf value;
                 in
-                  if type == "set" then
-                    "${attribute} = { " + (makeArgList value) + " }"
-                  else if builtins.elem type [ "string" "path" "bool" ] then
-                    "${attribute} = ${if type == "bool" then lib.boolToString value else ''"${value}"''}"
-                  else if value == null then
-                    ""
-                  else
-                    throw "Unsupported type '${type}' for attribute '${attribute}'!";
+                if type == "set" then
+                  "${attribute} = { " + (makeArgList value) + " }"
+                else if elem type [ "string" "path" "bool" ] then
+                  "${attribute} = ${if type == "bool" then boolToString value else ''"${value}"''}"
+                else if value == null then
+                  ""
+                else
+                  throw "Unsupported type '${type}' for attribute '${attribute}'!";
+
             in
-              lib.concatStringsSep ", " (lib.mapAttrsToList makeArg set);
+            concatStringsSep ", " (mapAttrsToList makeArg set);
 
 
-          /* Recurses into the `attrs` attrset, beginning at the path
-             resolved from `state.path ++ node`; if `node` is `null`,
-             starts from `state.path`. Only subattrsets that are JBoss
-             paths, i.e. follows the `key=value` format, are recursed
+          /* Recurses into the `nodeValue` attrset. Only subattrsets that
+             are JBoss paths, i.e. follows the `key=value` format, are recursed
              into - the rest are considered JBoss attributes / maps.
           */
-          recurse = state: node:
+          recurse = nodePath: nodeValue:
             let
-              path = state.path ++ (lib.optional (node != null) node);
+              nodeContent =
+                if isAttrs nodeValue && nodeValue._type or "" == "order" then
+                  nodeValue.content
+                else
+                  nodeValue;
               isPath = name:
                 let
-                  value = lib.getAttrFromPath (path ++ [ name ]) attrs;
+                  value = nodeContent.${name};
                 in
-                  if (builtins.match ".*([=]).*" name) == [ "=" ] then
-                    if builtins.isAttrs value || value == null then
-                      true
-                    else
-                      throw "Parsing path '${lib.concatStringsSep "." (path ++ [ name ])}' failed: JBoss attributes cannot contain '='!"
+                if (match ".*([=]).*" name) == [ "=" ] then
+                  if isAttrs value || value == null then
+                    true
                   else
-                    false;
-              jbossPath = "/" + (lib.concatStringsSep "/" path);
-              nodeValue = lib.getAttrFromPath path attrs;
-              children = if !builtins.isAttrs nodeValue then {} else nodeValue;
-              subPaths = builtins.filter isPath (builtins.attrNames children);
-              jbossAttrs = lib.filterAttrs (name: _: !(isPath name)) children;
-            in
-              state // {
-                text = state.text + (
-                  if nodeValue != null then ''
+                    throw "Parsing path '${concatStringsSep "." (nodePath ++ [ name ])}' failed: JBoss attributes cannot contain '='!"
+                else
+                  false;
+              jbossPath = "/" + concatStringsSep "/" nodePath;
+              children = if !isAttrs nodeContent then { } else nodeContent;
+              subPaths = filter isPath (attrNames children);
+              getPriority = name:
+                let
+                  value = children.${name};
+                in
+                if value._type or "" == "order" then value.priority else 1000;
+              orderedSubPaths = sort (a: b: getPriority a < getPriority b) subPaths;
+              jbossAttrs = filterAttrs (name: _: !(isPath name)) children;
+              text =
+                if nodeContent != null then
+                  ''
                     if (outcome != success) of ${jbossPath}:read-resource()
                         ${jbossPath}:add(${makeArgList jbossAttrs})
                     end-if
-                  '' + (writeAttributes jbossPath jbossAttrs)
-                  else ''
+                  '' + writeAttributes jbossPath jbossAttrs
+                else
+                  ''
                     if (outcome == success) of ${jbossPath}:read-resource()
                         ${jbossPath}:remove()
                     end-if
-                  '') + (builtins.foldl' recurse { text = ""; inherit path; } subPaths).text;
-              };
+                  '';
+            in
+            text + concatMapStringsSep "\n" (name: recurse (nodePath ++ [ name ]) children.${name}) orderedSubPaths;
         in
-          (recurse { text = ""; path = []; } null).text;
-
+        recurse [ ] attrs;
 
       jbossCliScript = pkgs.writeText "jboss-cli-script" (mkJbossScript keycloakConfig');
 
-      keycloakConfig = pkgs.runCommand "keycloak-config" {
-        nativeBuildInputs = [ cfg.package ];
-      } ''
-        export JBOSS_BASE_DIR="$(pwd -P)";
-        export JBOSS_MODULEPATH="${cfg.package}/modules";
-        export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log";
+      keycloakConfig = pkgs.runCommand "keycloak-config"
+        {
+          nativeBuildInputs = [ cfg.package ];
+        }
+        ''
+          export JBOSS_BASE_DIR="$(pwd -P)";
+          export JBOSS_MODULEPATH="${cfg.package}/modules";
+          export JBOSS_LOG_DIR="$JBOSS_BASE_DIR/log";
 
-        cp -r ${cfg.package}/standalone/configuration .
-        chmod -R u+rwX ./configuration
+          cp -r ${cfg.package}/standalone/configuration .
+          chmod -R u+rwX ./configuration
 
-        mkdir -p {deployments,ssl}
+          mkdir -p {deployments,ssl}
 
-        standalone.sh&
+          standalone.sh&
 
-        attempt=1
-        max_attempts=30
-        while ! jboss-cli.sh --connect ':read-attribute(name=server-state)'; do
-            if [[ "$attempt" == "$max_attempts" ]]; then
-                echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2
-                exit 1
-            fi
-            echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)"
-            sleep 1
-            (( attempt++ ))
-        done
+          attempt=1
+          max_attempts=30
+          while ! jboss-cli.sh --connect ':read-attribute(name=server-state)'; do
+              if [[ "$attempt" == "$max_attempts" ]]; then
+                  echo "ERROR: Could not connect to Keycloak after $attempt attempts! Failing.." >&2
+                  exit 1
+              fi
+              echo "Keycloak not fully started yet, retrying.. ($attempt/$max_attempts)"
+              sleep 1
+              (( attempt++ ))
+          done
 
-        jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
+          jboss-cli.sh --connect --file=${jbossCliScript} --echo-command
 
-        cp configuration/standalone.xml $out
-      '';
+          cp configuration/standalone.xml $out
+        '';
     in
-      lib.mkIf cfg.enable {
-
+    mkIf cfg.enable
+      {
         assertions = [
           {
             assertion = (cfg.database.useSSL && cfg.database.type == "postgresql") -> (cfg.database.caCert != null);
@@ -599,7 +683,7 @@ in
 
         environment.systemPackages = [ cfg.package ];
 
-        systemd.services.keycloakPostgreSQLInit = lib.mkIf createLocalPostgreSQL {
+        systemd.services.keycloakPostgreSQLInit = mkIf createLocalPostgreSQL {
           after = [ "postgresql.service" ];
           before = [ "keycloak.service" ];
           bindsTo = [ "postgresql.service" ];
@@ -623,7 +707,7 @@ in
           '';
         };
 
-        systemd.services.keycloakMySQLInit = lib.mkIf createLocalMySQL {
+        systemd.services.keycloakMySQLInit = mkIf createLocalMySQL {
           after = [ "mysql.service" ];
           before = [ "keycloak.service" ];
           bindsTo = [ "mysql.service" ];
@@ -650,13 +734,16 @@ in
           let
             databaseServices =
               if createLocalPostgreSQL then [
-                "keycloakPostgreSQLInit.service" "postgresql.service"
+                "keycloakPostgreSQLInit.service"
+                "postgresql.service"
               ]
               else if createLocalMySQL then [
-                "keycloakMySQLInit.service" "mysql.service"
+                "keycloakMySQLInit.service"
+                "mysql.service"
               ]
               else [ ];
-          in {
+          in
+          {
             after = databaseServices;
             bindsTo = databaseServices;
             wantedBy = [ "multi-user.target" ];
@@ -671,52 +758,16 @@ in
               JBOSS_MODULEPATH = "${cfg.package}/modules";
             };
             serviceConfig = {
-              ExecStartPre = let
-                startPreFullPrivileges = ''
-                  set -o errexit -o pipefail -o nounset -o errtrace
-                  shopt -s inherit_errexit
-
-                  umask u=rwx,g=,o=
-
-                  install -T -m 0400 -o keycloak -g keycloak '${cfg.database.passwordFile}' /run/keycloak/secrets/db_password
-                '' + lib.optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
-                  install -T -m 0400 -o keycloak -g keycloak '${cfg.sslCertificate}' /run/keycloak/secrets/ssl_cert
-                  install -T -m 0400 -o keycloak -g keycloak '${cfg.sslCertificateKey}' /run/keycloak/secrets/ssl_key
-                '';
-                startPre = ''
-                  set -o errexit -o pipefail -o nounset -o errtrace
-                  shopt -s inherit_errexit
-
-                  umask u=rwx,g=,o=
-
-                  install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
-                  install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
-
-                  replace-secret '@db-password@' '/run/keycloak/secrets/db_password' /run/keycloak/configuration/standalone.xml
-
-                  export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
-                  add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
-                '' + lib.optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
-                  pushd /run/keycloak/ssl/
-                  cat /run/keycloak/secrets/ssl_cert <(echo) \
-                      /run/keycloak/secrets/ssl_key <(echo) \
-                      /etc/ssl/certs/ca-certificates.crt \
-                      > allcerts.pem
-                  openssl pkcs12 -export -in /run/keycloak/secrets/ssl_cert -inkey /run/keycloak/secrets/ssl_key -chain \
-                                 -name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \
-                                 -CAfile allcerts.pem -passout pass:notsosecretpassword
-                  popd
-                '';
-              in [
-                "+${pkgs.writeShellScript "keycloak-start-pre-full-privileges" startPreFullPrivileges}"
-                "${pkgs.writeShellScript "keycloak-start-pre" startPre}"
+              LoadCredential = [
+                "db_password:${cfg.database.passwordFile}"
+              ] ++ optionals (cfg.sslCertificate != null && cfg.sslCertificateKey != null) [
+                "ssl_cert:${cfg.sslCertificate}"
+                "ssl_key:${cfg.sslCertificateKey}"
               ];
-              ExecStart = "${cfg.package}/bin/standalone.sh";
               User = "keycloak";
               Group = "keycloak";
               DynamicUser = true;
               RuntimeDirectory = map (p: "keycloak/" + p) [
-                "secrets"
                 "configuration"
                 "deployments"
                 "data"
@@ -728,13 +779,39 @@ in
               LogsDirectory = "keycloak";
               AmbientCapabilities = "CAP_NET_BIND_SERVICE";
             };
+            script = ''
+              set -o errexit -o pipefail -o nounset -o errtrace
+              shopt -s inherit_errexit
+
+              umask u=rwx,g=,o=
+
+              install -m 0600 ${cfg.package}/standalone/configuration/*.properties /run/keycloak/configuration
+              install -T -m 0600 ${keycloakConfig} /run/keycloak/configuration/standalone.xml
+
+              replace-secret '@db-password@' "$CREDENTIALS_DIRECTORY/db_password" /run/keycloak/configuration/standalone.xml
+
+              export JAVA_OPTS=-Djboss.server.config.user.dir=/run/keycloak/configuration
+              add-user-keycloak.sh -u admin -p '${cfg.initialAdminPassword}'
+            '' + optionalString (cfg.sslCertificate != null && cfg.sslCertificateKey != null) ''
+              pushd /run/keycloak/ssl/
+              cat "$CREDENTIALS_DIRECTORY/ssl_cert" <(echo) \
+                  "$CREDENTIALS_DIRECTORY/ssl_key" <(echo) \
+                  /etc/ssl/certs/ca-certificates.crt \
+                  > allcerts.pem
+              openssl pkcs12 -export -in "$CREDENTIALS_DIRECTORY/ssl_cert" -inkey "$CREDENTIALS_DIRECTORY/ssl_key" -chain \
+                             -name "${cfg.frontendUrl}" -out certificate_private_key_bundle.p12 \
+                             -CAfile allcerts.pem -passout pass:notsosecretpassword
+              popd
+            '' + ''
+              ${cfg.package}/bin/standalone.sh
+            '';
           };
 
-        services.postgresql.enable = lib.mkDefault createLocalPostgreSQL;
-        services.mysql.enable = lib.mkDefault createLocalMySQL;
-        services.mysql.package = lib.mkIf createLocalMySQL pkgs.mariadb;
+        services.postgresql.enable = mkDefault createLocalPostgreSQL;
+        services.mysql.enable = mkDefault createLocalMySQL;
+        services.mysql.package = mkIf createLocalMySQL pkgs.mariadb;
       };
 
   meta.doc = ./keycloak.xml;
-  meta.maintainers = [ lib.maintainers.talyz ];
+  meta.maintainers = [ maintainers.talyz ];
 }
diff --git a/nixos/modules/services/web-apps/keycloak.xml b/nixos/modules/services/web-apps/keycloak.xml
index 7ba656c20f1..cb706932f48 100644
--- a/nixos/modules/services/web-apps/keycloak.xml
+++ b/nixos/modules/services/web-apps/keycloak.xml
@@ -85,7 +85,12 @@
        The frontend URL is used as base for all frontend requests and
        must be configured through <xref linkend="opt-services.keycloak.frontendUrl" />.
        It should normally include a trailing <literal>/auth</literal>
-       (the default web context).
+       (the default web context). If you use a reverse proxy, you need
+       to set this option to <literal>""</literal>, so that frontend URL
+       is derived from HTTP headers. <literal>X-Forwarded-*</literal> headers
+       support also should be enabled, using <link
+       xlink:href="https://www.keycloak.org/docs/latest/server_installation/index.html#identifying-client-ip-addresses">
+       respective guidelines</link>.
      </para>
 
      <para>
@@ -131,6 +136,17 @@
      </warning>
    </section>
 
+   <section xml:id="module-services-keycloak-themes">
+     <title>Themes</title>
+     <para>
+        You can package custom themes and make them visible to Keycloak via
+        <xref linkend="opt-services.keycloak.themes" />
+        option. See the <link xlink:href="https://www.keycloak.org/docs/latest/server_development/#_themes">
+        Themes section of the Keycloak Server Development Guide</link>
+        and respective NixOS option description for more information.
+     </para>
+   </section>
+
    <section xml:id="module-services-keycloak-extra-config">
      <title>Additional configuration</title>
      <para>
diff --git a/nixos/modules/services/web-apps/mattermost.nix b/nixos/modules/services/web-apps/mattermost.nix
index 310a673f511..2901f307dc5 100644
--- a/nixos/modules/services/web-apps/mattermost.nix
+++ b/nixos/modules/services/web-apps/mattermost.nix
@@ -181,7 +181,7 @@ in
         description = ''
           Plugins to add to the configuration. Overrides any installed if non-null.
           This is a list of paths to .tar.gz files or derivations evaluating to
-          .tar.gz files. All entries will be passed to `mattermost plugin add`.
+          .tar.gz files.
         '';
       };
 
diff --git a/nixos/modules/services/web-apps/prosody-filer.nix b/nixos/modules/services/web-apps/prosody-filer.nix
index 6a52c36ab2c..a901a95fd5f 100644
--- a/nixos/modules/services/web-apps/prosody-filer.nix
+++ b/nixos/modules/services/web-apps/prosody-filer.nix
@@ -21,12 +21,10 @@ in {
 
         type = settingsFormat.type;
 
-        example = literalExample ''
-          {
-            secret = "mysecret";
-            storeDir = "/srv/http/nginx/prosody-upload";
-          }
-        '';
+        example = {
+          secret = "mysecret";
+          storeDir = "/srv/http/nginx/prosody-upload";
+        };
 
         defaultText = literalExpression ''
           {