summary refs log tree commit diff
path: root/nixos/modules/services/misc/sourcehut
diff options
context:
space:
mode:
Diffstat (limited to 'nixos/modules/services/misc/sourcehut')
-rw-r--r--nixos/modules/services/misc/sourcehut/builds.nix234
-rw-r--r--nixos/modules/services/misc/sourcehut/default.nix198
-rw-r--r--nixos/modules/services/misc/sourcehut/dispatch.nix125
-rw-r--r--nixos/modules/services/misc/sourcehut/git.nix214
-rw-r--r--nixos/modules/services/misc/sourcehut/hg.nix173
-rw-r--r--nixos/modules/services/misc/sourcehut/hub.nix118
-rw-r--r--nixos/modules/services/misc/sourcehut/lists.nix185
-rw-r--r--nixos/modules/services/misc/sourcehut/man.nix122
-rw-r--r--nixos/modules/services/misc/sourcehut/meta.nix211
-rw-r--r--nixos/modules/services/misc/sourcehut/paste.nix133
-rw-r--r--nixos/modules/services/misc/sourcehut/service.nix66
-rw-r--r--nixos/modules/services/misc/sourcehut/sourcehut.xml115
-rw-r--r--nixos/modules/services/misc/sourcehut/todo.nix161
13 files changed, 2055 insertions, 0 deletions
diff --git a/nixos/modules/services/misc/sourcehut/builds.nix b/nixos/modules/services/misc/sourcehut/builds.nix
new file mode 100644
index 00000000000..a17a1010dbf
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/builds.nix
@@ -0,0 +1,234 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  scfg = cfg.builds;
+  rcfg = config.services.redis;
+  iniKey = "builds.sr.ht";
+
+  drv = pkgs.sourcehut.buildsrht;
+in
+{
+  options.services.sourcehut.builds = {
+    user = mkOption {
+      type = types.str;
+      default = "buildsrht";
+      description = ''
+        User for builds.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5002;
+      description = ''
+        Port on which the "builds" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "builds.sr.ht";
+      description = ''
+        PostgreSQL database name for builds.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/buildsrht";
+      description = ''
+        State path for builds.sr.ht.
+      '';
+    };
+
+    enableWorker = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Run workers for builds.sr.ht.
+      '';
+    };
+
+    images = mkOption {
+      type = types.attrsOf (types.attrsOf (types.attrsOf types.package));
+      default = { };
+      example = lib.literalExample ''(let
+          # Pinning unstable to allow usage with flakes and limit rebuilds.
+          pkgs_unstable = builtins.fetchGit {
+              url = "https://github.com/NixOS/nixpkgs";
+              rev = "ff96a0fa5635770390b184ae74debea75c3fd534";
+              ref = "nixos-unstable";
+          };
+          image_from_nixpkgs = pkgs_unstable: (import ("${pkgs.sourcehut.buildsrht}/lib/images/nixos/image.nix") {
+            pkgs = (import pkgs_unstable {});
+          });
+        in
+        {
+          nixos.unstable.x86_64 = image_from_nixpkgs pkgs_unstable;
+        }
+      )'';
+      description = ''
+        Images for builds.sr.ht. Each package should be distro.release.arch and point to a /nix/store/package/root.img.qcow2.
+      '';
+    };
+
+  };
+
+  config = with scfg; let
+    image_dirs = lib.lists.flatten (
+      lib.attrsets.mapAttrsToList
+        (distro: revs:
+          lib.attrsets.mapAttrsToList
+            (rev: archs:
+              lib.attrsets.mapAttrsToList
+                (arch: image:
+                  pkgs.runCommandNoCC "buildsrht-images" { } ''
+                    mkdir -p $out/${distro}/${rev}/${arch}
+                    ln -s ${image}/*.qcow2 $out/${distro}/${rev}/${arch}/root.img.qcow2
+                  '')
+                archs)
+            revs)
+        scfg.images);
+    image_dir_pre = pkgs.symlinkJoin {
+      name = "builds.sr.ht-worker-images-pre";
+      paths = image_dirs ++ [
+        "${pkgs.sourcehut.buildsrht}/lib/images"
+      ];
+    };
+    image_dir = pkgs.runCommandNoCC "builds.sr.ht-worker-images" { } ''
+      mkdir -p $out/images
+      cp -Lr ${image_dir_pre}/* $out/images
+    '';
+  in
+  lib.mkIf (cfg.enable && elem "builds" cfg.services) {
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          extraGroups = lib.optionals cfg.builds.enableWorker [ "docker" ];
+          description = "builds.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0755 ${user} ${user} -"
+      ] ++ (lib.optionals cfg.builds.enableWorker
+        [ "d ${statePath}/logs 0775 ${user} ${user} - -" ]
+      );
+
+      services = {
+        buildsrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey
+          {
+            after = [ "postgresql.service" "network.target" ];
+            requires = [ "postgresql.service" ];
+            wantedBy = [ "multi-user.target" ];
+
+            description = "builds.sr.ht website service";
+
+            serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+
+            # Hack to bypass this hack: https://git.sr.ht/~sircmpwn/core.sr.ht/tree/master/item/srht-update-profiles#L6
+          } // { preStart = " "; };
+
+        buildsrht-worker = {
+          enable = scfg.enableWorker;
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+          partOf = [ "buildsrht.service" ];
+          description = "builds.sr.ht worker service";
+          path = [ pkgs.openssh pkgs.docker ];
+          preStart = let qemuPackage = pkgs.qemu_kvm;
+          in ''
+            if [[ "$(docker images -q qemu:latest 2> /dev/null)" == "" || "$(cat ${statePath}/docker-image-qemu 2> /dev/null || true)" != "${qemuPackage.version}" ]]; then
+              # Create and import qemu:latest image for docker
+              ${
+                pkgs.dockerTools.streamLayeredImage {
+                  name = "qemu";
+                  tag = "latest";
+                  contents = [ qemuPackage ];
+                }
+              } | docker load
+              # Mark down current package version
+              printf "%s" "${qemuPackage.version}" > ${statePath}/docker-image-qemu
+            fi
+          '';
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Group = "nginx";
+            Restart = "always";
+          };
+          serviceConfig.ExecStart = "${pkgs.sourcehut.buildsrht}/bin/builds.sr.ht-worker";
+        };
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL builds.sr.ht is being served at (protocol://domain)
+      "builds.sr.ht".origin = mkDefault "http://builds.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "builds.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "builds.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "builds.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "builds.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # builds.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "builds.sr.ht".oauth-client-id = mkDefault null;
+      "builds.sr.ht".oauth-client-secret = mkDefault null;
+      # The redis connection used for the celery worker
+      "builds.sr.ht".redis = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/3";
+      # The shell used for ssh
+      "builds.sr.ht".shell = mkDefault "runner-shell";
+      # Register the builds.sr.ht dispatcher
+      "git.sr.ht::dispatch".${builtins.unsafeDiscardStringContext "${pkgs.sourcehut.buildsrht}/bin/buildsrht-keys"} = mkDefault "${user}:${user}";
+
+      # Location for build logs, images, and control command
+    } // lib.attrsets.optionalAttrs scfg.enableWorker {
+      # Default worker stores logs that are accessible via this address:port
+      "builds.sr.ht::worker".name = mkDefault "127.0.0.1:5020";
+      "builds.sr.ht::worker".buildlogs = mkDefault "${scfg.statePath}/logs";
+      "builds.sr.ht::worker".images = mkDefault "${image_dir}/images";
+      "builds.sr.ht::worker".controlcmd = mkDefault "${image_dir}/images/control";
+      "builds.sr.ht::worker".timeout = mkDefault "3m";
+    };
+
+    services.nginx.virtualHosts."logs.${cfg.originBase}" =
+      if scfg.enableWorker then {
+        listen = with builtins; let address = split ":" cfg.settings."builds.sr.ht::worker".name;
+        in [{ addr = elemAt address 0; port = lib.toInt (elemAt address 2); }];
+        locations."/logs".root = "${scfg.statePath}";
+      } else { };
+
+    services.nginx.virtualHosts."builds.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.buildsrht}/${pkgs.sourcehut.python.sitePackages}/buildsrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/default.nix b/nixos/modules/services/misc/sourcehut/default.nix
new file mode 100644
index 00000000000..9c812d6b043
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/default.nix
@@ -0,0 +1,198 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  settingsFormat = pkgs.formats.ini { };
+
+  # Specialized python containing all the modules
+  python = pkgs.sourcehut.python.withPackages (ps: with ps; [
+    gunicorn
+    # Sourcehut services
+    srht
+    buildsrht
+    dispatchsrht
+    gitsrht
+    hgsrht
+    hubsrht
+    listssrht
+    mansrht
+    metasrht
+    pastesrht
+    todosrht
+  ]);
+in
+{
+  imports =
+    [
+      ./git.nix
+      ./hg.nix
+      ./hub.nix
+      ./todo.nix
+      ./man.nix
+      ./meta.nix
+      ./paste.nix
+      ./builds.nix
+      ./lists.nix
+      ./dispatch.nix
+      (mkRemovedOptionModule [ "services" "sourcehut" "nginx" "enable" ] ''
+        The sourcehut module supports `nginx` as a local reverse-proxy by default and doesn't
+        support other reverse-proxies officially.
+
+        However it's possible to use an alternative reverse-proxy by
+
+          * disabling nginx
+          * adjusting the relevant settings for server addresses and ports directly
+
+        Further details about this can be found in the `Sourcehut`-section of the NixOS-manual.
+      '')
+    ];
+
+  options.services.sourcehut = {
+    enable = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Enable sourcehut - git hosting, continuous integration, mailing list, ticket tracking,
+        task dispatching, wiki and account management services
+      '';
+    };
+
+    services = mkOption {
+      type = types.nonEmptyListOf (types.enum [ "builds" "dispatch" "git" "hub" "hg" "lists" "man" "meta" "paste" "todo" ]);
+      default = [ "man" "meta" "paste" ];
+      example = [ "builds" "dispatch" "git" "hub" "hg" "lists" "man" "meta" "paste" "todo" ];
+      description = ''
+        Services to enable on the sourcehut network.
+      '';
+    };
+
+    originBase = mkOption {
+      type = types.str;
+      default = with config.networking; hostName + lib.optionalString (domain != null) ".${domain}";
+      description = ''
+        Host name used by reverse-proxy and for default settings. Will host services at git."''${originBase}". For example: git.sr.ht
+      '';
+    };
+
+    address = mkOption {
+      type = types.str;
+      default = "127.0.0.1";
+      description = ''
+        Address to bind to.
+      '';
+    };
+
+    python = mkOption {
+      internal = true;
+      type = types.package;
+      default = python;
+      description = ''
+        The python package to use. It should contain references to the *srht modules and also
+        gunicorn.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "/var/lib/sourcehut";
+      description = ''
+        Root state path for the sourcehut network. If left as the default value
+        this directory will automatically be created before the sourcehut server
+        starts, otherwise the sysadmin is responsible for ensuring the
+        directory exists with appropriate ownership and permissions.
+      '';
+    };
+
+    settings = mkOption {
+      type = lib.types.submodule {
+        freeformType = settingsFormat.type;
+      };
+      default = { };
+      description = ''
+        The configuration for the sourcehut network.
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions =
+      [
+        {
+          assertion = with cfgIni.webhooks; private-key != null && stringLength private-key == 44;
+          message = "The webhook's private key must be defined and of a 44 byte length.";
+        }
+
+        {
+          assertion = hasAttrByPath [ "meta.sr.ht" "origin" ] cfgIni && cfgIni."meta.sr.ht".origin != null;
+          message = "meta.sr.ht's origin must be defined.";
+        }
+      ];
+
+    virtualisation.docker.enable = true;
+    environment.etc."sr.ht/config.ini".source =
+      settingsFormat.generate "sourcehut-config.ini" (mapAttrsRecursive
+        (
+          path: v: if v == null then "" else v
+        )
+        cfg.settings);
+
+    environment.systemPackages = [ pkgs.sourcehut.coresrht ];
+
+    # PostgreSQL server
+    services.postgresql.enable = mkOverride 999 true;
+    # Mail server
+    services.postfix.enable = mkOverride 999 true;
+    # Cron daemon
+    services.cron.enable = mkOverride 999 true;
+    # Redis server
+    services.redis.enable = mkOverride 999 true;
+    services.redis.bind = mkOverride 999 "127.0.0.1";
+
+    services.sourcehut.settings = {
+      # The name of your network of sr.ht-based sites
+      "sr.ht".site-name = mkDefault "sourcehut";
+      # The top-level info page for your site
+      "sr.ht".site-info = mkDefault "https://sourcehut.org";
+      # {{ site-name }}, {{ site-blurb }}
+      "sr.ht".site-blurb = mkDefault "the hacker's forge";
+      # If this != production, we add a banner to each page
+      "sr.ht".environment = mkDefault "development";
+      # Contact information for the site owners
+      "sr.ht".owner-name = mkDefault "Drew DeVault";
+      "sr.ht".owner-email = mkDefault "sir@cmpwn.com";
+      # The source code for your fork of sr.ht
+      "sr.ht".source-url = mkDefault "https://git.sr.ht/~sircmpwn/srht";
+      # A secret key to encrypt session cookies with
+      "sr.ht".secret-key = mkDefault null;
+      "sr.ht".global-domain = mkDefault null;
+
+      # Outgoing SMTP settings
+      mail.smtp-host = mkDefault null;
+      mail.smtp-port = mkDefault null;
+      mail.smtp-user = mkDefault null;
+      mail.smtp-password = mkDefault null;
+      mail.smtp-from = mkDefault null;
+      # Application exceptions are emailed to this address
+      mail.error-to = mkDefault null;
+      mail.error-from = mkDefault null;
+      # Your PGP key information (DO NOT mix up pub and priv here)
+      # You must remove the password from your secret key, if present.
+      # You can do this with gpg --edit-key [key-id], then use the passwd
+      # command and do not enter a new password.
+      mail.pgp-privkey = mkDefault null;
+      mail.pgp-pubkey = mkDefault null;
+      mail.pgp-key-id = mkDefault null;
+
+      # base64-encoded Ed25519 key for signing webhook payloads. This should be
+      # consistent for all *.sr.ht sites, as we'll use this key to verify signatures
+      # from other sites in your network.
+      #
+      # Use the srht-webhook-keygen command to generate a key.
+      webhooks.private-key = mkDefault null;
+    };
+  };
+  meta.doc = ./sourcehut.xml;
+  meta.maintainers = with maintainers; [ tomberek ];
+}
diff --git a/nixos/modules/services/misc/sourcehut/dispatch.nix b/nixos/modules/services/misc/sourcehut/dispatch.nix
new file mode 100644
index 00000000000..a9db17bebe8
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/dispatch.nix
@@ -0,0 +1,125 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.dispatch;
+  iniKey = "dispatch.sr.ht";
+
+  drv = pkgs.sourcehut.dispatchsrht;
+in
+{
+  options.services.sourcehut.dispatch = {
+    user = mkOption {
+      type = types.str;
+      default = "dispatchsrht";
+      description = ''
+        User for dispatch.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5005;
+      description = ''
+        Port on which the "dispatch" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "dispatch.sr.ht";
+      description = ''
+        PostgreSQL database name for dispatch.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/dispatchsrht";
+      description = ''
+        State path for dispatch.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "dispatch" cfg.services) {
+
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          description = "dispatch.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services.dispatchsrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+        after = [ "postgresql.service" "network.target" ];
+        requires = [ "postgresql.service" ];
+        wantedBy = [ "multi-user.target" ];
+
+        description = "dispatch.sr.ht website service";
+
+        serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL dispatch.sr.ht is being served at (protocol://domain)
+      "dispatch.sr.ht".origin = mkDefault "http://dispatch.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "dispatch.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "dispatch.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "dispatch.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "dispatch.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # dispatch.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "dispatch.sr.ht".oauth-client-id = mkDefault null;
+      "dispatch.sr.ht".oauth-client-secret = mkDefault null;
+
+      # Github Integration
+      "dispatch.sr.ht::github".oauth-client-id = mkDefault null;
+      "dispatch.sr.ht::github".oauth-client-secret = mkDefault null;
+
+      # Gitlab Integration
+      "dispatch.sr.ht::gitlab".enabled = mkDefault null;
+      "dispatch.sr.ht::gitlab".canonical-upstream = mkDefault "gitlab.com";
+      "dispatch.sr.ht::gitlab".repo-cache = mkDefault "./repo-cache";
+      # "dispatch.sr.ht::gitlab"."gitlab.com" = mkDefault "GitLab:application id:secret";
+    };
+
+    services.nginx.virtualHosts."dispatch.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.dispatchsrht}/${pkgs.sourcehut.python.sitePackages}/dispatchsrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/git.nix b/nixos/modules/services/misc/sourcehut/git.nix
new file mode 100644
index 00000000000..99b9aec0612
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/git.nix
@@ -0,0 +1,214 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  scfg = cfg.git;
+  iniKey = "git.sr.ht";
+
+  rcfg = config.services.redis;
+  drv = pkgs.sourcehut.gitsrht;
+in
+{
+  options.services.sourcehut.git = {
+    user = mkOption {
+      type = types.str;
+      visible = false;
+      internal = true;
+      readOnly = true;
+      default = "git";
+      description = ''
+        User for git.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5001;
+      description = ''
+        Port on which the "git" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "git.sr.ht";
+      description = ''
+        PostgreSQL database name for git.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/gitsrht";
+      description = ''
+        State path for git.sr.ht.
+      '';
+    };
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.git;
+      example = literalExample "pkgs.gitFull";
+      description = ''
+        Git package for git.sr.ht. This can help silence collisions.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "git" cfg.services) {
+    # sshd refuses to run with `Unsafe AuthorizedKeysCommand ... bad ownership or modes for directory /nix/store`
+    environment.etc."ssh/gitsrht-dispatch" = {
+      mode = "0755";
+      text = ''
+        #! ${pkgs.stdenv.shell}
+        ${cfg.python}/bin/gitsrht-dispatch "$@"
+      '';
+    };
+
+    # Needs this in the $PATH when sshing into the server
+    environment.systemPackages = [ cfg.git.package ];
+
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          # https://stackoverflow.com/questions/22314298/git-push-results-in-fatal-protocol-error-bad-line-length-character-this
+          # Probably could use gitsrht-shell if output is restricted to just parameters...
+          shell = pkgs.bash;
+          description = "git.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services = {
+      cron.systemCronJobs = [ "*/20 * * * * ${cfg.python}/bin/gitsrht-periodic" ];
+      fcgiwrap.enable = true;
+
+      openssh.authorizedKeysCommand = ''/etc/ssh/gitsrht-dispatch "%u" "%h" "%t" "%k"'';
+      openssh.authorizedKeysCommandUser = "root";
+      openssh.extraConfig = ''
+        PermitUserEnvironment SRHT_*
+      '';
+
+      postgresql = {
+        authentication = ''
+          local ${database} ${user} trust
+        '';
+        ensureDatabases = [ database ];
+        ensureUsers = [
+          {
+            name = user;
+            ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+          }
+        ];
+      };
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        # /var/log is owned by root
+        "f /var/log/git-srht-shell 0644 ${user} ${user} -"
+
+        "d ${statePath} 0750 ${user} ${user} -"
+        "d ${cfg.settings."${iniKey}".repos} 2755 ${user} ${user} -"
+      ];
+
+      services = {
+        gitsrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+          after = [ "redis.service" "postgresql.service" "network.target" ];
+          requires = [ "redis.service" "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          # Needs internally to create repos at the very least
+          path = [ pkgs.git ];
+          description = "git.sr.ht website service";
+
+          serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+        };
+
+        gitsrht-webhooks = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "git.sr.ht webhooks service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+          };
+
+          serviceConfig.ExecStart = "${cfg.python}/bin/celery -A ${drv.pname}.webhooks worker --loglevel=info";
+        };
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL git.sr.ht is being served at (protocol://domain)
+      "git.sr.ht".origin = mkDefault "http://git.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "git.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "git.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "git.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "git.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # The redis connection used for the webhooks worker
+      "git.sr.ht".webhooks = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/1";
+
+      # A post-update script which is installed in every git repo.
+      "git.sr.ht".post-update-script = mkDefault "${pkgs.sourcehut.gitsrht}/bin/gitsrht-update-hook";
+
+      # git.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "git.sr.ht".oauth-client-id = mkDefault null;
+      "git.sr.ht".oauth-client-secret = mkDefault null;
+      # Path to git repositories on disk
+      "git.sr.ht".repos = mkDefault "/var/lib/git";
+
+      "git.sr.ht".outgoing-domain = mkDefault "http://git.${cfg.originBase}";
+
+      # The authorized keys hook uses this to dispatch to various handlers
+      # The format is a program to exec into as the key, and the user to match as the
+      # value. When someone tries to log in as this user, this program is executed
+      # and is expected to omit an AuthorizedKeys file.
+      #
+      # Discard of the string context is in order to allow derivation-derived strings.
+      # This is safe if the relevant package is installed which will be the case if the setting is utilized.
+      "git.sr.ht::dispatch".${builtins.unsafeDiscardStringContext "${pkgs.sourcehut.gitsrht}/bin/gitsrht-keys"} = mkDefault "${user}:${user}";
+    };
+
+    services.nginx.virtualHosts."git.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.gitsrht}/${pkgs.sourcehut.python.sitePackages}/gitsrht";
+      extraConfig = ''
+            location = /authorize {
+            proxy_pass http://${cfg.address}:${toString port};
+            proxy_pass_request_body off;
+            proxy_set_header Content-Length "";
+            proxy_set_header X-Original-URI $request_uri;
+        }
+            location ~ ^/([^/]+)/([^/]+)/(HEAD|info/refs|objects/info/.*|git-upload-pack).*$ {
+                auth_request /authorize;
+                root /var/lib/git;
+                fastcgi_pass unix:/run/fcgiwrap.sock;
+                fastcgi_param SCRIPT_FILENAME ${pkgs.git}/bin/git-http-backend;
+                fastcgi_param PATH_INFO $uri;
+                fastcgi_param GIT_PROJECT_ROOT $document_root;
+                fastcgi_read_timeout 500s;
+                include ${pkgs.nginx}/conf/fastcgi_params;
+                gzip off;
+            }
+      '';
+
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/hg.nix b/nixos/modules/services/misc/sourcehut/hg.nix
new file mode 100644
index 00000000000..5cd36bb0455
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/hg.nix
@@ -0,0 +1,173 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  scfg = cfg.hg;
+  iniKey = "hg.sr.ht";
+
+  rcfg = config.services.redis;
+  drv = pkgs.sourcehut.hgsrht;
+in
+{
+  options.services.sourcehut.hg = {
+    user = mkOption {
+      type = types.str;
+      internal = true;
+      readOnly = true;
+      default = "hg";
+      description = ''
+        User for hg.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5010;
+      description = ''
+        Port on which the "hg" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "hg.sr.ht";
+      description = ''
+        PostgreSQL database name for hg.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/hgsrht";
+      description = ''
+        State path for hg.sr.ht.
+      '';
+    };
+
+    cloneBundles = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Generate clonebundles (which require more disk space but dramatically speed up cloning large repositories).
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "hg" cfg.services) {
+    # In case it ever comes into being
+    environment.etc."ssh/hgsrht-dispatch" = {
+      mode = "0755";
+      text = ''
+        #! ${pkgs.stdenv.shell}
+        ${cfg.python}/bin/gitsrht-dispatch $@
+      '';
+    };
+
+    environment.systemPackages = [ pkgs.mercurial ];
+
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          # Assuming hg.sr.ht needs this too
+          shell = pkgs.bash;
+          description = "hg.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services = {
+      cron.systemCronJobs = [ "*/20 * * * * ${cfg.python}/bin/hgsrht-periodic" ]
+        ++ optional cloneBundles "0 * * * * ${cfg.python}/bin/hgsrht-clonebundles";
+
+      openssh.authorizedKeysCommand = ''/etc/ssh/hgsrht-dispatch "%u" "%h" "%t" "%k"'';
+      openssh.authorizedKeysCommandUser = "root";
+      openssh.extraConfig = ''
+        PermitUserEnvironment SRHT_*
+      '';
+
+      postgresql = {
+        authentication = ''
+          local ${database} ${user} trust
+        '';
+        ensureDatabases = [ database ];
+        ensureUsers = [
+          {
+            name = user;
+            ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+          }
+        ];
+      };
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        # /var/log is owned by root
+        "f /var/log/hg-srht-shell 0644 ${user} ${user} -"
+
+        "d ${statePath} 0750 ${user} ${user} -"
+        "d ${cfg.settings."${iniKey}".repos} 2755 ${user} ${user} -"
+      ];
+
+      services.hgsrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+        after = [ "redis.service" "postgresql.service" "network.target" ];
+        requires = [ "redis.service" "postgresql.service" ];
+        wantedBy = [ "multi-user.target" ];
+
+        path = [ pkgs.mercurial ];
+        description = "hg.sr.ht website service";
+
+        serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL hg.sr.ht is being served at (protocol://domain)
+      "hg.sr.ht".origin = mkDefault "http://hg.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "hg.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "hg.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "hg.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # The redis connection used for the webhooks worker
+      "hg.sr.ht".webhooks = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/1";
+      # A post-update script which is installed in every mercurial repo.
+      "hg.sr.ht".changegroup-script = mkDefault "${cfg.python}/bin/hgsrht-hook-changegroup";
+      # hg.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "hg.sr.ht".oauth-client-id = mkDefault null;
+      "hg.sr.ht".oauth-client-secret = mkDefault null;
+      # Path to mercurial repositories on disk
+      "hg.sr.ht".repos = mkDefault "/var/lib/hg";
+      # Path to the srht mercurial extension
+      # (defaults to where the hgsrht code is)
+      # "hg.sr.ht".srhtext = mkDefault null;
+      # .hg/store size (in MB) past which the nightly job generates clone bundles.
+      # "hg.sr.ht".clone_bundle_threshold = mkDefault 50;
+      # Path to hg-ssh (if not in $PATH)
+      # "hg.sr.ht".hg_ssh = mkDefault /path/to/hg-ssh;
+
+      # The authorized keys hook uses this to dispatch to various handlers
+      # The format is a program to exec into as the key, and the user to match as the
+      # value. When someone tries to log in as this user, this program is executed
+      # and is expected to omit an AuthorizedKeys file.
+      #
+      # Uncomment the relevant lines to enable the various sr.ht dispatchers.
+      "hg.sr.ht::dispatch"."/run/current-system/sw/bin/hgsrht-keys" = mkDefault "${user}:${user}";
+    };
+
+    # TODO: requires testing and addition of hg-specific requirements
+    services.nginx.virtualHosts."hg.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.hgsrht}/${pkgs.sourcehut.python.sitePackages}/hgsrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/hub.nix b/nixos/modules/services/misc/sourcehut/hub.nix
new file mode 100644
index 00000000000..be3ea21011c
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/hub.nix
@@ -0,0 +1,118 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.hub;
+  iniKey = "hub.sr.ht";
+
+  drv = pkgs.sourcehut.hubsrht;
+in
+{
+  options.services.sourcehut.hub = {
+    user = mkOption {
+      type = types.str;
+      default = "hubsrht";
+      description = ''
+        User for hub.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5014;
+      description = ''
+        Port on which the "hub" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "hub.sr.ht";
+      description = ''
+        PostgreSQL database name for hub.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/hubsrht";
+      description = ''
+        State path for hub.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "hub" cfg.services) {
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          description = "hub.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services.hubsrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+        after = [ "postgresql.service" "network.target" ];
+        requires = [ "postgresql.service" ];
+        wantedBy = [ "multi-user.target" ];
+
+        description = "hub.sr.ht website service";
+
+        serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL hub.sr.ht is being served at (protocol://domain)
+      "hub.sr.ht".origin = mkDefault "http://hub.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "hub.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "hub.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "hub.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "hub.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # hub.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "hub.sr.ht".oauth-client-id = mkDefault null;
+      "hub.sr.ht".oauth-client-secret = mkDefault null;
+    };
+
+    services.nginx.virtualHosts."${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.hubsrht}/${pkgs.sourcehut.python.sitePackages}/hubsrht";
+    };
+    services.nginx.virtualHosts."hub.${cfg.originBase}" = {
+      globalRedirect = "${cfg.originBase}";
+      forceSSL = true;
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/lists.nix b/nixos/modules/services/misc/sourcehut/lists.nix
new file mode 100644
index 00000000000..7b1fe9fd463
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/lists.nix
@@ -0,0 +1,185 @@
+# Email setup is fairly involved, useful references:
+# https://drewdevault.com/2018/08/05/Local-mail-server.html
+
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.lists;
+  iniKey = "lists.sr.ht";
+
+  rcfg = config.services.redis;
+  drv = pkgs.sourcehut.listssrht;
+in
+{
+  options.services.sourcehut.lists = {
+    user = mkOption {
+      type = types.str;
+      default = "listssrht";
+      description = ''
+        User for lists.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5006;
+      description = ''
+        Port on which the "lists" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "lists.sr.ht";
+      description = ''
+        PostgreSQL database name for lists.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/listssrht";
+      description = ''
+        State path for lists.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "lists" cfg.services) {
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          extraGroups = [ "postfix" ];
+          description = "lists.sr.ht user";
+        };
+      };
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services = {
+        listssrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "lists.sr.ht website service";
+
+          serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+        };
+
+        listssrht-process = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "lists.sr.ht process service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+            ExecStart = "${cfg.python}/bin/celery -A ${drv.pname}.process worker --loglevel=info";
+          };
+        };
+
+        listssrht-lmtp = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "lists.sr.ht process service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+            ExecStart = "${cfg.python}/bin/listssrht-lmtp";
+          };
+        };
+
+
+        listssrht-webhooks = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "lists.sr.ht webhooks service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+            ExecStart = "${cfg.python}/bin/celery -A ${drv.pname}.webhooks worker --loglevel=info";
+          };
+        };
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL lists.sr.ht is being served at (protocol://domain)
+      "lists.sr.ht".origin = mkDefault "http://lists.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "lists.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "lists.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "lists.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "lists.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # lists.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "lists.sr.ht".oauth-client-id = mkDefault null;
+      "lists.sr.ht".oauth-client-secret = mkDefault null;
+      # Outgoing email for notifications generated by users
+      "lists.sr.ht".notify-from = mkDefault "CHANGEME@example.org";
+      # The redis connection used for the webhooks worker
+      "lists.sr.ht".webhooks = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/2";
+      # The redis connection used for the celery worker
+      "lists.sr.ht".redis = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/4";
+      # Network-key
+      "lists.sr.ht".network-key = mkDefault null;
+      # Allow creation
+      "lists.sr.ht".allow-new-lists = mkDefault "no";
+      # Posting Domain
+      "lists.sr.ht".posting-domain = mkDefault "lists.${cfg.originBase}";
+
+      # Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
+      # Alternatively, specify IP:PORT and an SMTP server will be run instead.
+      "lists.sr.ht::worker".sock = mkDefault "/tmp/lists.sr.ht-lmtp.sock";
+      # The lmtp daemon will make the unix socket group-read/write for users in this
+      # group.
+      "lists.sr.ht::worker".sock-group = mkDefault "postfix";
+      "lists.sr.ht::worker".reject-url = mkDefault "https://man.sr.ht/lists.sr.ht/etiquette.md";
+      "lists.sr.ht::worker".reject-mimetypes = mkDefault "text/html";
+
+    };
+
+    services.nginx.virtualHosts."lists.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.listssrht}/${pkgs.sourcehut.python.sitePackages}/listssrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/man.nix b/nixos/modules/services/misc/sourcehut/man.nix
new file mode 100644
index 00000000000..7693396d187
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/man.nix
@@ -0,0 +1,122 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.man;
+  iniKey = "man.sr.ht";
+
+  drv = pkgs.sourcehut.mansrht;
+in
+{
+  options.services.sourcehut.man = {
+    user = mkOption {
+      type = types.str;
+      default = "mansrht";
+      description = ''
+        User for man.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5004;
+      description = ''
+        Port on which the "man" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "man.sr.ht";
+      description = ''
+        PostgreSQL database name for man.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/mansrht";
+      description = ''
+        State path for man.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "man" cfg.services) {
+    assertions =
+      [
+        {
+          assertion = hasAttrByPath [ "git.sr.ht" "oauth-client-id" ] cfgIni;
+          message = "man.sr.ht needs access to git.sr.ht.";
+        }
+      ];
+
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          description = "man.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services.mansrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+        after = [ "postgresql.service" "network.target" ];
+        requires = [ "postgresql.service" ];
+        wantedBy = [ "multi-user.target" ];
+
+        description = "man.sr.ht website service";
+
+        serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL man.sr.ht is being served at (protocol://domain)
+      "man.sr.ht".origin = mkDefault "http://man.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "man.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "man.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "man.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "man.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # man.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "man.sr.ht".oauth-client-id = mkDefault null;
+      "man.sr.ht".oauth-client-secret = mkDefault null;
+    };
+
+    services.nginx.virtualHosts."man.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.mansrht}/${pkgs.sourcehut.python.sitePackages}/mansrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/meta.nix b/nixos/modules/services/misc/sourcehut/meta.nix
new file mode 100644
index 00000000000..56127a824eb
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/meta.nix
@@ -0,0 +1,211 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.meta;
+  iniKey = "meta.sr.ht";
+
+  rcfg = config.services.redis;
+  drv = pkgs.sourcehut.metasrht;
+in
+{
+  options.services.sourcehut.meta = {
+    user = mkOption {
+      type = types.str;
+      default = "metasrht";
+      description = ''
+        User for meta.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5000;
+      description = ''
+        Port on which the "meta" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "meta.sr.ht";
+      description = ''
+        PostgreSQL database name for meta.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/metasrht";
+      description = ''
+        State path for meta.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "meta" cfg.services) {
+    assertions =
+      [
+        {
+          assertion = with cfgIni."meta.sr.ht::billing"; enabled == "yes" -> (stripe-public-key != null && stripe-secret-key != null);
+          message = "If meta.sr.ht::billing is enabled, the keys should be defined.";
+        }
+      ];
+
+    users = {
+      users = {
+        ${user} = {
+          isSystemUser = true;
+          group = user;
+          description = "meta.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.cron.systemCronJobs = [ "0 0 * * * ${cfg.python}/bin/metasrht-daily" ];
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services = {
+        metasrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "meta.sr.ht website service";
+
+          preStart = ''
+            # Configure client(s) as "preauthorized"
+            ${concatMapStringsSep "\n\n"
+              (attr: ''
+                if ! test -e "${statePath}/${attr}.oauth" || [ "$(cat ${statePath}/${attr}.oauth)" != "${cfgIni."${attr}".oauth-client-id}" ]; then
+                  # Configure ${attr}'s OAuth client as "preauthorized"
+                  psql ${database} \
+                    -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${cfgIni."${attr}".oauth-client-id}'"
+
+                  printf "%s" "${cfgIni."${attr}".oauth-client-id}" > "${statePath}/${attr}.oauth"
+                fi
+              '')
+              (builtins.attrNames (filterAttrs
+                (k: v: !(hasInfix "::" k) && builtins.hasAttr "oauth-client-id" v && v.oauth-client-id != null)
+                cfg.settings))}
+          '';
+
+          serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+        };
+
+        metasrht-api = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "meta.sr.ht api service";
+
+          preStart = ''
+            # Configure client(s) as "preauthorized"
+            ${concatMapStringsSep "\n\n"
+              (attr: ''
+                if ! test -e "${statePath}/${attr}.oauth" || [ "$(cat ${statePath}/${attr}.oauth)" != "${cfgIni."${attr}".oauth-client-id}" ]; then
+                  # Configure ${attr}'s OAuth client as "preauthorized"
+                  psql ${database} \
+                    -c "UPDATE oauthclient SET preauthorized = true WHERE client_id = '${cfgIni."${attr}".oauth-client-id}'"
+
+                  printf "%s" "${cfgIni."${attr}".oauth-client-id}" > "${statePath}/${attr}.oauth"
+                fi
+              '')
+              (builtins.attrNames (filterAttrs
+                (k: v: !(hasInfix "::" k) && builtins.hasAttr "oauth-client-id" v && v.oauth-client-id != null)
+                cfg.settings))}
+          '';
+
+          serviceConfig.ExecStart = "${pkgs.sourcehut.metasrht}/bin/metasrht-api -b :${toString (port + 100)}";
+        };
+
+        metasrht-webhooks = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "meta.sr.ht webhooks service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+            ExecStart = "${cfg.python}/bin/celery -A ${drv.pname}.webhooks worker --loglevel=info";
+          };
+
+        };
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL meta.sr.ht is being served at (protocol://domain)
+      "meta.sr.ht".origin = mkDefault "https://meta.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "meta.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "meta.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "meta.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "meta.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # If "yes", the user will be sent the stock sourcehut welcome emails after
+      # signup (requires cron to be configured properly). These are specific to the
+      # sr.ht instance so you probably want to patch these before enabling this.
+      "meta.sr.ht".welcome-emails = mkDefault "no";
+
+      # The redis connection used for the webhooks worker
+      "meta.sr.ht".webhooks = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/6";
+
+      # If "no", public registration will not be permitted.
+      "meta.sr.ht::settings".registration = mkDefault "no";
+      # Where to redirect new users upon registration
+      "meta.sr.ht::settings".onboarding-redirect = mkDefault "https://meta.${cfg.originBase}";
+      # How many invites each user is issued upon registration (only applicable if
+      # open registration is disabled)
+      "meta.sr.ht::settings".user-invites = mkDefault 5;
+
+      # Origin URL for API, 100 more than web
+      "meta.sr.ht".api-origin = mkDefault "http://localhost:5100";
+
+      # You can add aliases for the client IDs of commonly used OAuth clients here.
+      #
+      # Example:
+      "meta.sr.ht::aliases" = mkDefault { };
+      # "meta.sr.ht::aliases"."git.sr.ht" = 12345;
+
+      # "yes" to enable the billing system
+      "meta.sr.ht::billing".enabled = mkDefault "no";
+      # Get your keys at https://dashboard.stripe.com/account/apikeys
+      "meta.sr.ht::billing".stripe-public-key = mkDefault null;
+      "meta.sr.ht::billing".stripe-secret-key = mkDefault null;
+    };
+
+    services.nginx.virtualHosts."meta.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.metasrht}/${pkgs.sourcehut.python.sitePackages}/metasrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/paste.nix b/nixos/modules/services/misc/sourcehut/paste.nix
new file mode 100644
index 00000000000..b2d5151969e
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/paste.nix
@@ -0,0 +1,133 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.paste;
+  iniKey = "paste.sr.ht";
+
+  rcfg = config.services.redis;
+  drv = pkgs.sourcehut.pastesrht;
+in
+{
+  options.services.sourcehut.paste = {
+    user = mkOption {
+      type = types.str;
+      default = "pastesrht";
+      description = ''
+        User for paste.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5011;
+      description = ''
+        Port on which the "paste" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "paste.sr.ht";
+      description = ''
+        PostgreSQL database name for paste.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/pastesrht";
+      description = ''
+        State path for pastesrht.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "paste" cfg.services) {
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          description = "paste.sr.ht user";
+        };
+      };
+
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services = {
+        pastesrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "paste.sr.ht website service";
+
+          serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+        };
+
+        pastesrht-webhooks = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "paste.sr.ht webhooks service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+            ExecStart = "${cfg.python}/bin/celery -A ${drv.pname}.webhooks worker --loglevel=info";
+          };
+
+        };
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL paste.sr.ht is being served at (protocol://domain)
+      "paste.sr.ht".origin = mkDefault "http://paste.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "paste.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "paste.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "paste.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "paste.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # paste.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "paste.sr.ht".oauth-client-id = mkDefault null;
+      "paste.sr.ht".oauth-client-secret = mkDefault null;
+      "paste.sr.ht".webhooks = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/5";
+    };
+
+    services.nginx.virtualHosts."paste.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.pastesrht}/${pkgs.sourcehut.python.sitePackages}/pastesrht";
+    };
+  };
+}
diff --git a/nixos/modules/services/misc/sourcehut/service.nix b/nixos/modules/services/misc/sourcehut/service.nix
new file mode 100644
index 00000000000..65b4ad020f9
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/service.nix
@@ -0,0 +1,66 @@
+{ config, pkgs, lib }:
+serviceCfg: serviceDrv: iniKey: attrs:
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings."${iniKey}";
+  pgSuperUser = config.services.postgresql.superUser;
+
+  setupDB = pkgs.writeScript "${serviceDrv.pname}-gen-db" ''
+    #! ${cfg.python}/bin/python
+    from ${serviceDrv.pname}.app import db
+    db.create()
+  '';
+in
+with serviceCfg; with lib; recursiveUpdate
+{
+  environment.HOME = statePath;
+  path = [ config.services.postgresql.package ] ++ (attrs.path or [ ]);
+  restartTriggers = [ config.environment.etc."sr.ht/config.ini".source ];
+  serviceConfig = {
+    Type = "simple";
+    User = user;
+    Group = user;
+    Restart = "always";
+    WorkingDirectory = statePath;
+  } // (if (cfg.statePath == "/var/lib/sourcehut/${serviceDrv.pname}") then {
+          StateDirectory = [ "sourcehut/${serviceDrv.pname}" ];
+        } else {})
+  ;
+
+  preStart = ''
+    if ! test -e ${statePath}/db; then
+      # Setup the initial database
+      ${setupDB}
+
+      # Set the initial state of the database for future database upgrades
+      if test -e ${cfg.python}/bin/${serviceDrv.pname}-migrate; then
+        # Run alembic stamp head once to tell alembic the schema is up-to-date
+        ${cfg.python}/bin/${serviceDrv.pname}-migrate stamp head
+      fi
+
+      printf "%s" "${serviceDrv.version}" > ${statePath}/db
+    fi
+
+    # Update copy of each users' profile to the latest
+    # See https://lists.sr.ht/~sircmpwn/sr.ht-admins/<20190302181207.GA13778%40cirno.my.domain>
+    if ! test -e ${statePath}/webhook; then
+      # Update ${iniKey}'s users' profile copy to the latest
+      ${cfg.python}/bin/srht-update-profiles ${iniKey}
+
+      touch ${statePath}/webhook
+    fi
+
+    ${optionalString (builtins.hasAttr "migrate-on-upgrade" cfgIni && cfgIni.migrate-on-upgrade == "yes") ''
+      if [ "$(cat ${statePath}/db)" != "${serviceDrv.version}" ]; then
+        # Manage schema migrations using alembic
+        ${cfg.python}/bin/${serviceDrv.pname}-migrate -a upgrade head
+
+        # Mark down current package version
+        printf "%s" "${serviceDrv.version}" > ${statePath}/db
+      fi
+    ''}
+
+    ${attrs.preStart or ""}
+  '';
+}
+  (builtins.removeAttrs attrs [ "path" "preStart" ])
diff --git a/nixos/modules/services/misc/sourcehut/sourcehut.xml b/nixos/modules/services/misc/sourcehut/sourcehut.xml
new file mode 100644
index 00000000000..ab9a8c6cb4b
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/sourcehut.xml
@@ -0,0 +1,115 @@
+<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-sourcehut">
+ <title>Sourcehut</title>
+ <para>
+  <link xlink:href="https://sr.ht.com/">Sourcehut</link> is an open-source,
+  self-hostable software development platform. The server setup can be automated using
+  <link linkend="opt-services.sourcehut.enable">services.sourcehut</link>.
+ </para>
+
+ <section xml:id="module-services-sourcehut-basic-usage">
+  <title>Basic usage</title>
+  <para>
+   Sourcehut is a Python and Go based set of applications.
+   <literal><link linkend="opt-services.sourcehut.enable">services.sourcehut</link></literal>
+   by default will use
+   <literal><link linkend="opt-services.nginx.enable">services.nginx</link></literal>,
+   <literal><link linkend="opt-services.nginx.enable">services.redis</link></literal>,
+   <literal><link linkend="opt-services.nginx.enable">services.cron</link></literal>,
+   and
+   <literal><link linkend="opt-services.postgresql.enable">services.postgresql</link></literal>.
+  </para>
+
+  <para>
+   A very basic configuration may look like this:
+<programlisting>
+{ pkgs, ... }:
+let
+  fqdn =
+    let
+      join = hostName: domain: hostName + optionalString (domain != null) ".${domain}";
+    in join config.networking.hostName config.networking.domain;
+in {
+
+  networking = {
+    <link linkend="opt-networking.hostName">hostName</link> = "srht";
+    <link linkend="opt-networking.domain">domain</link> = "tld";
+    <link linkend="opt-networking.firewall.allowedTCPPorts">firewall.allowedTCPPorts</link> = [ 22 80 443 ];
+  };
+
+  services.sourcehut = {
+    <link linkend="opt-services.sourcehut.enable">enable</link> = true;
+    <link linkend="opt-services.sourcehut.originBase">originBase</link> = fqdn;
+    <link linkend="opt-services.sourcehut.services">services</link> = [ "meta" "man" "git" ];
+    <link linkend="opt-services.sourcehut.settings">settings</link> = {
+        "sr.ht" = {
+          environment = "production";
+          global-domain = fqdn;
+          origin = "https://${fqdn}";
+          # Produce keys with srht-keygen from <package>sourcehut.coresrht</package>.
+          network-key = "SECRET";
+          service-key = "SECRET";
+        };
+        webhooks.private-key= "SECRET";
+    };
+  };
+
+  <link linkend="opt-security.acme.certs._name_.extraDomainNames">security.acme.certs."${fqdn}".extraDomainNames</link> = [
+    "meta.${fqdn}"
+    "man.${fqdn}"
+    "git.${fqdn}"
+  ];
+
+  services.nginx = {
+    <link linkend="opt-services.nginx.enable">enable</link> = true;
+    # only recommendedProxySettings 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;
+
+    # Settings to setup what certificates are used for which endpoint.
+    <link linkend="opt-services.nginx.virtualHosts">virtualHosts</link> = {
+      <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">"${fqdn}".enableACME</link> = true;
+      <link linkend="opt-services.nginx.virtualHosts._name_.useACMEHost">"meta.${fqdn}".useACMEHost</link> = fqdn:
+      <link linkend="opt-services.nginx.virtualHosts._name_.useACMEHost">"man.${fqdn}".useACMEHost</link> = fqdn:
+      <link linkend="opt-services.nginx.virtualHosts._name_.useACMEHost">"git.${fqdn}".useACMEHost</link> = fqdn:
+    };
+  };
+}
+</programlisting>
+  </para>
+
+  <para>
+   The <literal>hostName</literal> option is used internally to configure the nginx
+   reverse-proxy. The <literal>settings</literal> attribute set is
+   used by the configuration generator and the result is placed in <literal>/etc/sr.ht/config.ini</literal>.
+  </para>
+ </section>
+
+ <section xml:id="module-services-sourcehut-configuration">
+  <title>Configuration</title>
+
+  <para>
+   All configuration parameters are also stored in
+   <literal>/etc/sr.ht/config.ini</literal> which is generated by
+   the module and linked from the store to ensure that all values from <literal>config.ini</literal>
+   can be modified by the module.
+  </para>
+
+ </section>
+
+ <section xml:id="module-services-sourcehut-httpd">
+  <title>Using an alternative webserver as reverse-proxy (e.g. <literal>httpd</literal>)</title>
+  <para>
+   By default, <package>nginx</package> is used as reverse-proxy for <package>sourcehut</package>.
+   However, it's possible to use e.g. <package>httpd</package> by explicitly disabling
+   <package>nginx</package> using <xref linkend="opt-services.nginx.enable" /> and fixing the
+   <literal>settings</literal>.
+  </para>
+</section>
+
+</chapter>
diff --git a/nixos/modules/services/misc/sourcehut/todo.nix b/nixos/modules/services/misc/sourcehut/todo.nix
new file mode 100644
index 00000000000..aec773b0669
--- /dev/null
+++ b/nixos/modules/services/misc/sourcehut/todo.nix
@@ -0,0 +1,161 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.services.sourcehut;
+  cfgIni = cfg.settings;
+  scfg = cfg.todo;
+  iniKey = "todo.sr.ht";
+
+  rcfg = config.services.redis;
+  drv = pkgs.sourcehut.todosrht;
+in
+{
+  options.services.sourcehut.todo = {
+    user = mkOption {
+      type = types.str;
+      default = "todosrht";
+      description = ''
+        User for todo.sr.ht.
+      '';
+    };
+
+    port = mkOption {
+      type = types.port;
+      default = 5003;
+      description = ''
+        Port on which the "todo" module should listen.
+      '';
+    };
+
+    database = mkOption {
+      type = types.str;
+      default = "todo.sr.ht";
+      description = ''
+        PostgreSQL database name for todo.sr.ht.
+      '';
+    };
+
+    statePath = mkOption {
+      type = types.path;
+      default = "${cfg.statePath}/todosrht";
+      description = ''
+        State path for todo.sr.ht.
+      '';
+    };
+  };
+
+  config = with scfg; lib.mkIf (cfg.enable && elem "todo" cfg.services) {
+    users = {
+      users = {
+        "${user}" = {
+          isSystemUser = true;
+          group = user;
+          extraGroups = [ "postfix" ];
+          description = "todo.sr.ht user";
+        };
+      };
+      groups = {
+        "${user}" = { };
+      };
+    };
+
+    services.postgresql = {
+      authentication = ''
+        local ${database} ${user} trust
+      '';
+      ensureDatabases = [ database ];
+      ensureUsers = [
+        {
+          name = user;
+          ensurePermissions = { "DATABASE \"${database}\"" = "ALL PRIVILEGES"; };
+        }
+      ];
+    };
+
+    systemd = {
+      tmpfiles.rules = [
+        "d ${statePath} 0750 ${user} ${user} -"
+      ];
+
+      services = {
+        todosrht = import ./service.nix { inherit config pkgs lib; } scfg drv iniKey {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "todo.sr.ht website service";
+
+          serviceConfig.ExecStart = "${cfg.python}/bin/gunicorn ${drv.pname}.app:app -b ${cfg.address}:${toString port}";
+        };
+
+       todosrht-lmtp = {
+         after = [ "postgresql.service" "network.target" ];
+         bindsTo = [ "postgresql.service" ];
+         wantedBy = [ "multi-user.target" ];
+
+         description = "todo.sr.ht process service";
+         serviceConfig = {
+           Type = "simple";
+           User = user;
+           Restart = "always";
+           ExecStart = "${cfg.python}/bin/todosrht-lmtp";
+         };
+       };
+
+        todosrht-webhooks = {
+          after = [ "postgresql.service" "network.target" ];
+          requires = [ "postgresql.service" ];
+          wantedBy = [ "multi-user.target" ];
+
+          description = "todo.sr.ht webhooks service";
+          serviceConfig = {
+            Type = "simple";
+            User = user;
+            Restart = "always";
+            ExecStart = "${cfg.python}/bin/celery -A ${drv.pname}.webhooks worker --loglevel=info";
+          };
+
+        };
+      };
+    };
+
+    services.sourcehut.settings = {
+      # URL todo.sr.ht is being served at (protocol://domain)
+      "todo.sr.ht".origin = mkDefault "http://todo.${cfg.originBase}";
+      # Address and port to bind the debug server to
+      "todo.sr.ht".debug-host = mkDefault "0.0.0.0";
+      "todo.sr.ht".debug-port = mkDefault port;
+      # Configures the SQLAlchemy connection string for the database.
+      "todo.sr.ht".connection-string = mkDefault "postgresql:///${database}?user=${user}&host=/var/run/postgresql";
+      # Set to "yes" to automatically run migrations on package upgrade.
+      "todo.sr.ht".migrate-on-upgrade = mkDefault "yes";
+      # todo.sr.ht's OAuth client ID and secret for meta.sr.ht
+      # Register your client at meta.example.org/oauth
+      "todo.sr.ht".oauth-client-id = mkDefault null;
+      "todo.sr.ht".oauth-client-secret = mkDefault null;
+      # Outgoing email for notifications generated by users
+      "todo.sr.ht".notify-from = mkDefault "CHANGEME@example.org";
+      # The redis connection used for the webhooks worker
+      "todo.sr.ht".webhooks = mkDefault "redis://${rcfg.bind}:${toString rcfg.port}/1";
+      # Network-key
+      "todo.sr.ht".network-key = mkDefault null;
+
+      # Path for the lmtp daemon's unix socket. Direct incoming mail to this socket.
+      # Alternatively, specify IP:PORT and an SMTP server will be run instead.
+      "todo.sr.ht::mail".sock = mkDefault "/tmp/todo.sr.ht-lmtp.sock";
+      # The lmtp daemon will make the unix socket group-read/write for users in this
+      # group.
+      "todo.sr.ht::mail".sock-group = mkDefault "postfix";
+
+      "todo.sr.ht::mail".posting-domain = mkDefault "todo.${cfg.originBase}";
+    };
+
+    services.nginx.virtualHosts."todo.${cfg.originBase}" = {
+      forceSSL = true;
+      locations."/".proxyPass = "http://${cfg.address}:${toString port}";
+      locations."/query".proxyPass = "http://${cfg.address}:${toString (port + 100)}";
+      locations."/static".root = "${pkgs.sourcehut.todosrht}/${pkgs.sourcehut.python.sitePackages}/todosrht";
+    };
+  };
+}