summary refs log tree commit diff
path: root/nixos/modules/services/matrix
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules/services/matrix')
-rw-r--r--nixos/modules/services/matrix/matrix-synapse-log_config.yaml25
-rw-r--r--nixos/modules/services/matrix/matrix-synapse.nix773
-rw-r--r--nixos/modules/services/matrix/matrix-synapse.xml231
-rw-r--r--nixos/modules/services/matrix/mjolnir.nix242
-rw-r--r--nixos/modules/services/matrix/mjolnir.xml134
-rw-r--r--nixos/modules/services/matrix/pantalaimon-options.nix70
-rw-r--r--nixos/modules/services/matrix/pantalaimon.nix70
7 files changed, 1545 insertions, 0 deletions
diff --git a/nixos/modules/services/matrix/matrix-synapse-log_config.yaml b/nixos/modules/services/matrix/matrix-synapse-log_config.yaml
new file mode 100644
index 00000000000..d85bdd1208f
--- /dev/null
+++ b/nixos/modules/services/matrix/matrix-synapse-log_config.yaml
@@ -0,0 +1,25 @@
+version: 1
+
+# In systemd's journal, loglevel is implicitly stored, so let's omit it
+# from the message text.
+formatters:
+    journal_fmt:
+        format: '%(name)s: [%(request)s] %(message)s'
+
+filters:
+    context:
+        (): synapse.util.logcontext.LoggingContextFilter
+        request: ""
+
+handlers:
+    journal:
+        class: systemd.journal.JournalHandler
+        formatter: journal_fmt
+        filters: [context]
+        SYSLOG_IDENTIFIER: synapse
+
+root:
+    level: INFO
+    handlers: [journal]
+
+disable_existing_loggers: False
diff --git a/nixos/modules/services/matrix/matrix-synapse.nix b/nixos/modules/services/matrix/matrix-synapse.nix
new file mode 100644
index 00000000000..c4d14dbd547
--- /dev/null
+++ b/nixos/modules/services/matrix/matrix-synapse.nix
@@ -0,0 +1,773 @@
+{ config, lib, options, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.matrix-synapse;
+  format = pkgs.formats.yaml {};
+
+  # remove null values from the final configuration
+  finalSettings = lib.filterAttrsRecursive (_: v: v != null) cfg.settings;
+  configFile = format.generate "homeserver.yaml" finalSettings;
+  logConfigFile = format.generate "log_config.yaml" cfg.logConfig;
+
+  pluginsEnv = cfg.package.python.buildEnv.override {
+    extraLibs = cfg.plugins;
+  };
+
+  usePostgresql = cfg.settings.database.name == "psycopg2";
+  hasLocalPostgresDB = let args = cfg.settings.database.args; in
+    usePostgresql && (!(args ? host) || (elem args.host [ "localhost" "127.0.0.1" "::1" ]));
+
+  registerNewMatrixUser =
+    let
+      isIpv6 = x: lib.length (lib.splitString ":" x) > 1;
+      listener =
+        lib.findFirst (
+          listener: lib.any (
+            resource: lib.any (
+              name: name == "client"
+            ) resource.names
+          ) listener.resources
+        ) (lib.last cfg.settings.listeners) cfg.settings.listeners;
+        # FIXME: Handle cases with missing client listener properly,
+        # don't rely on lib.last, this will not work.
+
+      # add a tail, so that without any bind_addresses we still have a useable address
+      bindAddress = head (listener.bind_addresses ++ [ "127.0.0.1" ]);
+      listenerProtocol = if listener.tls
+        then "https"
+        else "http";
+    in
+    pkgs.writeShellScriptBin "matrix-synapse-register_new_matrix_user" ''
+      exec ${cfg.package}/bin/register_new_matrix_user \
+        $@ \
+        ${lib.concatMapStringsSep " " (x: "-c ${x}") ([ configFile ] ++ cfg.extraConfigFiles)} \
+        "${listenerProtocol}://${
+          if (isIpv6 bindAddress) then
+            "[${bindAddress}]"
+          else
+            "${bindAddress}"
+        }:${builtins.toString listener.port}/"
+    '';
+in {
+
+  imports = [
+
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "trusted_third_party_id_servers" ] ''
+      The `trusted_third_party_id_servers` option as been removed in `matrix-synapse` v1.4.0
+      as the behavior is now obsolete.
+    '')
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "create_local_database" ] ''
+      Database configuration must be done manually. An exemplary setup is demonstrated in
+      <nixpkgs/nixos/tests/matrix-synapse.nix>
+    '')
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "web_client" ] "")
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "room_invite_state_types" ] ''
+      You may add additional event types via
+      `services.matrix-synapse.room_prejoin_state.additional_event_types` and
+      disable the default events via
+      `services.matrix-synapse.room_prejoin_state.disable_default_event_types`.
+    '')
+
+    # options that don't exist in synapse anymore
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "bind_host" ] "Use listener settings instead." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "bind_port" ] "Use listener settings instead." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "expire_access_tokens" ] "" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "no_tls" ] "It is no longer supported by synapse." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "tls_dh_param_path" ] "It was removed from synapse." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "unsecure_port" ] "Use settings.listeners instead." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "user_creation_max_duration" ] "It is no longer supported by synapse." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "verbose" ] "Use a log config instead." )
+
+    # options that were moved into rfc42 style settigns
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "app_service_config_files" ] "Use settings.app_service_config_Files instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "database_args" ] "Use settings.database.args instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "database_name" ] "Use settings.database.args.database instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "database_type" ] "Use settings.database.name instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "database_user" ] "Use settings.database.args.user instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "dynamic_thumbnails" ] "Use settings.dynamic_thumbnails instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "enable_metrics" ] "Use settings.enable_metrics instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "enable_registration" ] "Use settings.enable_registration instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "extraConfig" ] "Use settings instead." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "listeners" ] "Use settings.listeners instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "logConfig" ] "Use settings.log_config instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "max_image_pixels" ] "Use settings.max_image_pixels instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "max_upload_size" ] "Use settings.max_upload_size instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "presence" "enabled" ] "Use settings.presence.enabled instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "public_baseurl" ] "Use settings.public_baseurl instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "report_stats" ] "Use settings.report_stats instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "server_name" ] "Use settings.server_name instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "servers" ] "Use settings.trusted_key_servers instead." )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "tls_certificate_path" ] "Use settings.tls_certificate_path instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "tls_private_key_path" ] "Use settings.tls_private_key_path instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "turn_shared_secret" ] "Use settings.turn_shared_secret instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "turn_uris" ] "Use settings.turn_uris instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "turn_user_lifetime" ] "Use settings.turn_user_lifetime instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "url_preview_enabled" ] "Use settings.url_preview_enabled instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "url_preview_ip_range_blacklist" ] "Use settings.url_preview_ip_range_blacklist instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "url_preview_ip_range_whitelist" ] "Use settings.url_preview_ip_range_whitelist instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "url_preview_url_blacklist" ] "Use settings.url_preview_url_blacklist instead" )
+
+    # options that are too specific to mention them explicitly in settings
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "account_threepid_delegates" "email" ] "Use settings.account_threepid_delegates.email instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "account_threepid_delegates" "msisdn" ] "Use settings.account_threepid_delegates.msisdn instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "allow_guest_access" ] "Use settings.allow_guest_access instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "bcrypt_rounds" ] "Use settings.bcrypt_rounds instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "enable_registration_captcha" ] "Use settings.enable_registration_captcha instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "event_cache_size" ] "Use settings.event_cache_size instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "federation_rc_concurrent" ] "Use settings.rc_federation.concurrent instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "federation_rc_reject_limit" ] "Use settings.rc_federation.reject_limit instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "federation_rc_sleep_delay" ] "Use settings.rc_federation.sleep_delay instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "federation_rc_sleep_limit" ] "Use settings.rc_federation.sleep_limit instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "federation_rc_window_size" ] "Use settings.rc_federation.window_size instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "key_refresh_interval" ] "Use settings.key_refresh_interval instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "rc_messages_burst_count" ] "Use settings.rc_messages.burst_count instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "rc_messages_per_second" ] "Use settings.rc_messages.per_second instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "recaptcha_private_key" ] "Use settings.recaptcha_private_key instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "recaptcha_public_key" ] "Use settings.recaptcha_public_key instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "redaction_retention_period" ] "Use settings.redaction_retention_period instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "room_prejoin_state" "additional_event_types" ] "Use settings.room_prejoin_state.additional_event_types instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "room_prejoin_state" "disable_default_event_types" ] "Use settings.room_prejoin-state.disable_default_event_types instead" )
+
+    # Options that should be passed via extraConfigFiles, so they are not persisted into the nix store
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "macaroon_secret_key" ] "Pass this value via extraConfigFiles instead" )
+    (mkRemovedOptionModule [ "services" "matrix-synapse" "registration_shared_secret" ] "Pass this value via extraConfigFiles instead" )
+
+  ];
+
+  options = {
+    services.matrix-synapse = {
+      enable = mkEnableOption "matrix.org synapse";
+
+      configFile = mkOption {
+        type = types.str;
+        readOnly = true;
+        description = ''
+          Path to the configuration file on the target system. Useful to configure e.g. workers
+          that also need this.
+        '';
+      };
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.matrix-synapse;
+        defaultText = literalExpression "pkgs.matrix-synapse";
+        description = ''
+          Overridable attribute of the matrix synapse server package to use.
+        '';
+      };
+
+      plugins = mkOption {
+        type = types.listOf types.package;
+        default = [ ];
+        example = literalExpression ''
+          with config.services.matrix-synapse.package.plugins; [
+            matrix-synapse-ldap3
+            matrix-synapse-pam
+          ];
+        '';
+        description = ''
+          List of additional Matrix plugins to make available.
+        '';
+      };
+
+      withJemalloc = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether to preload jemalloc to reduce memory fragmentation and overall usage.
+        '';
+      };
+
+      dataDir = mkOption {
+        type = types.str;
+        default = "/var/lib/matrix-synapse";
+        description = ''
+          The directory where matrix-synapse stores its stateful data such as
+          certificates, media and uploads.
+        '';
+      };
+
+      settings = mkOption {
+        default = {};
+        description = ''
+          The primary synapse configuration. See the
+          <link xlink:href="https://github.com/matrix-org/synapse/blob/v${cfg.package.version}/docs/sample_config.yaml">sample configuration</link>
+          for possible values.
+
+          Secrets should be passed in by using the <literal>extraConfigFiles</literal> option.
+        '';
+        type = with types; submodule {
+          freeformType = format.type;
+          options = {
+            # This is a reduced set of popular options and defaults
+            # Do not add every available option here, they can be specified
+            # by the user at their own discretion. This is a freeform type!
+
+            server_name = mkOption {
+              type = types.str;
+              example = "example.com";
+              default = config.networking.hostName;
+              defaultText = literalExpression "config.networking.hostName";
+              description = ''
+                The domain name of the server, with optional explicit port.
+                This is used by remote servers to look up the server address.
+                This is also the last part of your UserID.
+
+                The server_name cannot be changed later so it is important to configure this correctly before you start Synapse.
+              '';
+            };
+
+            enable_registration = mkOption {
+              type = types.bool;
+              default = false;
+              description = ''
+                Enable registration for new users.
+              '';
+            };
+
+            registration_shared_secret = mkOption {
+              type = types.nullOr types.str;
+              default = null;
+              description = ''
+                If set, allows registration by anyone who also has the shared
+                secret, even if registration is otherwise disabled.
+
+                Secrets should be passed in via <literal>extraConfigFiles</literal>!
+              '';
+            };
+
+            macaroon_secret_key = mkOption {
+              type = types.nullOr types.str;
+              default = null;
+              description = ''
+                Secret key for authentication tokens. If none is specified,
+                the registration_shared_secret is used, if one is given; otherwise,
+                a secret key is derived from the signing key.
+
+                Secrets should be passed in via <literal>extraConfigFiles</literal>!
+              '';
+            };
+
+            enable_metrics = mkOption {
+              type = types.bool;
+              default = false;
+              description = ''
+                Enable collection and rendering of performance metrics
+              '';
+            };
+
+            report_stats = mkOption {
+              type = types.bool;
+              default = false;
+              description = ''
+                Whether or not to report anonymized homeserver usage statistics.
+              '';
+            };
+
+            signing_key_path = mkOption {
+              type = types.path;
+              default = "${cfg.dataDir}/homeserver.signing.key";
+              description = ''
+                Path to the signing key to sign messages with.
+              '';
+            };
+
+            pid_file = mkOption {
+              type = types.path;
+              default = "/run/matrix-synapse.pid";
+              readOnly = true;
+              description = ''
+                The file to store the PID in.
+              '';
+            };
+
+            log_config = mkOption {
+              type = types.path;
+              default = ./matrix-synapse-log_config.yaml;
+              description = ''
+                The file that holds the logging configuration.
+              '';
+            };
+
+            media_store_path = mkOption {
+              type = types.path;
+              default = if lib.versionAtLeast config.system.stateVersion "22.05"
+                then "${cfg.dataDir}/media_store"
+                else "${cfg.dataDir}/media";
+              description = ''
+                Directory where uploaded images and attachments are stored.
+              '';
+            };
+
+            public_baseurl = mkOption {
+              type = types.nullOr types.str;
+              default = null;
+              example = "https://example.com:8448/";
+              description = ''
+                The public-facing base URL for the client API (not including _matrix/...)
+              '';
+            };
+
+            tls_certificate_path = mkOption {
+              type = types.nullOr types.str;
+              default = null;
+              example = "/var/lib/acme/example.com/fullchain.pem";
+              description = ''
+                PEM encoded X509 certificate for TLS.
+                You can replace the self-signed certificate that synapse
+                autogenerates on launch with your own SSL certificate + key pair
+                if you like.  Any required intermediary certificates can be
+                appended after the primary certificate in hierarchical order.
+              '';
+            };
+
+            tls_private_key_path = mkOption {
+              type = types.nullOr types.str;
+              default = null;
+              example = "/var/lib/acme/example.com/key.pem";
+              description = ''
+                PEM encoded private key for TLS. Specify null if synapse is not
+                speaking TLS directly.
+              '';
+            };
+
+            presence.enabled = mkOption {
+              type = types.bool;
+              default = true;
+              example = false;
+              description = ''
+                Whether to enable presence tracking.
+
+                Presence tracking allows users to see the state (e.g online/offline)
+                of other local and remote users.
+              '';
+            };
+
+            listeners = mkOption {
+              type = types.listOf (types.submodule {
+                options = {
+                  port = mkOption {
+                    type = types.port;
+                    example = 8448;
+                    description = ''
+                      The port to listen for HTTP(S) requests on.
+                    '';
+                  };
+
+                  bind_addresses = mkOption {
+                    type = types.listOf types.str;
+                    default = [
+                      "::1"
+                      "127.0.0.1"
+                    ];
+                    example = literalExpression ''
+                    [
+                      "::"
+                      "0.0.0.0"
+                    ]
+                    '';
+                    description = ''
+                     IP addresses to bind the listener to.
+                    '';
+                  };
+
+                  type = mkOption {
+                    type = types.enum [
+                      "http"
+                      "manhole"
+                      "metrics"
+                      "replication"
+                    ];
+                    default = "http";
+                    example = "metrics";
+                    description = ''
+                      The type of the listener, usually http.
+                    '';
+                  };
+
+                  tls = mkOption {
+                    type = types.bool;
+                    default = true;
+                    example = false;
+                    description = ''
+                      Whether to enable TLS on the listener socket.
+                    '';
+                  };
+
+                  x_forwarded = mkOption {
+                    type = types.bool;
+                    default = false;
+                    example = true;
+                    description = ''
+                      Use the X-Forwarded-For (XFF) header as the client IP and not the
+                      actual client IP.
+                    '';
+                  };
+
+                  resources = mkOption {
+                    type = types.listOf (types.submodule {
+                      options = {
+                        names = mkOption {
+                          type = types.listOf (types.enum [
+                            "client"
+                            "consent"
+                            "federation"
+                            "keys"
+                            "media"
+                            "metrics"
+                            "openid"
+                            "replication"
+                            "static"
+                          ]);
+                          description = ''
+                            List of resources to host on this listener.
+                          '';
+                          example = [
+                            "client"
+                          ];
+                        };
+                        compress = mkOption {
+                          type = types.bool;
+                          description = ''
+                            Should synapse compress HTTP responses to clients that support it?
+                            This should be disabled if running synapse behind a load balancer
+                            that can do automatic compression.
+                          '';
+                        };
+                      };
+                    });
+                    description = ''
+                      List of HTTP resources to serve on this listener.
+                    '';
+                  };
+                };
+              });
+              default = [ {
+                port = 8008;
+                bind_addresses = [ "127.0.0.1" ];
+                type = "http";
+                tls = false;
+                x_forwarded = true;
+                resources = [ {
+                  names = [ "client" ];
+                  compress = true;
+                } {
+                  names = [ "federation" ];
+                  compress = false;
+                } ];
+              } ];
+              description = ''
+                List of ports that Synapse should listen on, their purpose and their configuration.
+              '';
+            };
+
+            database.name = mkOption {
+              type = types.enum [
+                "sqlite3"
+                "psycopg2"
+              ];
+              default = if versionAtLeast config.system.stateVersion "18.03"
+                then "psycopg2"
+                else "sqlite3";
+               defaultText = literalExpression ''
+                if versionAtLeast config.system.stateVersion "18.03"
+                then "psycopg2"
+                else "sqlite3"
+              '';
+              description = ''
+                The database engine name. Can be sqlite3 or psycopg2.
+              '';
+            };
+
+            database.args.database = mkOption {
+              type = types.str;
+              default = {
+                sqlite3 = "${cfg.dataDir}/homeserver.db";
+                psycopg2 = "matrix-synapse";
+              }.${cfg.settings.database.name};
+              defaultText = literalExpression ''
+              {
+                sqlite3 = "''${${options.services.matrix-synapse.dataDir}}/homeserver.db";
+                psycopg2 = "matrix-synapse";
+              }.''${${options.services.matrix-synapse.settings}.database.name};
+              '';
+              description = ''
+                Name of the database when using the psycopg2 backend,
+                path to the database location when using sqlite3.
+              '';
+            };
+
+            database.args.user = mkOption {
+              type = types.nullOr types.str;
+              default = {
+                sqlite3 = null;
+                psycopg2 = "matrix-synapse";
+              }.${cfg.settings.database.name};
+              description = ''
+                Username to connect with psycopg2, set to null
+                when using sqlite3.
+              '';
+            };
+
+            url_preview_enabled = mkOption {
+              type = types.bool;
+              default = true;
+              example = false;
+              description = ''
+                Is the preview URL API enabled?  If enabled, you *must* specify an
+                explicit url_preview_ip_range_blacklist of IPs that the spider is
+                denied from accessing.
+              '';
+            };
+
+            url_preview_ip_range_blacklist = mkOption {
+              type = types.listOf types.str;
+              default = [
+                "10.0.0.0/8"
+                "100.64.0.0/10"
+                "127.0.0.0/8"
+                "169.254.0.0/16"
+                "172.16.0.0/12"
+                "192.0.0.0/24"
+                "192.0.2.0/24"
+                "192.168.0.0/16"
+                "192.88.99.0/24"
+                "198.18.0.0/15"
+                "198.51.100.0/24"
+                "2001:db8::/32"
+                "203.0.113.0/24"
+                "224.0.0.0/4"
+                "::1/128"
+                "fc00::/7"
+                "fe80::/10"
+                "fec0::/10"
+                "ff00::/8"
+              ];
+              description = ''
+                List of IP address CIDR ranges that the URL preview spider is denied
+                from accessing.
+              '';
+            };
+
+            url_preview_ip_range_whitelist = mkOption {
+              type = types.listOf types.str;
+              default = [];
+              description = ''
+                List of IP address CIDR ranges that the URL preview spider is allowed
+                to access even if they are specified in url_preview_ip_range_blacklist.
+              '';
+            };
+
+            url_preview_url_blacklist = mkOption {
+              type = types.listOf types.str;
+              default = [];
+              description = ''
+                Optional list of URL matches that the URL preview spider is
+                denied from accessing.
+              '';
+            };
+
+            max_upload_size = mkOption {
+              type = types.str;
+              default = "50M";
+              example = "100M";
+              description = ''
+                The largest allowed upload size in bytes
+              '';
+            };
+
+            max_image_pixels = mkOption {
+              type = types.str;
+              default = "32M";
+              example = "64M";
+              description = ''
+                Maximum number of pixels that will be thumbnailed
+              '';
+            };
+
+            dynamic_thumbnails = mkOption {
+              type = types.bool;
+              default = false;
+              example = true;
+              description = ''
+                Whether to generate new thumbnails on the fly to precisely match
+                the resolution requested by the client. If true then whenever
+                a new resolution is requested by the client the server will
+                generate a new thumbnail. If false the server will pick a thumbnail
+                from a precalculated list.
+              '';
+            };
+
+            turn_uris = mkOption {
+              type = types.listOf types.str;
+              default = [];
+              example = [
+                "turn:turn.example.com:3487?transport=udp"
+                "turn:turn.example.com:3487?transport=tcp"
+                "turns:turn.example.com:5349?transport=udp"
+                "turns:turn.example.com:5349?transport=tcp"
+              ];
+              description = ''
+                The public URIs of the TURN server to give to clients
+              '';
+            };
+            turn_shared_secret = mkOption {
+              type = types.str;
+              default = "";
+              example = literalExpression ''
+                config.services.coturn.static-auth-secret
+              '';
+              description = ''
+                The shared secret used to compute passwords for the TURN server.
+
+                Secrets should be passed in via <literal>extraConfigFiles</literal>!
+              '';
+            };
+
+            trusted_key_servers = mkOption {
+              type = types.listOf (types.submodule {
+                options = {
+                  server_name = mkOption {
+                    type = types.str;
+                    example = "matrix.org";
+                    description = ''
+                      Hostname of the trusted server.
+                    '';
+                  };
+
+                  verify_keys = mkOption {
+                    type = types.nullOr (types.attrsOf types.str);
+                    default = null;
+                    example = literalExpression ''
+                      {
+                        "ed25519:auto" = "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw";
+                      }
+                    '';
+                    description = ''
+                      Attribute set from key id to base64 encoded public key.
+
+                      If specified synapse will check that the response is signed
+                      by at least one of the given keys.
+                    '';
+                  };
+                };
+              });
+              default = [ {
+                server_name = "matrix.org";
+                verify_keys = {
+                  "ed25519:auto" = "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw";
+                };
+              } ];
+              description = ''
+                The trusted servers to download signing keys from.
+              '';
+            };
+
+            app_service_config_files = mkOption {
+              type = types.listOf types.path;
+              default = [ ];
+              description = ''
+                A list of application service config file to use
+              '';
+            };
+
+          };
+        };
+      };
+
+      extraConfigFiles = mkOption {
+        type = types.listOf types.path;
+        default = [];
+        description = ''
+          Extra config files to include.
+
+          The configuration files will be included based on the command line
+          argument --config-path. This allows to configure secrets without
+          having to go through the Nix store, e.g. based on deployment keys if
+          NixOps is in use.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = [
+      { assertion = hasLocalPostgresDB -> config.services.postgresql.enable;
+        message = ''
+          Cannot deploy matrix-synapse with a configuration for a local postgresql database
+            and a missing postgresql service. Since 20.03 it's mandatory to manually configure the
+            database (please read the thread in https://github.com/NixOS/nixpkgs/pull/80447 for
+            further reference).
+
+            If you
+            - try to deploy a fresh synapse, you need to configure the database yourself. An example
+              for this can be found in <nixpkgs/nixos/tests/matrix-synapse.nix>
+            - update your existing matrix-synapse instance, you simply need to add `services.postgresql.enable = true`
+              to your configuration.
+
+          For further information about this update, please read the release-notes of 20.03 carefully.
+        '';
+      }
+    ];
+
+    services.matrix-synapse.configFile = configFile;
+
+    users.users.matrix-synapse = {
+      group = "matrix-synapse";
+      home = cfg.dataDir;
+      createHome = true;
+      shell = "${pkgs.bash}/bin/bash";
+      uid = config.ids.uids.matrix-synapse;
+    };
+
+    users.groups.matrix-synapse = {
+      gid = config.ids.gids.matrix-synapse;
+    };
+
+    systemd.services.matrix-synapse = {
+      description = "Synapse Matrix homeserver";
+      after = [ "network.target" ] ++ optional hasLocalPostgresDB "postgresql.service";
+      wantedBy = [ "multi-user.target" ];
+      preStart = ''
+        ${cfg.package}/bin/synapse_homeserver \
+          --config-path ${configFile} \
+          --keys-directory ${cfg.dataDir} \
+          --generate-keys
+      '';
+      environment = {
+        PYTHONPATH = makeSearchPathOutput "lib" cfg.package.python.sitePackages [ pluginsEnv ];
+      } // optionalAttrs (cfg.withJemalloc) {
+        LD_PRELOAD = "${pkgs.jemalloc}/lib/libjemalloc.so";
+      };
+      serviceConfig = {
+        Type = "notify";
+        User = "matrix-synapse";
+        Group = "matrix-synapse";
+        WorkingDirectory = cfg.dataDir;
+        ExecStartPre = [ ("+" + (pkgs.writeShellScript "matrix-synapse-fix-permissions" ''
+          chown matrix-synapse:matrix-synapse ${cfg.dataDir}/homeserver.signing.key
+          chmod 0600 ${cfg.dataDir}/homeserver.signing.key
+        '')) ];
+        ExecStart = ''
+          ${cfg.package}/bin/synapse_homeserver \
+            ${ concatMapStringsSep "\n  " (x: "--config-path ${x} \\") ([ configFile ] ++ cfg.extraConfigFiles) }
+            --keys-directory ${cfg.dataDir}
+        '';
+        ExecReload = "${pkgs.util-linux}/bin/kill -HUP $MAINPID";
+        Restart = "on-failure";
+        UMask = "0077";
+      };
+    };
+
+    environment.systemPackages = [ registerNewMatrixUser ];
+  };
+
+  meta = {
+    buildDocsInSandbox = false;
+    doc = ./matrix-synapse.xml;
+    maintainers = teams.matrix.members;
+  };
+
+}
diff --git a/nixos/modules/services/matrix/matrix-synapse.xml b/nixos/modules/services/matrix/matrix-synapse.xml
new file mode 100644
index 00000000000..cf33957d58e
--- /dev/null
+++ b/nixos/modules/services/matrix/matrix-synapse.xml
@@ -0,0 +1,231 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-matrix">
+ <title>Matrix</title>
+ <para>
+  <link xlink:href="https://matrix.org/">Matrix</link> is an open standard for
+  interoperable, decentralised, real-time communication over IP. It can be used
+  to power Instant Messaging, VoIP/WebRTC signalling, Internet of Things
+  communication - or anywhere you need a standard HTTP API for publishing and
+  subscribing to data whilst tracking the conversation history.
+ </para>
+ <para>
+  This chapter will show you how to set up your own, self-hosted Matrix
+  homeserver using the Synapse reference homeserver, and how to serve your own
+  copy of the Element web client. See the
+  <link xlink:href="https://matrix.org/docs/projects/try-matrix-now.html">Try
+  Matrix Now!</link> overview page for links to Element Apps for Android and iOS,
+  desktop clients, as well as bridges to other networks and other projects
+  around Matrix.
+ </para>
+ <section xml:id="module-services-matrix-synapse">
+  <title>Synapse Homeserver</title>
+
+  <para>
+   <link xlink:href="https://github.com/matrix-org/synapse">Synapse</link> is
+   the reference homeserver implementation of Matrix from the core development
+   team at matrix.org. The following configuration example will set up a
+   synapse server for the <literal>example.org</literal> domain, served from
+   the host <literal>myhostname.example.org</literal>. For more information,
+   please refer to the
+   <link xlink:href="https://github.com/matrix-org/synapse#synapse-installation">
+   installation instructions of Synapse </link>.
+<programlisting>
+{ pkgs, lib, ... }:
+let
+  fqdn =
+    let
+      join = hostName: domain: hostName + lib.optionalString (domain != null) ".${domain}";
+    in join config.networking.hostName config.networking.domain;
+in {
+  networking = {
+    <link linkend="opt-networking.hostName">hostName</link> = "myhostname";
+    <link linkend="opt-networking.domain">domain</link> = "example.org";
+  };
+  <link linkend="opt-networking.firewall.allowedTCPPorts">networking.firewall.allowedTCPPorts</link> = [ 80 443 ];
+
+  <link linkend="opt-services.postgresql.enable">services.postgresql.enable</link> = true;
+  <link linkend="opt-services.postgresql.initialScript">services.postgresql.initialScript</link> = pkgs.writeText "synapse-init.sql" ''
+    CREATE ROLE "matrix-synapse" WITH LOGIN PASSWORD 'synapse';
+    CREATE DATABASE "matrix-synapse" WITH OWNER "matrix-synapse"
+      TEMPLATE template0
+      LC_COLLATE = "C"
+      LC_CTYPE = "C";
+  '';
+
+  services.nginx = {
+    <link linkend="opt-services.nginx.enable">enable</link> = true;
+    # only recommendedProxySettings and recommendedGzipSettings are strictly required,
+    # but the rest make sense as well
+    <link linkend="opt-services.nginx.recommendedTlsSettings">recommendedTlsSettings</link> = true;
+    <link linkend="opt-services.nginx.recommendedOptimisation">recommendedOptimisation</link> = true;
+    <link linkend="opt-services.nginx.recommendedGzipSettings">recommendedGzipSettings</link> = true;
+    <link linkend="opt-services.nginx.recommendedProxySettings">recommendedProxySettings</link> = true;
+
+    <link linkend="opt-services.nginx.virtualHosts">virtualHosts</link> = {
+      # This host section can be placed on a different host than the rest,
+      # i.e. to delegate from the host being accessible as ${config.networking.domain}
+      # to another host actually running the Matrix homeserver.
+      "${config.networking.domain}" = {
+        <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true;
+        <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
+
+        <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.extraConfig">locations."= /.well-known/matrix/server".extraConfig</link> =
+          let
+            # use 443 instead of the default 8448 port to unite
+            # the client-server and server-server port for simplicity
+            server = { "m.server" = "${fqdn}:443"; };
+          in ''
+            add_header Content-Type application/json;
+            return 200 '${builtins.toJSON server}';
+          '';
+        <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.extraConfig">locations."= /.well-known/matrix/client".extraConfig</link> =
+          let
+            client = {
+              "m.homeserver" =  { "base_url" = "https://${fqdn}"; };
+              "m.identity_server" =  { "base_url" = "https://vector.im"; };
+            };
+          # ACAO required to allow element-web on any URL to request this json file
+          in ''
+            add_header Content-Type application/json;
+            add_header Access-Control-Allow-Origin *;
+            return 200 '${builtins.toJSON client}';
+          '';
+      };
+
+      # Reverse proxy for Matrix client-server and server-server communication
+      ${fqdn} = {
+        <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true;
+        <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
+
+        # Or do a redirect instead of the 404, or whatever is appropriate for you.
+        # But do not put a Matrix Web client here! See the Element web section below.
+        <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.extraConfig">locations."/".extraConfig</link> = ''
+          return 404;
+        '';
+
+        # forward all Matrix API calls to the synapse Matrix homeserver
+        locations."/_matrix" = {
+          <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.proxyPass">proxyPass</link> = "http://[::1]:8008"; # without a trailing /
+        };
+      };
+    };
+  };
+  services.matrix-synapse = {
+    <link linkend="opt-services.matrix-synapse.enable">enable</link> = true;
+    <link linkend="opt-services.matrix-synapse.settings.server_name">server_name</link> = config.networking.domain;
+    <link linkend="opt-services.matrix-synapse.settings.listeners">listeners</link> = [
+      {
+        <link linkend="opt-services.matrix-synapse.settings.listeners._.port">port</link> = 8008;
+        <link linkend="opt-services.matrix-synapse.settings.listeners._.bind_addresses">bind_addresses</link> = [ "::1" ];
+        <link linkend="opt-services.matrix-synapse.settings.listeners._.type">type</link> = "http";
+        <link linkend="opt-services.matrix-synapse.settings.listeners._.tls">tls</link> = false;
+        <link linkend="opt-services.matrix-synapse.settings.listeners._.x_forwarded">x_forwarded</link> = true;
+        <link linkend="opt-services.matrix-synapse.settings.listeners._.resources">resources</link> = [ {
+          <link linkend="opt-services.matrix-synapse.settings.listeners._.resources._.names">names</link> = [ "client" ];
+          <link linkend="opt-services.matrix-synapse.settings.listeners._.resources._.compress">compress</link> = true;
+        } {
+          <link linkend="opt-services.matrix-synapse.settings.listeners._.resources._.names">names</link> = [ "federation" ];
+          <link linkend="opt-services.matrix-synapse.settings.listeners._.resources._.compress">compress</link> = false;
+        } ];
+      }
+    ];
+  };
+}
+</programlisting>
+  </para>
+
+  <para>
+   If the <code>A</code> and <code>AAAA</code> DNS records on
+   <literal>example.org</literal> do not point on the same host as the records
+   for <code>myhostname.example.org</code>, you can easily move the
+   <code>/.well-known</code> virtualHost section of the code to the host that
+   is serving <literal>example.org</literal>, while the rest stays on
+   <literal>myhostname.example.org</literal> with no other changes required.
+   This pattern also allows to seamlessly move the homeserver from
+   <literal>myhostname.example.org</literal> to
+   <literal>myotherhost.example.org</literal> by only changing the
+   <code>/.well-known</code> redirection target.
+  </para>
+
+  <para>
+   If you want to run a server with public registration by anybody, you can
+   then enable <literal><link linkend="opt-services.matrix-synapse.settings.enable_registration">services.matrix-synapse.settings.enable_registration</link> =
+   true;</literal>. Otherwise, or you can generate a registration secret with
+   <command>pwgen -s 64 1</command> and set it with
+   <option><link linkend="opt-services.matrix-synapse.settings.registration_shared_secret">services.matrix-synapse.settings.registration_shared_secret</link></option>.
+   To create a new user or admin, run the following after you have set the secret
+   and have rebuilt NixOS:
+<screen>
+<prompt>$ </prompt>nix run nixpkgs.matrix-synapse
+<prompt>$ </prompt>register_new_matrix_user -k <replaceable>your-registration-shared-secret</replaceable> http://localhost:8008
+<prompt>New user localpart: </prompt><replaceable>your-username</replaceable>
+<prompt>Password:</prompt>
+<prompt>Confirm password:</prompt>
+<prompt>Make admin [no]:</prompt>
+Success!
+</screen>
+   In the example, this would create a user with the Matrix Identifier
+   <literal>@your-username:example.org</literal>. Note that the registration
+   secret ends up in the nix store and therefore is world-readable by any user
+   on your machine, so it makes sense to only temporarily activate the
+   <link linkend="opt-services.matrix-synapse.settings.registration_shared_secret">registration_shared_secret</link>
+   option until a better solution for NixOS is in place.
+  </para>
+ </section>
+ <section xml:id="module-services-matrix-element-web">
+  <title>Element (formerly known as Riot) Web Client</title>
+
+  <para>
+   <link xlink:href="https://github.com/vector-im/riot-web/">Element Web</link> is
+   the reference web client for Matrix and developed by the core team at
+   matrix.org. Element was formerly known as Riot.im, see the
+   <link xlink:href="https://element.io/blog/welcome-to-element/">Element introductory blog post</link>
+   for more information. The following snippet can be optionally added to the code before
+   to complete the synapse installation with a web client served at
+   <code>https://element.myhostname.example.org</code> and
+   <code>https://element.example.org</code>. Alternatively, you can use the hosted
+   copy at <link xlink:href="https://app.element.io/">https://app.element.io/</link>,
+   or use other web clients or native client applications. Due to the
+   <literal>/.well-known</literal> urls set up done above, many clients should
+   fill in the required connection details automatically when you enter your
+   Matrix Identifier. See
+   <link xlink:href="https://matrix.org/docs/projects/try-matrix-now.html">Try
+   Matrix Now!</link> for a list of existing clients and their supported
+   featureset.
+<programlisting>
+{
+  services.nginx.virtualHosts."element.${fqdn}" = {
+    <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true;
+    <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
+    <link linkend="opt-services.nginx.virtualHosts._name_.serverAliases">serverAliases</link> = [
+      "element.${config.networking.domain}"
+    ];
+
+    <link linkend="opt-services.nginx.virtualHosts._name_.root">root</link> = pkgs.element-web.override {
+      conf = {
+        default_server_config."m.homeserver" = {
+          "base_url" = "https://${fqdn}";
+          "server_name" = "${fqdn}";
+        };
+      };
+    };
+  };
+}
+</programlisting>
+  </para>
+
+  <para>
+   Note that the Element developers do not recommend running Element and your Matrix
+   homeserver on the same fully-qualified domain name for security reasons. In
+   the example, this means that you should not reuse the
+   <literal>myhostname.example.org</literal> virtualHost to also serve Element,
+   but instead serve it on a different subdomain, like
+   <literal>element.example.org</literal> in the example. See the
+   <link xlink:href="https://github.com/vector-im/riot-web#important-security-note">Element
+   Important Security Notes</link> for more information on this subject.
+  </para>
+ </section>
+</chapter>
diff --git a/nixos/modules/services/matrix/mjolnir.nix b/nixos/modules/services/matrix/mjolnir.nix
new file mode 100644
index 00000000000..278924b05cf
--- /dev/null
+++ b/nixos/modules/services/matrix/mjolnir.nix
@@ -0,0 +1,242 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.mjolnir;
+
+  yamlConfig = {
+    inherit (cfg) dataPath managementRoom protectedRooms;
+
+    accessToken = "@ACCESS_TOKEN@"; # will be replaced in "generateConfig"
+    homeserverUrl =
+      if cfg.pantalaimon.enable then
+        "http://${cfg.pantalaimon.options.listenAddress}:${toString cfg.pantalaimon.options.listenPort}"
+      else
+        cfg.homeserverUrl;
+
+    rawHomeserverUrl = cfg.homeserverUrl;
+
+    pantalaimon = {
+      inherit (cfg.pantalaimon) username;
+
+      use = cfg.pantalaimon.enable;
+      password = "@PANTALAIMON_PASSWORD@"; # will be replaced in "generateConfig"
+    };
+  };
+
+  moduleConfigFile = pkgs.writeText "module-config.yaml" (
+    generators.toYAML { } (filterAttrs (_: v: v != null)
+      (fold recursiveUpdate { } [ yamlConfig cfg.settings ])));
+
+  # these config files will be merged one after the other to build the final config
+  configFiles = [
+    "${pkgs.mjolnir}/share/mjolnir/config/default.yaml"
+    moduleConfigFile
+  ];
+
+  # this will generate the default.yaml file with all configFiles as inputs and
+  # replace all secret strings using replace-secret
+  generateConfig = pkgs.writeShellScript "mjolnir-generate-config" (
+    let
+      yqEvalStr = concatImapStringsSep " * " (pos: _: "select(fileIndex == ${toString (pos - 1)})") configFiles;
+      yqEvalArgs = concatStringsSep " " configFiles;
+    in
+    ''
+      set -euo pipefail
+
+      umask 077
+
+      # mjolnir will try to load a config from "./config/default.yaml" in the working directory
+      # -> let's place the generated config there
+      mkdir -p ${cfg.dataPath}/config
+
+      # merge all config files into one, overriding settings of the previous one with the next config
+      # e.g. "eval-all 'select(fileIndex == 0) * select(fileIndex == 1)' filea.yaml fileb.yaml" will merge filea.yaml with fileb.yaml
+      ${pkgs.yq-go}/bin/yq eval-all -P '${yqEvalStr}' ${yqEvalArgs} > ${cfg.dataPath}/config/default.yaml
+
+      ${optionalString (cfg.accessTokenFile != null) ''
+        ${pkgs.replace-secret}/bin/replace-secret '@ACCESS_TOKEN@' '${cfg.accessTokenFile}' ${cfg.dataPath}/config/default.yaml
+      ''}
+      ${optionalString (cfg.pantalaimon.passwordFile != null) ''
+        ${pkgs.replace-secret}/bin/replace-secret '@PANTALAIMON_PASSWORD@' '${cfg.pantalaimon.passwordFile}' ${cfg.dataPath}/config/default.yaml
+      ''}
+    ''
+  );
+in
+{
+  options.services.mjolnir = {
+    enable = mkEnableOption "Mjolnir, a moderation tool for Matrix";
+
+    homeserverUrl = mkOption {
+      type = types.str;
+      default = "https://matrix.org";
+      description = ''
+        Where the homeserver is located (client-server URL).
+
+        If <literal>pantalaimon.enable</literal> is <literal>true</literal>, this option will become the homeserver to which <literal>pantalaimon</literal> connects.
+        The listen address of <literal>pantalaimon</literal> will then become the <literal>homeserverUrl</literal> of <literal>mjolnir</literal>.
+      '';
+    };
+
+    accessTokenFile = mkOption {
+      type = with types; nullOr path;
+      default = null;
+      description = ''
+        File containing the matrix access token for the <literal>mjolnir</literal> user.
+      '';
+    };
+
+    pantalaimon = mkOption {
+      description = ''
+        <literal>pantalaimon</literal> options (enables E2E Encryption support).
+
+        This will create a <literal>pantalaimon</literal> instance with the name "mjolnir".
+      '';
+      default = { };
+      type = types.submodule {
+        options = {
+          enable = mkEnableOption ''
+            If true, accessToken is ignored and the username/password below will be
+            used instead. The access token of the bot will be stored in the dataPath.
+          '';
+
+          username = mkOption {
+            type = types.str;
+            description = "The username to login with.";
+          };
+
+          passwordFile = mkOption {
+            type = with types; nullOr path;
+            default = null;
+            description = ''
+              File containing the matrix password for the <literal>mjolnir</literal> user.
+            '';
+          };
+
+          options = mkOption {
+            type = types.submodule (import ./pantalaimon-options.nix);
+            default = { };
+            description = ''
+              passthrough additional options to the <literal>pantalaimon</literal> service.
+            '';
+          };
+        };
+      };
+    };
+
+    dataPath = mkOption {
+      type = types.path;
+      default = "/var/lib/mjolnir";
+      description = ''
+        The directory the bot should store various bits of information in.
+      '';
+    };
+
+    managementRoom = mkOption {
+      type = types.str;
+      default = "#moderators:example.org";
+      description = ''
+        The room ID where people can use the bot. The bot has no access controls, so
+        anyone in this room can use the bot - secure your room!
+        This should be a room alias or room ID - not a matrix.to URL.
+        Note: <literal>mjolnir</literal> is fairly verbose - expect a lot of messages from it.
+      '';
+    };
+
+    protectedRooms = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      example = literalExpression ''
+        [
+          "https://matrix.to/#/#yourroom:example.org"
+          "https://matrix.to/#/#anotherroom:example.org"
+        ]
+      '';
+      description = ''
+        A list of rooms to protect (matrix.to URLs).
+      '';
+    };
+
+    settings = mkOption {
+      default = { };
+      type = (pkgs.formats.yaml { }).type;
+      example = literalExpression ''
+        {
+          autojoinOnlyIfManager = true;
+          automaticallyRedactForReasons = [ "spam" "advertising" ];
+        }
+      '';
+      description = ''
+        Additional settings (see <link xlink:href="https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml">mjolnir default config</link> for available settings). These settings will override settings made by the module config.
+      '';
+    };
+  };
+
+  config = mkIf config.services.mjolnir.enable {
+    assertions = [
+      {
+        assertion = !(cfg.pantalaimon.enable && cfg.pantalaimon.passwordFile == null);
+        message = "Specify pantalaimon.passwordFile";
+      }
+      {
+        assertion = !(cfg.pantalaimon.enable && cfg.accessTokenFile != null);
+        message = "Do not specify accessTokenFile when using pantalaimon";
+      }
+      {
+        assertion = !(!cfg.pantalaimon.enable && cfg.accessTokenFile == null);
+        message = "Specify accessTokenFile when not using pantalaimon";
+      }
+    ];
+
+    services.pantalaimon-headless.instances."mjolnir" = mkIf cfg.pantalaimon.enable
+      {
+        homeserver = cfg.homeserverUrl;
+      } // cfg.pantalaimon.options;
+
+    systemd.services.mjolnir = {
+      description = "mjolnir - a moderation tool for Matrix";
+      wants = [ "network-online.target" ] ++ optionals (cfg.pantalaimon.enable) [ "pantalaimon-mjolnir.service" ];
+      after = [ "network-online.target" ] ++ optionals (cfg.pantalaimon.enable) [ "pantalaimon-mjolnir.service" ];
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        ExecStart = ''${pkgs.mjolnir}/bin/mjolnir'';
+        ExecStartPre = [ generateConfig ];
+        WorkingDirectory = cfg.dataPath;
+        StateDirectory = "mjolnir";
+        StateDirectoryMode = "0700";
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        PrivateTmp = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        User = "mjolnir";
+        Restart = "on-failure";
+
+        /* TODO: wait for #102397 to be resolved. Then load secrets from $CREDENTIALS_DIRECTORY+"/NAME"
+        DynamicUser = true;
+        LoadCredential = [] ++
+          optionals (cfg.accessTokenFile != null) [
+            "access_token:${cfg.accessTokenFile}"
+          ] ++
+          optionals (cfg.pantalaimon.passwordFile != null) [
+            "pantalaimon_password:${cfg.pantalaimon.passwordFile}"
+          ];
+        */
+      };
+    };
+
+    users = {
+      users.mjolnir = {
+        group = "mjolnir";
+        isSystemUser = true;
+      };
+      groups.mjolnir = { };
+    };
+  };
+
+  meta = {
+    doc = ./mjolnir.xml;
+    maintainers = with maintainers; [ jojosch ];
+  };
+}
diff --git a/nixos/modules/services/matrix/mjolnir.xml b/nixos/modules/services/matrix/mjolnir.xml
new file mode 100644
index 00000000000..b07abe33979
--- /dev/null
+++ b/nixos/modules/services/matrix/mjolnir.xml
@@ -0,0 +1,134 @@
+<chapter xmlns="http://docbook.org/ns/docbook"
+         xmlns:xlink="http://www.w3.org/1999/xlink"
+         xmlns:xi="http://www.w3.org/2001/XInclude"
+         version="5.0"
+         xml:id="module-services-mjolnir">
+ <title>Mjolnir (Matrix Moderation Tool)</title>
+ <para>
+  This chapter will show you how to set up your own, self-hosted
+  <link xlink:href="https://github.com/matrix-org/mjolnir">Mjolnir</link>
+  instance.
+ </para>
+ <para>
+  As an all-in-one moderation tool, it can protect your server from
+  malicious invites, spam messages, and whatever else you don't want.
+  In addition to server-level protection, Mjolnir is great for communities
+  wanting to protect their rooms without having to use their personal
+  accounts for moderation.
+ </para>
+ <para>
+  The bot by default includes support for bans, redactions, anti-spam,
+  server ACLs, room directory changes, room alias transfers, account
+  deactivation, room shutdown, and more.
+ </para>
+ <para>
+  See the <link xlink:href="https://github.com/matrix-org/mjolnir#readme">README</link>
+  page and the <link xlink:href="https://github.com/matrix-org/mjolnir/blob/main/docs/moderators.md">Moderator's guide</link>
+  for additional instructions on how to setup and use Mjolnir.
+ </para>
+ <para>
+  For <link linkend="opt-services.mjolnir.settings">additional settings</link>
+  see <link xlink:href="https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml">the default configuration</link>.
+ </para>
+ <section xml:id="module-services-mjolnir-setup">
+  <title>Mjolnir Setup</title>
+  <para>
+   First create a new Room which will be used as a management room for Mjolnir. In
+   this room, Mjolnir will log possible errors and debugging information. You'll
+   need to set this Room-ID in <link linkend="opt-services.mjolnir.managementRoom">services.mjolnir.managementRoom</link>.
+  </para>
+  <para>
+   Next, create a new user for Mjolnir on your homeserver, if not present already.
+  </para>
+  <para>
+   The Mjolnir Matrix user expects to be free of any rate limiting.
+   See <link xlink:href="https://github.com/matrix-org/synapse/issues/6286">Synapse #6286</link>
+   for an example on how to achieve this.
+  </para>
+  <para>
+   If you want Mjolnir to be able to deactivate users, move room aliases, shutdown rooms, etc.
+   you'll need to make the Mjolnir user a Matrix server admin.
+  </para>
+  <para>
+   Now invite the Mjolnir user to the management room.
+  </para>
+  <para>
+   It is recommended to use <link xlink:href="https://github.com/matrix-org/pantalaimon">Pantalaimon</link>,
+   so your management room can be encrypted. This also applies if you are looking to moderate an encrypted room.
+  </para>
+  <para>
+   To enable the Pantalaimon E2E Proxy for mjolnir, enable
+   <link linkend="opt-services.mjolnir.pantalaimon.enable">services.mjolnir.pantalaimon</link>. This will
+   autoconfigure a new Pantalaimon instance, which will connect to the homeserver
+   set in <link linkend="opt-services.mjolnir.homeserverUrl">services.mjolnir.homeserverUrl</link> and Mjolnir itself
+   will be configured to connect to the new Pantalaimon instance.
+  </para>
+<programlisting>
+{
+  services.mjolnir = {
+    enable = true;
+    <link linkend="opt-services.mjolnir.homeserverUrl">homeserverUrl</link> = "https://matrix.domain.tld";
+    <link linkend="opt-services.mjolnir.pantalaimon">pantalaimon</link> = {
+       <link linkend="opt-services.mjolnir.pantalaimon.enable">enable</link> = true;
+       <link linkend="opt-services.mjolnir.pantalaimon.username">username</link> = "mjolnir";
+       <link linkend="opt-services.mjolnir.pantalaimon.passwordFile">passwordFile</link> = "/run/secrets/mjolnir-password";
+    };
+    <link linkend="opt-services.mjolnir.protectedRooms">protectedRooms</link> = [
+      "https://matrix.to/#/!xxx:domain.tld"
+    ];
+    <link linkend="opt-services.mjolnir.managementRoom">managementRoom</link> = "!yyy:domain.tld";
+  };
+}
+</programlisting>
+ <section xml:id="module-services-mjolnir-setup-ems">
+  <title>Element Matrix Services (EMS)</title>
+  <para>
+   If you are using a managed <link xlink:href="https://ems.element.io/">"Element Matrix Services (EMS)"</link>
+   server, you will need to consent to the terms and conditions. Upon startup, an error
+   log entry with a URL to the consent page will be generated.
+  </para>
+ </section>
+ </section>
+
+ <section xml:id="module-services-mjolnir-matrix-synapse-antispam">
+  <title>Synapse Antispam Module</title>
+  <para>
+   A Synapse module is also available to apply the same rulesets the bot
+   uses across an entire homeserver.
+  </para>
+  <para>
+   To use the Antispam Module, add <package>matrix-synapse-plugins.matrix-synapse-mjolnir-antispam</package>
+   to the Synapse plugin list and enable the <literal>mjolnir.Module</literal> module.
+  </para>
+<programlisting>
+{
+  services.matrix-synapse = {
+    plugins = with pkgs; [
+      matrix-synapse-plugins.matrix-synapse-mjolnir-antispam
+    ];
+    extraConfig = ''
+      modules:
+        - module: mjolnir.Module
+          config:
+            # Prevent servers/users in the ban lists from inviting users on this
+            # server to rooms. Default true.
+            block_invites: true
+            # Flag messages sent by servers/users in the ban lists as spam. Currently
+            # this means that spammy messages will appear as empty to users. Default
+            # false.
+            block_messages: false
+            # Remove users from the user directory search by filtering matrix IDs and
+            # display names by the entries in the user ban list. Default false.
+            block_usernames: false
+            # The room IDs of the ban lists to honour. Unlike other parts of Mjolnir,
+            # this list cannot be room aliases or permalinks. This server is expected
+            # to already be joined to the room - Mjolnir will not automatically join
+            # these rooms.
+            ban_lists:
+              - "!roomid:example.org"
+    '';
+  };
+}
+</programlisting>
+ </section>
+</chapter>
diff --git a/nixos/modules/services/matrix/pantalaimon-options.nix b/nixos/modules/services/matrix/pantalaimon-options.nix
new file mode 100644
index 00000000000..035c57540d0
--- /dev/null
+++ b/nixos/modules/services/matrix/pantalaimon-options.nix
@@ -0,0 +1,70 @@
+{ config, lib, name, ... }:
+
+with lib;
+{
+  options = {
+    dataPath = mkOption {
+      type = types.path;
+      default = "/var/lib/pantalaimon-${name}";
+      description = ''
+        The directory where <literal>pantalaimon</literal> should store its state such as the database file.
+      '';
+    };
+
+    logLevel = mkOption {
+      type = types.enum [ "info" "warning" "error" "debug" ];
+      default = "warning";
+      description = ''
+        Set the log level of the daemon.
+      '';
+    };
+
+    homeserver = mkOption {
+      type = types.str;
+      example = "https://matrix.org";
+      description = ''
+        The URI of the homeserver that the <literal>pantalaimon</literal> proxy should
+        forward requests to, without the matrix API path but including
+        the http(s) schema.
+      '';
+    };
+
+    ssl = mkOption {
+      type = types.bool;
+      default = true;
+      description = ''
+        Whether or not SSL verification should be enabled for outgoing
+        connections to the homeserver.
+      '';
+    };
+
+    listenAddress = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = ''
+        The address where the daemon will listen to client connections
+        for this homeserver.
+      '';
+    };
+
+    listenPort = mkOption {
+      type = types.port;
+      default = 8009;
+      description = ''
+        The port where the daemon will listen to client connections for
+        this homeserver. Note that the listen address/port combination
+        needs to be unique between different homeservers.
+      '';
+    };
+
+    extraSettings = mkOption {
+      type = types.attrs;
+      default = { };
+      description = ''
+        Extra configuration options. See
+        <link xlink:href="https://github.com/matrix-org/pantalaimon/blob/master/docs/man/pantalaimon.5.md">pantalaimon(5)</link>
+        for available options.
+      '';
+    };
+  };
+}
diff --git a/nixos/modules/services/matrix/pantalaimon.nix b/nixos/modules/services/matrix/pantalaimon.nix
new file mode 100644
index 00000000000..63b40099ca5
--- /dev/null
+++ b/nixos/modules/services/matrix/pantalaimon.nix
@@ -0,0 +1,70 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.pantalaimon-headless;
+
+  iniFmt = pkgs.formats.ini { };
+
+  mkConfigFile = name: instanceConfig: iniFmt.generate "pantalaimon.conf" {
+    Default = {
+      LogLevel = instanceConfig.logLevel;
+      Notifications = false;
+    };
+
+    ${name} = (recursiveUpdate
+      {
+        Homeserver = instanceConfig.homeserver;
+        ListenAddress = instanceConfig.listenAddress;
+        ListenPort = instanceConfig.listenPort;
+        SSL = instanceConfig.ssl;
+
+        # Set some settings to prevent user interaction for headless operation
+        IgnoreVerification = true;
+        UseKeyring = false;
+      }
+      instanceConfig.extraSettings
+    );
+  };
+
+  mkPantalaimonService = name: instanceConfig:
+    nameValuePair "pantalaimon-${name}" {
+      description = "pantalaimon instance ${name} - E2EE aware proxy daemon for matrix clients";
+      wants = [ "network-online.target" ];
+      after = [ "network-online.target" ];
+      wantedBy = [ "multi-user.target" ];
+
+      serviceConfig = {
+        ExecStart = ''${pkgs.pantalaimon-headless}/bin/pantalaimon --config ${mkConfigFile name instanceConfig} --data-path ${instanceConfig.dataPath}'';
+        Restart = "on-failure";
+        DynamicUser = true;
+        NoNewPrivileges = true;
+        PrivateDevices = true;
+        PrivateTmp = true;
+        ProtectHome = true;
+        ProtectSystem = "strict";
+        StateDirectory = "pantalaimon-${name}";
+      };
+    };
+in
+{
+  options.services.pantalaimon-headless.instances = mkOption {
+    default = { };
+    type = types.attrsOf (types.submodule (import ./pantalaimon-options.nix));
+    description = ''
+      Declarative instance config.
+
+      Note: to use pantalaimon interactively, e.g. for a Matrix client which does not
+      support End-to-end encryption (like <literal>fractal</literal>), refer to the home-manager module.
+    '';
+  };
+
+  config = mkIf (config.services.pantalaimon-headless.instances != { })
+    {
+      systemd.services = mapAttrs' mkPantalaimonService config.services.pantalaimon-headless.instances;
+    };
+
+  meta = {
+    maintainers = with maintainers; [ jojosch ];
+  };
+}