about summary refs log tree commit diff
diff options
context:
space:
mode:
authorsefidel <contact@sefidel.net>2023-03-29 20:54:19 +0900
committersefidel <contact@sefidel.net>2023-04-03 18:32:29 +0900
commitce06f43476863da90dc60dcee606d2b6c5a89a8e (patch)
tree5d14946330cb09ff0ebd97bee59407fccee4d860
downloadinfra-ce06f43476863da90dc60dcee606d2b6c5a89a8e.tar.gz
infra-ce06f43476863da90dc60dcee606d2b6c5a89a8e.zip
project: initial commit
-rw-r--r--.gitignore3
-rw-r--r--LICENSE17
-rw-r--r--README.md11
-rw-r--r--TODO.md16
-rw-r--r--default.nix10
-rw-r--r--flake.lock253
-rw-r--r--flake.nix44
-rw-r--r--lib/README.md13
-rw-r--r--lib/attrs.nix26
-rw-r--r--lib/default.nix18
-rw-r--r--lib/misc.nix9
-rw-r--r--lib/modules.nix54
-rw-r--r--lib/system.nix22
-rw-r--r--modules/README.md9
-rw-r--r--modules/cachix/caches/nix-community.nix12
-rw-r--r--modules/cachix/default.nix13
-rw-r--r--modules/flakes.nix36
-rw-r--r--modules/nix.nix34
-rw-r--r--modules/security.nix59
-rw-r--r--modules/services/acme.nix52
-rw-r--r--modules/services/akkoma/blocklist.toml163
-rw-r--r--modules/services/akkoma/default.nix95
-rw-r--r--modules/services/akkoma/favicon-withbg.pngbin0 -> 17246 bytes
-rw-r--r--modules/services/akkoma/favicon.pngbin0 -> 16693 bytes
-rw-r--r--modules/services/akkoma/logo.pngbin0 -> 1304 bytes
-rw-r--r--modules/services/akkoma/logo.svg71
-rw-r--r--modules/services/akkoma/robots.txt2
-rw-r--r--modules/services/akkoma/terms-of-service.html26
-rw-r--r--modules/services/cgit.nix121
-rw-r--r--modules/services/coredns/_corefile.nix3
-rw-r--r--modules/services/coredns/default.nix18
-rw-r--r--modules/services/coturn.nix64
-rw-r--r--modules/services/dendrite.nix230
-rw-r--r--modules/services/dovecot.nix18
-rw-r--r--modules/services/element-web.nix47
-rw-r--r--modules/services/fail2ban.nix17
-rw-r--r--modules/services/git-daemon.nix29
-rw-r--r--modules/services/gitolite/default.nix108
-rw-r--r--modules/services/gitolite/fix-refs9
-rw-r--r--modules/services/gitolite/rename63
-rw-r--r--modules/services/jitsi.nix38
-rw-r--r--modules/services/ldap.nix76
-rw-r--r--modules/services/matrix-bridge.nix200
-rw-r--r--modules/services/matrix-moderation.nix52
-rw-r--r--modules/services/metrics.nix165
-rw-r--r--modules/services/misskey/config/default.yml156
-rw-r--r--modules/services/misskey/default.nix88
-rw-r--r--modules/services/nginx.nix37
-rw-r--r--modules/services/nixos-mailserver.nix106
-rw-r--r--modules/services/postgresql.nix34
-rw-r--r--modules/services/pubnix.nix20
-rw-r--r--modules/services/sefidel-web.nix26
-rw-r--r--modules/services/soju.nix48
-rw-r--r--modules/services/userweb.nix36
-rw-r--r--modules/services/vikunja.nix50
-rw-r--r--modules/sops.nix21
-rw-r--r--overlays/README.md4
-rw-r--r--overlays/default.nix4
-rw-r--r--overlays/git-daemon-module.nix137
-rw-r--r--overlays/mautrix-signal-module.nix196
-rw-r--r--overlays/mautrix-whatsapp-module.nix192
-rw-r--r--overlays/mjolnir-module/default.nix242
-rw-r--r--overlays/mjolnir-module/mjolnir.md110
-rw-r--r--overlays/mjolnir-module/pantalaimon-options.nix70
-rw-r--r--overlays/mjolnir-package/default.nix80
-rw-r--r--overlays/mjolnir-package/package.json69
-rw-r--r--overlays/mjolnir-package/pin.json5
-rwxr-xr-xoverlays/mjolnir-package/update.sh36
-rw-r--r--overlays/sliding-sync-module.nix87
-rw-r--r--overlays/sliding-sync.nix22
-rw-r--r--overlays/soju-module.nix132
-rw-r--r--scripts/README.md5
-rwxr-xr-xscripts/manage-user79
-rw-r--r--systems/.sops.yaml10
-rw-r--r--systems/cobalt/default.nix303
-rw-r--r--systems/cobalt/hardware-configuration.nix65
-rw-r--r--systems/cobalt/secrets/secrets.yaml39
77 files changed, 4835 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f3790b5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+result
+*.qcow2
+**/**/.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6a528e8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,17 @@
+ISC License
+===================================
+
+Copyright (c) 2023, sefidel <sef@exotic.sh>
+
+Permission to use, copy, modify, and/or distribute this software for any purpose
+with or without fee is hereby granted, provided that the above copyright notice
+and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+THIS SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ae1411b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,11 @@
+exotic->infra
+=============
+
+Infrastructure configurations for exotic.sh
+
+This is still WIP: [TODO](TODO.md)
+
+Systems
+-------
+
+`cobalt` - Main server
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..b08a964
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,16 @@
+# infra->todo
+
+* BEFORE-RELEASE: send mjolnir patch upstream
+
+* move dendrite and matrix-\* modules to modules/matrix/[..]
+* Ensure functionality on polylith setup
+    1. Mirror userdb (mirror ldap, or migrate to pgsql+libnss-pgsql)
+* Add a 'ctl' command to let users control their info
+* move scripts to ./script
+* use same option name for 'hostName', 'domain', etc (perhaps use `fqdn`?)
+* don't blindly enable TLS, make `tls.enable, tls.acmeHost` for all modules
+* set `hostName` default to `config.networking.hostName`
+* DNS server should also be hosted here
+* ^ subdomains should be replaced to `*`
+
+* prometheus dashboard
diff --git a/default.nix b/default.nix
new file mode 100644
index 0000000..afcdc08
--- /dev/null
+++ b/default.nix
@@ -0,0 +1,10 @@
+{ inputs, lib, ... }:
+with lib;
+with lib.my;
+{
+  imports = [
+    inputs.impermanence.nixosModules.impermanence
+  ] ++ mapModulesRec' (toString ./modules) import;
+
+  networking.useDHCP = mkDefault false;
+}
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..6b86b8b
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,253 @@
+{
+  "nodes": {
+    "blobs": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1604995301,
+        "narHash": "sha256-wcLzgLec6SGJA8fx1OEN1yV/Py5b+U5iyYpksUY/yLw=",
+        "owner": "simple-nixos-mailserver",
+        "repo": "blobs",
+        "rev": "2cccdf1ca48316f2cfd1c9a0017e8de5a7156265",
+        "type": "gitlab"
+      },
+      "original": {
+        "owner": "simple-nixos-mailserver",
+        "repo": "blobs",
+        "type": "gitlab"
+      }
+    },
+    "flake-compat": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1668681692,
+        "narHash": "sha256-Ht91NGdewz8IQLtWZ9LCeNXMSXHUss+9COoqu6JLmXU=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "009399224d5e398d03b22badca40a37ac85412a1",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
+    "flake-utils": {
+      "locked": {
+        "lastModified": 1667395993,
+        "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "impermanence": {
+      "locked": {
+        "lastModified": 1675359654,
+        "narHash": "sha256-FPxzuvJkcO49g4zkWLSeuZkln54bLoTtrggZDJBH90I=",
+        "owner": "nix-community",
+        "repo": "impermanence",
+        "rev": "6138eb8e737bffabd4c8fc78ae015d4fd6a7e2fd",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "impermanence",
+        "type": "github"
+      }
+    },
+    "nixos-mailserver": {
+      "inputs": {
+        "blobs": "blobs",
+        "flake-compat": "flake-compat",
+        "nixpkgs": [
+          "unstable"
+        ],
+        "nixpkgs-22_11": "nixpkgs-22_11",
+        "utils": "utils"
+      },
+      "locked": {
+        "lastModified": 1671738303,
+        "narHash": "sha256-PRgqtaWf2kMSYqVmcnmhTh+UsC0RmvXRTr+EOw5VZUA=",
+        "owner": "simple-nixos-mailserver",
+        "repo": "nixos-mailserver",
+        "rev": "6d0d9fb966cc565a3df74d3b686f924c7615118c",
+        "type": "gitlab"
+      },
+      "original": {
+        "owner": "simple-nixos-mailserver",
+        "repo": "nixos-mailserver",
+        "type": "gitlab"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1673606088,
+        "narHash": "sha256-wdYD41UwNwPhTdMaG0AIe7fE1bAdyHe6bB4HLUqUvck=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "37b97ae3dd714de9a17923d004a2c5b5543dfa6d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "nixpkgs-2111": {
+      "locked": {
+        "lastModified": 1659446231,
+        "narHash": "sha256-hekabNdTdgR/iLsgce5TGWmfIDZ86qjPhxDg/8TlzhE=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "eabc38219184cc3e04a974fe31857d8e0eac098d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "ref": "nixos-21.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "nixpkgs-22_11": {
+      "locked": {
+        "lastModified": 1669558522,
+        "narHash": "sha256-yqxn+wOiPqe6cxzOo4leeJOp1bXE/fjPEi/3F/bBHv8=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "ce5fe99df1f15a09a91a86be9738d68fadfbad82",
+        "type": "github"
+      },
+      "original": {
+        "id": "nixpkgs",
+        "ref": "nixos-22.11",
+        "type": "indirect"
+      }
+    },
+    "nixpkgs-stable": {
+      "locked": {
+        "lastModified": 1679748960,
+        "narHash": "sha256-BP8XcYHyj1NxQi04RpyNW8e7KiXSoI+Fy1tXIK2GfdA=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "da26ae9f6ce2c9ab380c0f394488892616fc5a6a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "release-22.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "impermanence": "impermanence",
+        "nixos-mailserver": "nixos-mailserver",
+        "nixpkgs-2111": "nixpkgs-2111",
+        "sefidel-web": "sefidel-web",
+        "sops-nix": "sops-nix",
+        "unstable": "unstable",
+        "unstable-small": "unstable-small"
+      }
+    },
+    "sefidel-web": {
+      "inputs": {
+        "flake-utils": "flake-utils",
+        "nixpkgs": "nixpkgs"
+      },
+      "locked": {
+        "lastModified": 1680013210,
+        "narHash": "sha256-892xzeELa85wJxOro40fVK4zmqbj7ZWJW54cECW7IYg=",
+        "ref": "refs/heads/main",
+        "rev": "ac0028999700416577f9a353530a89bd896cbc0d",
+        "revCount": 3,
+        "type": "git",
+        "url": "https://git.exotic.sh/pub/sefidel/sefidel-web"
+      },
+      "original": {
+        "type": "git",
+        "url": "https://git.exotic.sh/pub/sefidel/sefidel-web"
+      }
+    },
+    "sops-nix": {
+      "inputs": {
+        "nixpkgs": [
+          "unstable"
+        ],
+        "nixpkgs-stable": "nixpkgs-stable"
+      },
+      "locked": {
+        "lastModified": 1679993313,
+        "narHash": "sha256-pfZ/BxJDTifnQBMXg60OhwpJvg96LHvEXGtpHeGcWLM=",
+        "owner": "Mic92",
+        "repo": "sops-nix",
+        "rev": "5b26523e28989a7f56953b695184070c06335814",
+        "type": "github"
+      },
+      "original": {
+        "owner": "Mic92",
+        "repo": "sops-nix",
+        "type": "github"
+      }
+    },
+    "unstable": {
+      "locked": {
+        "lastModified": 1679944645,
+        "narHash": "sha256-e5Qyoe11UZjVfgRfwNoSU57ZeKuEmjYb77B9IVW7L/M=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "4bb072f0a8b267613c127684e099a70e1f6ff106",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "ref": "nixos-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "unstable-small": {
+      "locked": {
+        "lastModified": 1680024716,
+        "narHash": "sha256-f9824KWmxVBI4WLI7o6tDFfj+dW+qj6uQKo0ZRsbaZQ=",
+        "owner": "nixos",
+        "repo": "nixpkgs",
+        "rev": "49079a134fd3d3ac25d5ae1f5516f37770f19138",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nixos",
+        "ref": "nixos-unstable-small",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "utils": {
+      "locked": {
+        "lastModified": 1605370193,
+        "narHash": "sha256-YyMTf3URDL/otKdKgtoMChu4vfVL3vCMkRqpGifhUn0=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "5021eac20303a61fafe17224c087f5519baed54d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..40bd39d
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,44 @@
+{
+  description = "exotic.sh system flake";
+
+  inputs = {
+    unstable.url = "github:nixos/nixpkgs/nixos-unstable";
+    unstable-small.url = "github:nixos/nixpkgs/nixos-unstable-small";
+    nixpkgs-2111.url = "github:nixos/nixpkgs/nixos-21.11";
+
+    impermanence.url = "github:nix-community/impermanence";
+
+    sops-nix.url = "github:Mic92/sops-nix";
+    sops-nix.inputs.nixpkgs.follows = "unstable";
+
+    nixos-mailserver.url = "gitlab:simple-nixos-mailserver/nixos-mailserver";
+    nixos-mailserver.inputs.nixpkgs.follows = "unstable";
+
+    sefidel-web.url = "git+https://git.exotic.sh/pub/sefidel/sefidel-web";
+  };
+
+  outputs = { self, unstable, ... } @ inputs:
+    let
+      inherit (lib.my) mapModulesRec mapSystems;
+
+      system = "x86_64-linux";
+
+      pkgs = import unstable { inherit system; };
+
+      lib = unstable.lib.extend
+        (self: super: { my = import ./lib { inherit pkgs inputs; lib = self; }; });
+    in
+    {
+      lib = lib.my;
+
+      nixosModules = mapModulesRec ./modules import;
+
+      colmena = {
+        meta = {
+          nixpkgs = import unstable { inherit system; overlays = [ (import ./overlays) ]; };
+          specialArgs = { inherit lib inputs system; };
+        };
+      }
+      // mapSystems ./systems { };
+    };
+}
diff --git a/lib/README.md b/lib/README.md
new file mode 100644
index 0000000..21ca023
--- /dev/null
+++ b/lib/README.md
@@ -0,0 +1,13 @@
+infra->lib
+==========
+
+Attribution
+-----------
+
+Most of the 'moving parts' of this is largely copied/modified from:
+
+- [hlissner/dotfiles][hlissner]: 2023-Feb, MIT (C) Henrik Lissner
+- [NobbZ/nixos-config][nobbz]: 2022, MIT (C) Nobert Melzer
+
+[hlissner]: https://github.com/hlissner/dotfiles
+[nobbz]: https://github.com/NobbZ/nixos-config
diff --git a/lib/attrs.nix b/lib/attrs.nix
new file mode 100644
index 0000000..0f8ebd1
--- /dev/null
+++ b/lib/attrs.nix
@@ -0,0 +1,26 @@
+{ lib, ... }:
+
+with builtins;
+with lib;
+rec {
+  # attrsToList
+  attrsToList = attrs:
+    mapAttrsToList (name: value: { inherit name value; }) attrs;
+
+  # mapFilterAttrs ::
+  #   (name -> value -> bool)
+  #   (name -> value -> { name = any; value = any; })
+  #   attrs
+  mapFilterAttrs = pred: f: attrs: filterAttrs pred (mapAttrs' f attrs);
+
+  # Generate an attribute set by mapping a function over a list of values.
+  genAttrs' = values: f: listToAttrs (map f values);
+
+  # anyAttrs :: (name -> value -> bool) attrs
+  anyAttrs = pred: attrs:
+    any (attr: pred attr.name attr.value) (attrsToList attrs);
+
+  # countAttrs :: (name -> value -> bool) attrs
+  countAttrs = pred: attrs:
+    count (attr: pred attr.name attr.value) (attrsToList attrs);
+}
diff --git a/lib/default.nix b/lib/default.nix
new file mode 100644
index 0000000..a275c09
--- /dev/null
+++ b/lib/default.nix
@@ -0,0 +1,18 @@
+{ inputs, lib, pkgs, ... }:
+
+let
+  inherit (lib) makeExtensible attrValues foldr;
+  inherit (modules) mapModules;
+
+  modules = import ./modules.nix {
+    inherit lib;
+    self.attrs = import ./attrs.nix { inherit lib; self = { }; };
+  };
+
+  mylib = makeExtensible (self:
+    with self; mapModules ./.
+      (file: import file { inherit self lib pkgs inputs; }));
+in
+mylib.extend
+  (self: super:
+  foldr (a: b: a // b) { } (attrValues super))
diff --git a/lib/misc.nix b/lib/misc.nix
new file mode 100644
index 0000000..484d0d5
--- /dev/null
+++ b/lib/misc.nix
@@ -0,0 +1,9 @@
+{ pkgs, ... }:
+
+rec {
+  # ifd3f/infra
+  wrapFile = name: path:
+    (pkgs.runCommand name { inherit path; } ''
+      cp -r "$path" "$out"
+    '');
+}
diff --git a/lib/modules.nix b/lib/modules.nix
new file mode 100644
index 0000000..ef7c289
--- /dev/null
+++ b/lib/modules.nix
@@ -0,0 +1,54 @@
+{ self, lib, ... }:
+
+let
+  inherit (builtins) attrValues readDir pathExists concatLists;
+  inherit (lib) id mapAttrsToList filterAttrs hasPrefix hasSuffix nameValuePair removeSuffix;
+  inherit (self.attrs) mapFilterAttrs;
+in
+rec {
+  mapModules = dir: fn:
+    mapFilterAttrs
+      (n: v:
+        v != null &&
+        !(hasPrefix "_" n))
+      (n: v:
+        let path = "${toString dir}/${n}"; in
+        if v == "directory" && pathExists "${path}/default.nix"
+        then nameValuePair n (fn path)
+        else if v == "regular" &&
+          n != "default.nix" &&
+          hasSuffix ".nix" n
+        then nameValuePair (removeSuffix ".nix" n) (fn path)
+        else nameValuePair "" null)
+      (readDir dir);
+
+  mapModules' = dir: fn:
+    attrValues (mapModules dir fn);
+
+  mapModulesRec = dir: fn:
+    mapFilterAttrs
+      (n: v:
+        v != null &&
+        !(hasPrefix "_" n))
+      (n: v:
+        let path = "${toString dir}/${n}"; in
+        if v == "directory"
+        then nameValuePair n (mapModulesRec path fn)
+        else if v == "regular" && n != "default.nix" && hasSuffix ".nix" n
+        then nameValuePair (removeSuffix ".nix" n) (fn path)
+        else nameValuePair "" null)
+      (readDir dir);
+
+  mapModulesRec' = dir: fn:
+    let
+      dirs =
+        mapAttrsToList
+          (k: _: "${dir}/${k}")
+          (filterAttrs
+            (n: v: v == "directory" && !(hasPrefix "_" n))
+            (readDir dir));
+      files = attrValues (mapModules dir id);
+      paths = files ++ concatLists (map (d: mapModulesRec' d id) dirs);
+    in
+    map fn paths;
+}
diff --git a/lib/system.nix b/lib/system.nix
new file mode 100644
index 0000000..8fc4dce
--- /dev/null
+++ b/lib/system.nix
@@ -0,0 +1,22 @@
+{ self, inputs, lib, pkgs, ... }:
+
+with lib;
+with lib.my;
+{
+  mkSystem = path: attrs @ { ... }: {
+    imports = [
+      {
+        networking.hostName = mkDefault
+          (removeSuffix ".nix" (baseNameOf path));
+        system.configurationRevision = self.rev or "dirty";
+      }
+      ../. # /default.nix
+      (import path)
+    ];
+  };
+
+  mapSystems = dir: attrs @ { system ? system, ... }:
+    mapModules dir
+      (hostPath: mkSystem hostPath attrs);
+}
+
diff --git a/modules/README.md b/modules/README.md
new file mode 100644
index 0000000..25031dc
--- /dev/null
+++ b/modules/README.md
@@ -0,0 +1,9 @@
+infra->modules
+==============
+
+This is all the modules used to configure our systems based on its purposes.
+
+As of now, it doesn't have any clear "naming convention", but generally it's
+`<servicename>` for a module configuring one thing, `<capability>` otherwise.
+
+e.g) `dendrite` -> `dendrite`, `prometheus`, `grafana` -> `metrics`
diff --git a/modules/cachix/caches/nix-community.nix b/modules/cachix/caches/nix-community.nix
new file mode 100644
index 0000000..d323939
--- /dev/null
+++ b/modules/cachix/caches/nix-community.nix
@@ -0,0 +1,12 @@
+{ config, lib, ... }:
+
+{
+  nix.settings = {
+    substituters = [
+      "https://nix-community.cachix.org"
+    ];
+    trusted-public-keys = [
+      "nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
+    ];
+  };
+}
diff --git a/modules/cachix/default.nix b/modules/cachix/default.nix
new file mode 100644
index 0000000..9dd55b5
--- /dev/null
+++ b/modules/cachix/default.nix
@@ -0,0 +1,13 @@
+{ config, pkgs, lib, ... }:
+let
+  folder = ./caches;
+  toImport = name: value: folder + ("/" + name);
+  filterCaches = key: value: value == "regular" && lib.hasSuffix ".nix" key;
+  imports = lib.mapAttrsToList toImport (lib.filterAttrs filterCaches (builtins.readDir folder));
+in
+{
+  inherit imports;
+  nix.settings.substituters = [ "https://cache.nixos.org/" ];
+
+  environment.systemPackages = [ pkgs.cachix ];
+}
diff --git a/modules/flakes.nix b/modules/flakes.nix
new file mode 100644
index 0000000..df86369
--- /dev/null
+++ b/modules/flakes.nix
@@ -0,0 +1,36 @@
+{ config, pkgs, unstable, unstable-small, nixpkgs-2111, lib, ... }:
+
+with lib;
+let
+  base = "/etc/nixpkgs/channels";
+  nixpkgsPath = "${base}/nixpkgs";
+  nixpkgsSmallPath = "${base}/nixpkgsSmall";
+  nixpkgs2111Path = "${base}/nixpkgs2111";
+in
+{
+  options.nix.flakes.enable = mkEnableOption "nix flakes";
+
+  config = lib.mkIf config.nix.flakes.enable {
+    nix = {
+      package = pkgs.nixUnstable;
+      experimentalFeatures = "nix-command flakes";
+
+      registry.nixpkgs.flake = unstable;
+      registry.nixpkgsSmall.flake = unstable-small;
+      registry.nixpkgs2111.flake = nixpkgs-2111;
+
+      nixPath = [
+        "nixpkgs=${nixpkgsPath}"
+        "nixpkgsSmall=${nixpkgsSmallPath}"
+        "nixpkgs2111=${nixpkgs2111Path}"
+        "/nix/var/nix/profiles/per-user/root/channels"
+      ];
+    };
+
+    systemd.tmpfiles.rules = [
+      "L+ ${nixpkgsPath}         - - - - ${unstable}"
+      "L+ ${nixpkgsSmallPath} - - - - ${unstable-small}"
+      "L+ ${nixpkgs2111Path} - - - - ${nixpkgs-2111}"
+    ];
+  };
+}
diff --git a/modules/nix.nix b/modules/nix.nix
new file mode 100644
index 0000000..1f61d45
--- /dev/null
+++ b/modules/nix.nix
@@ -0,0 +1,34 @@
+{ config, lib, ... }:
+
+let
+  allowed = config.nix.allowedUnfree;
+in
+{
+  options.nix = {
+    experimentalFeatures = lib.mkOption {
+      type = lib.types.separatedString " ";
+      default = "";
+      description = ''
+        Enables experimental features
+      '';
+    };
+
+    allowedUnfree = lib.mkOption {
+      type = lib.types.listOf lib.types.string;
+      default = [ ];
+      description = ''
+        Allows for  unfree packages by their name.
+      '';
+    };
+  };
+
+  config = lib.mkMerge [
+    (lib.mkIf (config.nix.experimentalFeatures != "") { nix.extraOptions = "experimental-features = ${config.nix.experimentalFeatures}"; })
+    (lib.mkIf (allowed != [ ]) { nixpkgs.config.allowUnfreePredicate = (pkg: __elem (lib.getName pkg) allowed); })
+    { nix.settings.auto-optimise-store = lib.mkDefault true; }
+    {
+      nix.gc.automatic = lib.mkDefault true;
+      nix.gc.options = lib.mkDefault "--delete-older-than 10d";
+    }
+  ];
+}
diff --git a/modules/security.nix b/modules/security.nix
new file mode 100644
index 0000000..d845393
--- /dev/null
+++ b/modules/security.nix
@@ -0,0 +1,59 @@
+{ config, lib, ... }:
+
+{
+  # Security-related system tweaks
+
+  # Prevent replacing the running kernel without reboot.
+  security.protectKernelImage = true;
+
+  # mount /tmp in ram. This makes temp file management faster
+  # on ssd systems, and volatile! Because it's wiped on reboot.
+  boot.tmpOnTmpfs = false;
+  boot.tmpOnTmpfsSize = "80%";
+
+  # Purge /tmp on boot. (fallback option)
+  boot.cleanTmpDir = lib.mkDefault (!config.boot.tmpOnTmpfs);
+
+  boot.kernel.sysctl = {
+    # The Magic SysRq key is a key combo that allows users connected to the
+    # system console of a Linux kernel to perform some low-level commands.
+    # Disable it, since we don't need it, and is a potential security concern.
+    "kernel.sysrq" = 0;
+
+    ## TCP hardening
+    # Prevent bogus ICMP errors from filling up logs.
+    "net.ipv4.icmp_ignore_bogus_error_responses" = 1;
+    # Reverse path filtering causes the kernel to do source validation of
+    # packets received from all interfaces. This can mitigate IP spoofing.
+    "net.ipv4.conf.default.rp_filter" = 1;
+    "net.ipv4.conf.all.rp_filter" = 1;
+    # Do not accept IP source route packets (we're not a router)
+    "net.ipv4.conf.all.accept_source_route" = 0;
+    "net.ipv6.conf.all.accept_source_route" = 0;
+    # Don't send ICMP redirects (again, we're on a router)
+    "net.ipv4.conf.all.send_redirects" = 0;
+    "net.ipv4.conf.default.send_redirects" = 0;
+    # Refuse ICMP redirects (MITM mitigations)
+    "net.ipv4.conf.all.accept_redirects" = 0;
+    "net.ipv4.conf.default.accept_redirects" = 0;
+    "net.ipv4.conf.all.secure_redirects" = 0;
+    "net.ipv4.conf.default.secure_redirects" = 0;
+    "net.ipv6.conf.all.accept_redirects" = 0;
+    "net.ipv6.conf.default.accept_redirects" = 0;
+    # Protects against SYN flood attacks
+    "net.ipv4.tcp_syncookies" = 1;
+    # Incomplete protection again TIME-WAIT assassination
+    "net.ipv4.tcp_rfc1337" = 1;
+
+    ## TCP optimization
+    # TCP Fast Open is a TCP extension that reduces network latency by packing
+    # data in the sender’s initial TCP SYN. Setting 3 = enable TCP Fast Open for
+    # both incoming and outgoing connections:
+    "net.ipv4.tcp_fastopen" = 3;
+    # Bufferbloat mitigations + slight improvement in throughput & latency
+    "net.ipv4.tcp_congestion_control" = "bbr";
+    "net.core.default_qdisc" = "cake";
+  };
+
+  boot.kernelModules = [ "tcp_bbr" ];
+}
diff --git a/modules/services/acme.nix b/modules/services/acme.nix
new file mode 100644
index 0000000..6f6e33e
--- /dev/null
+++ b/modules/services/acme.nix
@@ -0,0 +1,52 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.acme;
+in
+{
+  options.modules.services.acme = {
+    enable = mkEnableOption "ACME certificate manager";
+    email = mkOption {
+      type = types.str;
+      description = mdDoc ''
+        The postmaster email address to use.
+      '';
+    };
+    certs = mkOption {
+      type = types.attrsOf
+        (types.submodule {
+          options = {
+            domain = mkOption {
+              type = types.nullOr types.str;
+              default = null;
+            };
+            subDomains = mkOption { type = types.listOf types.str; };
+          };
+        });
+    };
+    secrets.acme-credentials = mkOption { type = types.str; description = "path to the acme environment file"; };
+  };
+
+  config = mkIf cfg.enable {
+    security.acme = {
+      acceptTerms = true;
+      defaults.email = cfg.email;
+      certs = mapAttrs
+        (name: { domain, subDomains }: {
+          extraDomainNames = lists.forEach subDomains (elem: elem + ".${name}");
+        } // {
+          dnsProvider = "hetzner";
+          dnsPropagationCheck = true;
+          credentialsFile = cfg.secrets.acme-credentials;
+        } // optionalAttrs (domain != null) {
+          domain = domain;
+        })
+        cfg.certs;
+    };
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/acme"
+    ];
+  };
+}
diff --git a/modules/services/akkoma/blocklist.toml b/modules/services/akkoma/blocklist.toml
new file mode 100644
index 0000000..e5eac7a
--- /dev/null
+++ b/modules/services/akkoma/blocklist.toml
@@ -0,0 +1,163 @@
+[followers_only]
+
+[media_nsfw]
+
+[reject]
+"*.tk" = "Free TLD"
+"*.ml" = "Free TLD"
+"*.ga" = "Free TLD"
+"*.cf" = "Free TLD"
+"*.gq" = "Free TLD"
+# Reject list from chaos.social at 2023-02-06
+"activitypub-proxy.cf" = "Only exists to evade instance blocks, details"
+"activitypub-troll.cf" = "Spam"
+"aethy.com" = "Lolicon"
+"bae.st" = "Discrimination, racism, “free speech zone”"
+"baraag.net" = "Lolicon"
+"banepo.st" = "Homophobia"
+"beefyboys.club" = "Discrimination, racism, “free speech zone”"
+"beefyboys.win" = "Discrimination, racism, “free speech zone”"
+"beta.birdsite.live" = "Twitter crossposter"
+"birb.elfenban.de" = "Twitter crossposter"
+"bird.evilcyberhacker.net" = "Twitter crossposter"
+"bird.froth.zone" = "Twitter crossposter"
+"bird.geiger.ee" = "Twitter crossposter"
+"bird.im-in.space" = "Twitter crossposter"
+"bird.istheguy.com" = "Twitter crossposter"
+"bird.karatek.net" = "Twitter crossposter"
+"bird.makeup" = "Twitter crossposter"
+"bird.nzbr.de" = "Twitter crossposter"
+"bird.r669.live" = "Twitter crossposter"
+"bird.seafoam.space" = "Twitter crossposter"
+"birdbots.leptonics.com" = "Twitter crossposter"
+"birdsite.b93.dece.space" = "Twitter crossposter"
+"birdsite.blazelights.dev" = "Twitter crossposter"
+"birdsite.frog.fashion" = "Twitter crossposter"
+"birdsite.gabeappleton.me" = "Twitter crossposter"
+"birdsite.james.moody.name" = "Twitter crossposter"
+"birdsite.koyu.space" = "Twitter crossposter"
+"birdsite.lakedrops.com" = "Twitter crossposter"
+"birdsite.link" = "Twitter crossposter"
+"birdsite.monster" = "Twitter crossposter"
+"birdsite.oliviaappleton.com" = "Twitter crossposter"
+"birdsite.platypush.tech" = "Twitter crossposter"
+"birdsite.slashdev.space" = "Twitter crossposter"
+"birdsite.tcjc.uk" = "Twitter crossposter"
+"birdsite.thorlaksson.com" = "Twitter crossposter"
+"birdsite.toot.si" = "Twitter crossposter"
+"birdsite.wilde.cloud" = "Twitter crossposter"
+"birdsitelive.ffvo.dev" = "Twitter crossposter"
+"birdsitelive.kevinyank.com" = "Twitter crossposter"
+"birdsitelive.peanutlasko.com" = "Twitter crossposter"
+"birdsitelive.treffler.cloud" = "Twitter crossposter"
+"bridge.birb.space" = "Twitter crossposter"
+"brighteon.social" = "“free speech zone”"
+"cawfee.club" = "Discrimination, racism, “free speech zone”"
+"childpawn.shop" = "Pedophilia"
+"chudbuds.lol" = "Discrimination, racism, “free speech zone”"
+"club.darknight-coffee.eu" = "“free speech zone”"
+"clubcyberia.co" = "Homophobia"
+"clube.social" = "Harassment"
+"comfyboy.club" = "Discrimination, racism"
+"cum.camp" = "Harassment"
+"cum.salon" = "Misogynic, pedophilia"
+"daishouri.moe" = "Fascism, openly advertises with swastika"
+"detroitriotcity.com" = "Discrimination, racism, “free speech zone”"
+"eientei.org" = "Racism, antisemitism"
+"eveningzoo.club" = "Discrimination, racism, “free speech zone”"
+"f.haeder.net" = "Discrimination"
+"freak.university" = "Pedophilia"
+"freeatlantis.com" = "Conspiracy theory instance"
+"freecumextremist.com" = "Discrimination, racism, “free speech zone”"
+"freefedifollowers.ga" = "Follower spam"
+"freespeechextremist.com" = "Discrimination, racism, “free speech zone”"
+"frennet.link" = "Discrimination, racism, “free speech zone”"
+"froth.zone" = "Calls freespeechextremist their local bubble"
+"gab.com/.ai, develop.gab.com" = "Discrimination, racism, “free speech zone”"
+"gameliberty.club" = "“free speech zone”"
+"gegenstimme.tv" = "“free speech zone”"
+"genderheretics.xyz" = "Tagline “Now With 41% More Misgendering!”"
+"gitmo.life" = "“free speech zone”"
+"gleasonator.com" = "Transphobia, TERFs"
+"glindr.org" = "Discrimination"
+"glowers.club" = "Discrimination, racism, “free speech zone”"
+"honkwerx.tech" = "Racism"
+"iamterminally.online" = "Discrimination, racism, “free speech zone”"
+"iddqd.social" = "Discrimination, racism, “free speech zone”"
+"itmslaves.com" = "“free speech zone”, noagenda affiliated"
+"jaeger.website" = "Discrimination, racism, “free speech zone”"
+"kenfm.quadplay.tv" = "Conspiracy videos"
+"kiwifarms.cc" = "Discrimination"
+"lgbtfree.zone" = "Racism, transphobia, all that"
+"liberdon.com" = "Conspiracy theories, transphobia, racism"
+"libre.tube" = "Promotion of violence and murder, multiple other violations of our rules"
+"lolicon.rocks" = "Lolicon"
+"lolison.top" = "Lolicon, paedophilia"
+"mastinator.com" = "Block evasion, unwanted profile mirroring, and more"
+"mastodon.network" = "Instance went down, now porn spam"
+"mastodon.popps.org" = "Homophobia"
+"mastodong.lol" = "Admin maintains and runs activitypub-proxy.cf"
+"meta-tube.de" = "Conspiracy, CoVid19 denier videos https://fediblock.org/blocklist/#meta-tube.de"
+"midnightride.rs" = "Discrimination"
+"misskey-forkbomb.cf" = "Spam"
+"morale.ch" = "Antisemitism and more"
+"mstdn.foxfam.club" = "Right wing twitter mirror"
+"natehiggers.online" = "Racism"
+"newjack.city" = "Exclusive to unwanted follow bots"
+"nicecrew.digital" = "Discrimination, racism, “free speech zone”"
+"noagendasocial.com" = "“free speech zone”, harassment"
+"noagendasocial.nl" = "“free speech zone”, harassment"
+"noagendatube.com" = "“free speech zone”, harassment"
+"ns.auction" = "Racism etc"
+"ohai.su" = "Offline"
+"pawoo.net" = "Untagged nfsw content, unwanted follow bots, lolicon"
+"paypig.org" = "Racism"
+"pieville.net" = "Racism, antisemitism"
+"pl.serialmay.link" = "Racism, transphobia"
+"pl.tkammer.de" = "Transphobia"
+"play.xmr.101010.pl" = "Cryptomining"
+"pleroma.kitsunemimi.club" = "Discrimination"
+"pleroma.narrativerry.xyz" = "Discrimination, racism, “free speech zone”"
+"pleroma.nobodyhasthe.biz" = "Doxxing and discrimination"
+"pleroma.rareome.ga" = "Doesn’t respect blocks or status privacy, lolicons"
+"poa.st" = "Discrimination"
+"podcastindex.social" = "noagenda affiliated"
+"poster.place" = "Discrimination, racism, “free speech zone”, harassment in response to blocks"
+"qoto.org" = "“free speech zone”, harassment"
+"rapemeat.solutions" = "Lolicon and also, like, the domain name"
+"rdrama.cc" = "Discrimination, “free speech zone”, racism"
+"repl.co" = "Spam"
+"rojogato.com" = "Harassment, “free speech zone”"
+"ryona.agency" = "Alt-right trolls, harassment"
+"seal.cafe" = "Discrimination, racism, “free speech zone”"
+"shitpost.cloud" = "“Free speech zone”, antisemitism"
+"shitposter.club" = "“Free speech zone”"
+"shortstackran.ch" = "Racism, homophobia, “free speech zone”"
+"shota.house" = "Lolicon"
+"skippers-bin.com" = "Same admin as neckbeard.xyz, same behaviour"
+"sleepy.cafe" = "Racism, harassment"
+"sneak.berlin" = "privacy violation"
+"sneed.social" = "Discrimination, racism, “free speech zone”, nationalism, hate speech, completely unmoderated"
+"soc.ua-fediland.de" = "Spam"
+"social.ancreport.com" = "Discrimination, racism, “free speech zone”"
+"social.lovingexpressions.net" = "Transphobia"
+"social.teci.world" = "Discrimination, racism, “free speech zone”"
+"social.urspringer.de" = "Conspiracy, CoVid19 denier"
+"socnet.supes.com" = "Right wing “free speech zone”"
+"solagg.com" = "Scammers"
+"spinster.xyz" = "Discrimination, TERFs"
+"tastingtraffic.net" = "Homophobia"
+"truthsocial.co.in" = "Alt-right trolls"
+"tube.kenfm.de" = "Right-wing conspiracy videos"
+"tube.querdenken-711.de" = "Right-wing onspiracy videos"
+"tweet.pasture.moe" = "Twitter crossposter"
+"tweetbridge.kogasa.de" = "Twitter crossposter"
+"tweets.icu" = "Twitter crossposter"
+"twitter.activitypub.actor" = "Twitter crossposter"
+"twitter.doesnotexist.club" = "Twitter crossposter"
+"twitterbridge.jannis.rocks" = "Twitter crossposter"
+"twtr.plus" = "Twitter crossposter"
+"varishangout.net" = "Transphobia and racism go unmoderated, aggressive trolling, lolicon permitted in rules"
+"wiki-tube.de" = "Right-wing conspiracy videos (initial video welcomes Querdenken and KenFM)"
+"wolfgirl.bar" = "Discrimination, homophobia, unmoderated trolling"
+"yggdrasil.social" = "Instance rules: “No LGBTQ. Period. No homosexuality. No men who think they’re women or women who think they’re men. No made up genders.”"
diff --git a/modules/services/akkoma/default.nix b/modules/services/akkoma/default.nix
new file mode 100644
index 0000000..a0cd42c
--- /dev/null
+++ b/modules/services/akkoma/default.nix
@@ -0,0 +1,95 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.akkoma;
+
+  poorObfuscation = y: x: "${x}@${y}";
+  federation-blocklist = lib.importTOML ./blocklist.toml;
+
+  inherit (lib.my) wrapFile;
+in
+{
+  options.modules.services.akkoma = {
+    enable = mkEnableOption "Akkoma instance";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; };
+    instanceName = mkOption { type = types.str; default = "Akkoma on ${cfg.domain}"; };
+  };
+
+  config = mkIf cfg.enable {
+    modules.services.postgresql.enable = true;
+
+    services.akkoma = {
+      enable = true;
+      initDb.enable = true;
+
+      extraStatic = {
+        "static/terms-of-service.html" = wrapFile "terms-of-service.html" ./terms-of-service.html;
+        "static/logo.svg" = wrapFile "logo.svg" ./logo.svg;
+        "static/logo.png" = wrapFile "logo.png" ./logo.png;
+        "static/logo-512.png" = wrapFile "logo-512.png" ./favicon-withbg.png; # Intentional, for PWA favicon.
+        "static/icon.png" = wrapFile "icon.png" ./favicon.png;
+        "favicon.png" = wrapFile "favicon.png" ./favicon.png;
+      };
+      config =
+        let inherit ((pkgs.formats.elixirConf { }).lib) mkRaw mkMap;
+        in {
+          ":pleroma"."Pleroma.Web.Endpoint".url.host = cfg.realHost;
+          ":pleroma"."Pleroma.Web.WebFinger".domain = cfg.domain;
+          ":pleroma".":media_proxy".enabled = false;
+          ":pleroma".":instance" = {
+            name = cfg.instanceName;
+
+            description = "Private akkoma instance";
+            email = poorObfuscation cfg.domain "postmaster";
+            notify_email = poorObfuscation cfg.domain "postmaster";
+
+            registrations_open = false;
+            invites_enabled = true;
+
+            limit = 5000;
+          };
+          ":pleroma".":frontend_configurations" = {
+            pleroma_fe = mkMap {
+              logo = "/static/logo.png";
+            };
+          };
+          ":pleroma".":mrf" = {
+            policies = map mkRaw [ "Pleroma.Web.ActivityPub.MRF.SimplePolicy" ];
+          };
+          ":pleroma".":mrf_simple" = {
+            followers_only = mkMap federation-blocklist.followers_only;
+            media_nsfw = mkMap federation-blocklist.media_nsfw;
+            reject = mkMap federation-blocklist.reject;
+          };
+        };
+
+      nginx = {
+        forceSSL = true;
+        useACMEHost = cfg.domain;
+
+        locations."~ \\.(js|css|woff|woff2?|png|jpe?g|svg)$" = {
+          extraConfig = ''
+            add_header Cache-Control "public, max-age=14400, must-revalidate";
+          '';
+
+          proxyPass = "http://unix:${config.services.akkoma.config.":pleroma"."Pleroma.Web.Endpoint".http.ip}";
+          proxyWebsockets = true;
+          recommendedProxySettings = true;
+        };
+      };
+    };
+
+      services.nginx.virtualHosts.${cfg.domain} = {
+        forceSSL = true;
+        useACMEHost = cfg.domain;
+
+        locations."/.well-known/host-meta" = {
+          extraConfig = ''
+            return 301 https://${cfg.realHost}$request_uri;
+          '';
+        };
+      };
+    };
+  }
diff --git a/modules/services/akkoma/favicon-withbg.png b/modules/services/akkoma/favicon-withbg.png
new file mode 100644
index 0000000..7d15954
--- /dev/null
+++ b/modules/services/akkoma/favicon-withbg.png
Binary files differdiff --git a/modules/services/akkoma/favicon.png b/modules/services/akkoma/favicon.png
new file mode 100644
index 0000000..d8cbce3
--- /dev/null
+++ b/modules/services/akkoma/favicon.png
Binary files differdiff --git a/modules/services/akkoma/logo.png b/modules/services/akkoma/logo.png
new file mode 100644
index 0000000..7744b1a
--- /dev/null
+++ b/modules/services/akkoma/logo.png
Binary files differdiff --git a/modules/services/akkoma/logo.svg b/modules/services/akkoma/logo.svg
new file mode 100644
index 0000000..68e647e
--- /dev/null
+++ b/modules/services/akkoma/logo.svg
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   xmlns:dc="http://purl.org/dc/elements/1.1/"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   version="1.1"
+   id="svg4485"
+   width="512"
+   height="512"
+   viewBox="0 0 512 512"
+   sodipodi:docname="logo.svg"
+   inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
+  <metadata
+     id="metadata4491">
+    <rdf:RDF>
+      <cc:Work
+         rdf:about="">
+        <dc:format>image/svg+xml</dc:format>
+        <dc:type
+           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+        <dc:title />
+      </cc:Work>
+    </rdf:RDF>
+  </metadata>
+  <defs
+     id="defs4489" />
+  <sodipodi:namedview
+     pagecolor="#ffffff"
+     bordercolor="#666666"
+     borderopacity="1"
+     objecttolerance="10"
+     gridtolerance="10"
+     guidetolerance="10"
+     inkscape:pageopacity="0"
+     inkscape:pageshadow="2"
+     inkscape:window-width="1274"
+     inkscape:window-height="1410"
+     id="namedview4487"
+     showgrid="false"
+     inkscape:zoom="1.2636719"
+     inkscape:cx="305.99333"
+     inkscape:cy="304.30809"
+     inkscape:window-x="1280"
+     inkscape:window-y="22"
+     inkscape:window-maximized="0"
+     inkscape:current-layer="g4612"
+     inkscape:document-rotation="0" />
+  <g
+     id="g4612">
+    <g
+       id="g850"
+       transform="matrix(0.99659595,0,0,0.99659595,0.37313949,0.87143746)">
+      <path
+         style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#009bff;stroke-width:0;stroke-linecap:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.175879"
+         d="m 194.75841,124.65165 a 20.449443,20.449443 0 0 0 -20.44944,20.44945 v 242.24725 h 65.28091 v -262.6967 z"
+         id="path4497" />
+      <path
+         style="fill:#fba457;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         d="M 272.6236,124.65165 V 256 h 45.61799 a 20.449443,20.449443 0 0 0 20.44944,-20.44945 v -110.8989 z"
+         id="path4516" />
+      <path
+         style="opacity:1;fill:#fba457;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+         d="m 272.6236,322.06744 v 65.28091 h 45.61799 a 20.449443,20.449443 0 0 0 20.44944,-20.44945 v -44.83146 z"
+         id="path4516-5" />
+    </g>
+  </g>
+</svg>
diff --git a/modules/services/akkoma/robots.txt b/modules/services/akkoma/robots.txt
new file mode 100644
index 0000000..1f53798
--- /dev/null
+++ b/modules/services/akkoma/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/modules/services/akkoma/terms-of-service.html b/modules/services/akkoma/terms-of-service.html
new file mode 100644
index 0000000..b954760
--- /dev/null
+++ b/modules/services/akkoma/terms-of-service.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+  <head></head>
+  <body>
+    <h2>Terms of Service</h2>
+    <p>This is a personal instance with only one user. Therefore, I'll write rules that I'll abide:</p>
+    <ol>
+      <li>
+        <p>No discrimination based on race, gender, sexual orientation, disabilities, or any other characteristic.</p>
+      </li>
+      <li>
+        <p>No harassment or doxxing towards others.</p>
+      </li>
+      <li>
+        <p>No promotion of violence.</p>
+      </li>
+      <li>
+        <p>No content that is illegal in United Kingdom, Japan, Finland, Germany, and South Korea.</p>
+      </li>
+      <li>
+        <p>Use content warnings for explicit or controversial content.</p>
+      </li>
+    </ol>
+    <p>Since I'm the only user here, I try to moderate myself best as I can. But I might sometimes fail to do so. If that ever happens, please do let me know. I'll make sure it never happens again!</p>
+  </body>
+</html>
diff --git a/modules/services/cgit.nix b/modules/services/cgit.nix
new file mode 100644
index 0000000..418312b
--- /dev/null
+++ b/modules/services/cgit.nix
@@ -0,0 +1,121 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.cgit;
+in
+{
+  options.modules.services.cgit = {
+    enable = mkEnableOption "cgit with uwsgi";
+
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; };
+    # TODO: use generators & submodules
+    settings = {
+      title = mkOption { type = types.str; default = "${cfg.domain} git"; };
+      description = mkOption { type = types.str; default = "cgit, hyperfast web frontend for Git"; };
+    };
+  };
+  config = mkIf cfg.enable {
+
+    modules.services.nginx.enable = true;
+
+    services.uwsgi = {
+      enable = true;
+      user = "nginx";
+      group = "nginx";
+      plugins = [ "cgi" ];
+
+      instance = {
+        type = "emperor";
+        vassals = {
+          cgit = {
+            type = "normal";
+            master = true;
+            socket = "/run/uwsgi/cgit.sock";
+            procname-master = "uwsgi cgit";
+            plugins = [ "cgi" ];
+            cgi = "${pkgs.cgit-pink}/cgit/cgit.cgi";
+          };
+        };
+      };
+    };
+
+    users.extraUsers.nginx.extraGroups = [ "git" ];
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+      root = "${pkgs.cgit-pink}/cgit";
+      locations = {
+        "/" = {
+          extraConfig = ''
+            try_files $uri @cgit;
+          '';
+        };
+        "@cgit" = {
+          extraConfig = ''
+            uwsgi_pass unix:/run/uwsgi/cgit.sock;
+            include ${pkgs.nginx}/conf/uwsgi_params;
+            uwsgi_modifier1 9;
+          '';
+        };
+      };
+    };
+
+    networking.firewall.allowedTCPPorts = [ 80 443 ];
+
+    systemd.services.create-cgit-cache = {
+      description = "Create cache directory for cgit";
+      enable = true;
+
+      script = ''
+        mkdir -p /run/cgit
+        chown -R nginx:nginx /run/cgit
+      '';
+
+      wantedBy = [ "uwsgi.service" ];
+      serviceConfig = {
+        Type = "oneshot";
+      };
+    };
+
+    environment.etc."cgitrc".text = ''
+      virtual-root=/
+
+      cache-size=1000
+      cache-root=/run/cgit
+
+      root-title=${cfg.domain} git
+      root-desc=Exotic place.
+
+      snapshots=tar.gz zip
+
+      enable-git-config=1
+      remove-suffix=1
+
+      enable-git-clone=1
+      enable-index-links=1
+      enable-commit-graph=1
+      enable-log-filecount=1
+      enable-log-linecount=1
+
+      branch-sort=age
+
+      readme=:README
+      readme=:readme
+      readme=:README.md
+      readme=:readme.md
+      readme=:README.org
+      readme=:readme.org
+
+      source-filter=${pkgs.cgit-pink}/lib/cgit/filters/syntax-highlighting.py
+      about-filter=${pkgs.cgit-pink}/lib/cgit/filters/about-formatting.sh
+
+      section-from-path=2
+
+      project-list=${config.services.gitolite.dataDir}/projects.list
+      scan-path=${config.services.gitolite.dataDir}/repositories
+    '';
+  };
+}
diff --git a/modules/services/coredns/_corefile.nix b/modules/services/coredns/_corefile.nix
new file mode 100644
index 0000000..8d0ec66
--- /dev/null
+++ b/modules/services/coredns/_corefile.nix
@@ -0,0 +1,3 @@
+''
+Add content here
+''
diff --git a/modules/services/coredns/default.nix b/modules/services/coredns/default.nix
new file mode 100644
index 0000000..52d8570
--- /dev/null
+++ b/modules/services/coredns/default.nix
@@ -0,0 +1,18 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.coredns;
+in
+{
+  options.modules.services.coredns = {
+    enable = mkEnableOption "coredns";
+  };
+
+  config = mkIf cfg.enable {
+    services.coredns = {
+      enable = true;
+      config = import ./_corefile.nix;
+    };
+  };
+}
diff --git a/modules/services/coturn.nix b/modules/services/coturn.nix
new file mode 100644
index 0000000..967ba60
--- /dev/null
+++ b/modules/services/coturn.nix
@@ -0,0 +1,64 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  turnRange = with config.services.coturn; [{
+    from = min-port;
+    to = max-port;
+  }];
+
+  cfg = config.modules.services.coturn;
+in
+{
+  options.modules.services.coturn = {
+    enable = mkEnableOption "coturn";
+    domain = mkOption { type = types.str; default = config.networking.hostName; };
+    shared_secret = mkOption { type = types.str; };
+    tls.acmeHost = mkOption { type = types.str; default = cfg.domain; };
+  };
+
+  config = mkIf cfg.enable {
+    services.coturn = {
+      enable = true;
+      use-auth-secret = true;
+      static-auth-secret = cfg.shared_secret;
+      realm = cfg.domain;
+      cert = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/fullchain.pem";
+      pkey = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/key.pem";
+
+      no-tcp-relay = true;
+      no-cli = true;
+
+      extraConfig = ''
+        user-quota=12
+        total-quota=1200
+
+        no-loopback-peers
+        no-multicast-peers
+        denied-peer-ip=0.0.0.0-0.255.255.255
+        denied-peer-ip=10.0.0.0-10.255.255.255
+        denied-peer-ip=100.64.0.0-100.127.255.255
+        denied-peer-ip=127.0.0.0-127.255.255.255
+        denied-peer-ip=169.254.0.0-169.254.255.255
+        denied-peer-ip=172.16.0.0-172.31.255.255
+        denied-peer-ip=192.0.0.0-192.0.0.255
+        denied-peer-ip=192.0.2.0-192.0.2.255
+        denied-peer-ip=192.88.99.0-192.88.99.255
+        denied-peer-ip=192.168.0.0-192.168.255.255
+        denied-peer-ip=198.18.0.0-198.19.255.255
+        denied-peer-ip=198.51.100.0-198.51.100.255
+        denied-peer-ip=203.0.113.0-203.0.113.255
+        denied-peer-ip=240.0.0.0-255.255.255.255
+      '';
+    };
+
+    systemd.services.coturn = {
+      serviceConfig.SupplementaryGroups = [ "acme" ];
+    };
+
+    networking.firewall.allowedUDPPortRanges = turnRange;
+    networking.firewall.allowedTCPPortRanges = turnRange;
+    networking.firewall.allowedTCPPorts = [ 3478 3479 5349 5350 ];
+    networking.firewall.allowedUDPPorts = [ 3478 3479 5349 5350 ];
+  };
+}
diff --git a/modules/services/dendrite.nix b/modules/services/dendrite.nix
new file mode 100644
index 0000000..70f9db8
--- /dev/null
+++ b/modules/services/dendrite.nix
@@ -0,0 +1,230 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.dendrite;
+
+  database = {
+    connection_string = "postgres:///dendrite?host=/run/postgresql";
+    max_open_conns = 100;
+    max_idle_conns = 5;
+    conn_max_lifetime = -1;
+  };
+in
+{
+  imports = [
+    ../../overlays/sliding-sync-module.nix
+  ];
+
+  options.modules.services.dendrite = {
+    enable = mkEnableOption "dendrite instance";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; default = "matrix.${cfg.domain}"; };
+    slidingSyncHost = mkOption { type = types.str; default = "slidingsync.${cfg.domain}"; };
+    turn = {
+      enable = mkEnableOption "VOIP suing TURN";
+      domain = mkOption { type = types.str; default = "turn.${cfg.domain}"; };
+      shared_secret = mkOption { type = types.str; };
+    };
+    secrets = {
+      matrix-server-key = mkOption { type = types.str; description = "path to the server key"; };
+      dendrite-envs = mkOption { type = types.nullOr types.str; description = "path for the environment file to source"; };
+      sliding-sync-secret = mkOption { type = types.nullOr types.str; description = "path to the sliding sync secret"; };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    # Adapted from Mic92/dotfiles, (C) 2021 Jörg Thalheim (MIT)
+    services.dendrite = {
+      enable = true;
+      settings = {
+        global = {
+          server_name = cfg.domain;
+          # `private_key` has the type `path`
+          # prefix a `/` to make `path` happy
+          private_key = "/$CREDENTIALS_DIRECTORY/matrix-server-key";
+          jetstream.storage_path = "/var/lib/dendrite/jetstream";
+          trusted_third_party_id_servers = [
+            "matrix.org"
+            "vector.im"
+          ];
+          metrics.enabled = true;
+        };
+        logging = [
+          {
+            type = "std";
+            level = "info"; # "warn" on public release
+          }
+        ];
+        app_service_api = {
+          inherit database;
+          config_files = [ ];
+        };
+        client_api = {
+          registration_disabled = true;
+          rate_limiting.enabled = false;
+          rate_limiting.exempt_user_ids = [
+            "@abuse:${cfg.domain}"
+          ];
+          # registration_shared_secret = ""; # Initially set this option to configure the admin user.
+        } // optionalAttrs cfg.turn.enable {
+          turn = {
+            turn_user_lifetime = "24h";
+            turn_uris = [
+              "turns:${cfg.turn.domain}?transport=udp"
+              "turns:${cfg.turn.domain}?transport=tcp"
+              "turn:${cfg.turn.domain}?transport=udp"
+              "turn:${cfg.turn.domain}?transport=tcp"
+            ];
+            turn_shared_secret = cfg.turn.shared_secret;
+          };
+        };
+        media_api = {
+          inherit database;
+          dynamic_thumbnails = true;
+        };
+        room_server = {
+          inherit database;
+        };
+        push_server = {
+          inherit database;
+        };
+        mscs = {
+          inherit database;
+          mscs = [ "msc2836" "msc2946" ];
+        };
+        sync_api = {
+          inherit database;
+          real_ip_header = "X-Real-IP";
+          # The NixOS option is 'enable', which doesn't exist in Dendrite.
+          search.enabled = true;
+        };
+        key_server = {
+          inherit database;
+        };
+        federation_api = {
+          inherit database;
+          key_perspectives = [
+            {
+              server_name = "matrix.org";
+              keys = [
+                {
+                  key_id = "ed25519:auto";
+                  public_key = "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw";
+                }
+                {
+                  key_id = "ed25519:a_RXGa";
+                  public_key = "l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ";
+                }
+              ];
+            }
+          ];
+          prefer_direct_fetch = false;
+        };
+        user_api = {
+          account_database = database;
+          device_database = database;
+        };
+      };
+      loadCredential = [ "matrix-server-key:${cfg.secrets.matrix-server-key}" ];
+    } // optionalAttrs (cfg.secrets.dendrite-envs != null) {
+      environmentFile = cfg.secrets.dendrite-envs;
+    };
+
+    services.prometheus.scrapeConfigs = [
+      {
+        job_name = "dendrite";
+        static_configs = [{
+          targets = [ "127.0.0.1:${toString config.services.dendrite.httpPort}" ];
+        }];
+      }
+    ];
+
+    systemd.services.dendrite = {
+      after = [ "postgresql.service" ];
+    };
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/private/dendrite"
+    ];
+
+    services.sliding-sync = {
+      enable = true;
+      server = "https://${cfg.realHost}";
+      bindAddr = "[::1]:8009";
+      db = "postgres:///syncv3?host=/run/postgresql";
+      secret = cfg.secrets.sliding-sync-secret;
+    };
+
+    services.postgresql.enable = true;
+    services.postgresql.ensureDatabases = [ "dendrite" "syncv3" ];
+    services.postgresql.ensureUsers = [
+      {
+        name = "dendrite";
+        ensurePermissions."DATABASE dendrite" = "ALL PRIVILEGES";
+      }
+      {
+        name = "sliding-sync";
+        ensurePermissions."DATABASE syncv3" = "ALL PRIVILEGES";
+      }
+    ];
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+      listen = [
+        { addr = "0.0.0.0"; port = 443; ssl = true; }
+        { addr = "[::]"; port = 443; ssl = true; }
+        { addr = "0.0.0.0"; port = 8448; ssl = true; }
+        { addr = "[::]"; port = 8448; ssl = true; }
+
+      ];
+      extraConfig = ''
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_read_timeout 600;
+        client_max_body_size 50M;
+      '';
+      locations."/_matrix".proxyPass = "http://[::1]:${toString config.services.dendrite.httpPort}";
+      locations."/_dendrite".proxyPass = "http://[::1]:${toString config.services.dendrite.httpPort}";
+      locations."/_synapse".proxyPass = "http://[::1]:${toString config.services.dendrite.httpPort}";
+    };
+
+    services.nginx.virtualHosts.${cfg.domain} =
+      let
+        server-hello = { "m.server" = "${cfg.realHost}:443"; };
+        client-hello = {
+          "m.homeserver"."base_url" = "https://${cfg.realHost}";
+          "m.identity_server"."base_url" = "https://vector.im";
+          "org.matrix.msc3575.proxy"."url" = "https://${cfg.slidingSyncHost}";
+        };
+      in
+      {
+        forceSSL = true;
+        useACMEHost = cfg.domain;
+        locations = {
+          "/.well-known/matrix/server" = {
+            extraConfig = ''
+              add_header Content-Type application/json;
+              return 200 '${builtins.toJSON server-hello}';
+            '';
+          };
+          "/.well-known/matrix/client" = {
+            extraConfig = ''
+              add_header Content-Type application/json;
+              add_header Access-Control-Allow-Origin *;
+              return 200 '${builtins.toJSON client-hello}';
+            '';
+          };
+        };
+      };
+
+    services.nginx.virtualHosts.${cfg.slidingSyncHost} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+      locations."/".proxyPass = "http://${config.services.sliding-sync.bindAddr}";
+    };
+
+    networking.firewall.allowedTCPPorts = [ 443 8448 ];
+  };
+}
diff --git a/modules/services/dovecot.nix b/modules/services/dovecot.nix
new file mode 100644
index 0000000..a33b0d1
--- /dev/null
+++ b/modules/services/dovecot.nix
@@ -0,0 +1,18 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.dovecot;
+in
+{
+  options.modules.services.dovecot = {
+    enable = mkEnableOption "dovecot";
+  };
+
+  config = mkIf cfg.enable {
+    services.dovecot2 = {
+      enable = true;
+    };
+    networking.firewall.allowedTCPPorts = [ 587 465 ];
+  };
+}
diff --git a/modules/services/element-web.nix b/modules/services/element-web.nix
new file mode 100644
index 0000000..2b200bd
--- /dev/null
+++ b/modules/services/element-web.nix
@@ -0,0 +1,47 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.element-web;
+in
+{
+  options.modules.services.element-web = {
+    enable = mkEnableOption "element-web";
+    package = mkOption { type = types.package; default = pkgs.element-web; };
+    hostName = mkOption { type = types.str; default = config.networking.hostName; };
+    matrix = {
+      baseUrl = mkOption { type = types.str; default = "https://matrix.${config.networking.hostName}"; };
+      serverName = mkOption { type = types.str; default = config.networking.hostName; };
+    };
+    tls.acmeHost = mkOption { type = types.str; default = cfg.hostName; };
+    jitsi.domain = mkOption { type = types.str; default = "jitsi.${cfg.hostName}"; };
+  };
+
+  config = mkIf cfg.enable {
+    services.nginx.virtualHosts.${cfg.hostName} = {
+      useACMEHost = cfg.tls.acmeHost;
+      forceSSL = true;
+
+      root = cfg.package.override {
+        conf = {
+          default_server_config = {
+            "m.homeserver" = {
+              "base_url" = cfg.matrix.baseUrl;
+              "server_name" = cfg.matrix.serverName;
+            };
+            "m.identity_server" = {
+              "base_url" = "https://vector.im";
+            };
+          };
+          showLabsSettings = true;
+        } // optionalAttrs (cfg.jitsi.domain != null) {
+          jitsi.preferredDomain = cfg.jitsi.domain;
+        };
+      };
+
+      locations."~ \\.(js|css|woff|woff2?|png|jpe?g|svg)$".extraConfig = ''
+        add_header Cache-Control "public, max-age=14400, must-revalidate";
+      '';
+    };
+  };
+}
diff --git a/modules/services/fail2ban.nix b/modules/services/fail2ban.nix
new file mode 100644
index 0000000..99351b1
--- /dev/null
+++ b/modules/services/fail2ban.nix
@@ -0,0 +1,17 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.fail2ban;
+in
+{
+  options.modules.services.fail2ban = {
+    enable = mkEnableOption "fail2ban";
+  };
+
+  config = mkIf cfg.enable {
+    services.fail2ban = {
+      enable = true;
+    };
+  };
+}
diff --git a/modules/services/git-daemon.nix b/modules/services/git-daemon.nix
new file mode 100644
index 0000000..5d027de
--- /dev/null
+++ b/modules/services/git-daemon.nix
@@ -0,0 +1,29 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.gitDaemon;
+in
+{
+  disabledModules = [
+    "services/networking/git-daemon.nix"
+  ];
+
+  imports = [
+    ../../overlays/git-daemon-module.nix
+  ];
+
+  options.modules.services.gitDaemon = {
+    enable = mkEnableOption "git daemon";
+  };
+
+  config = mkIf cfg.enable {
+    services.gitDaemon = {
+      enable = true;
+      createUserAndGroup = false;
+      basePath = "/var/lib/gitolite/repositories";
+    };
+
+    networking.firewall.allowedTCPPorts = [ 9418 ];
+  };
+}
diff --git a/modules/services/gitolite/default.nix b/modules/services/gitolite/default.nix
new file mode 100644
index 0000000..c2eb975
--- /dev/null
+++ b/modules/services/gitolite/default.nix
@@ -0,0 +1,108 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.gitolite;
+in
+{
+  options.modules.services.gitolite = {
+    enable = mkEnableOption "gitolite server";
+    adminPubkey = mkOption { type = types.str; };
+  };
+  config = mkIf cfg.enable {
+    services.openssh.enable = true;
+
+    services.gitolite = {
+      enable = true;
+      user = "git";
+      group = "git";
+      adminPubkey = cfg.adminPubkey;
+      extraGitoliteRc = ''
+        $RC{UMASK} = 0027;
+        $RC{GIT_CONFIG_KEYS} = '.*';
+        $RC{ROLES}{OWNERS} = 1;
+        $RC{OWNER_ROLENAME} = 'OWNERS';
+        # For some unknown reason, $ENV{HOME} doesn't get resolved to the correct
+        # directory.
+        # $RC{LOCAL_CODE} = '$ENV{HOME}/local';
+        $RC{LOCAL_CODE} = '/var/lib/gitolite/local';
+        push(@{$RC{ENABLE}}, 'D');
+        push(@{$RC{ENABLE}}, 'symbolic-ref');
+        push(@{$RC{ENABLE}}, 'rename');
+        push(@{$RC{POST_GIT}}, 'fix-refs');
+        # push(@{$RC{ENABLE}}, 'set-default-roles');
+        # push(@{$RC{ENABLE}}, 'create');
+        # push(@{$RC{ENABLE}}, 'fork');
+
+      '';
+    };
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/gitolite"
+    ];
+
+    system.activationScripts.gitolite-create-local = ''
+      mkdir -p /var/lib/gitolite/local/triggers
+      mkdir -p /var/lib/gitolite/local/commands
+      chown -R git:git /var/lib/gitolite/local
+    '';
+
+    systemd.tmpfiles.rules = [
+      # https://groups.google.com/g/gitolite/c/NwZ1-hq9-9E/m/mDbiKyAvDwAJ
+      "C /var/lib/gitolite/local/triggers/fix-refs 755 - - - ${./fix-refs}"
+      "C /var/lib/gitolite/local/commands/rename 755 - - - ${./rename}"
+    ];
+
+
+    systemd.timers."gitolite-trash-cleanup" = {
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        OnCalendar = "*-*-* 00:00:00";
+        Unit = "gitolite-trash-cleanup.service";
+      };
+    };
+
+    systemd.services."gitolite-trash-cleanup" = {
+      script = ''
+        set -euo pipefail
+        if [ ! -d "Trash" ] ; then
+          echo Trash directory is nonexistent!
+          echo No operations to perform. Exiting.
+          exit 0
+        fi
+
+        match=$(find Trash -type d -regextype posix-extended -regex ".*/[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}:[0-9]{2}:[0-9]{2}$")
+        processed_entry=0
+        removed_entry=0
+
+        for dir in $match
+        do
+          system_timestamp=$(date +%s)
+          trash_timestamp=$(basename $dir | sed -e "s/_/ /g" | date -f - +%s)
+          age=$(( $system_timestamp - $trash_timestamp ))
+          # Wipe trashes older than 2w
+          if [[ age -gt 1209600 ]] ; then
+            echo "Removing '$dir' (age $age)"
+            rm -rf $dir
+            ((removed_entry+=1))
+          fi
+          ((processed_entry+=1))
+        done
+
+        echo "Directories that needs cleanup:"
+        find Trash -type d -empty -print -delete
+        echo "Cleaned empty directories."
+
+        echo "Done! Removed $removed_entry/$processed_entry"
+      '';
+
+      path = with pkgs; [ bash util-linux coreutils ];
+
+      serviceConfig = {
+        Type = "oneshot";
+        User = "git";
+        WorkingDirectory = "/var/lib/gitolite/repositories";
+      };
+    };
+  };
+}
diff --git a/modules/services/gitolite/fix-refs b/modules/services/gitolite/fix-refs
new file mode 100644
index 0000000..8ffec9e
--- /dev/null
+++ b/modules/services/gitolite/fix-refs
@@ -0,0 +1,9 @@
+[[ $4 == W ]] || exit 0
+
+cd $GL_REPO_BASE/$2.git
+
+head=`git symbolic-ref HEAD`
+[[ -f $head ]] || {
+  set -- refs/heads/*
+  git symbolic-ref HEAD $1
+}
diff --git a/modules/services/gitolite/rename b/modules/services/gitolite/rename
new file mode 100644
index 0000000..2b00c7a
--- /dev/null
+++ b/modules/services/gitolite/rename
@@ -0,0 +1,63 @@
+
+# Usage:    ssh git@host rename [-c] <repo1> <repo2>
+#
+# Renames repo1 to repo2. You must be the creator of repo1, and have
+# create ("C") permissions for repo2, which of course must not exist.
+# Alternatively you must be an account admin, that is, you must have
+# write access to the gitolite-admin repository. If you have "C"
+# permissions for repo2 then you can use the -c option to take over
+# as creator of the repository.
+
+die() { echo "$@" >&2; exit 1; }
+usage() { perl -lne 'print substr($_, 2) if /^# Usage/../^$/' < $0; exit 1; }
+[ -z "$1" ] && usage
+[ "$1" = "-h" ] && usage
+[ -z "$GL_USER" ] && die GL_USER not set
+
+# ----------------------------------------------------------------------
+
+if [ "$1" = "-c" ]
+then	shift
+	takeover=true
+else	takeover=false
+fi
+
+from="$1"; shift
+to="$1"; shift
+[ -z "$to" ] && usage
+
+topath=$GL_REPO_BASE/$to.git
+
+checkto() {
+	gitolite access -q "$to" $GL_USER ^C any ||
+		die "'$to' already exists or you are not allowed to create it"
+}
+
+if gitolite access -q gitolite-admin $GL_USER
+then
+	# the user is an admin so we can avoid most permission checks
+	if $takeover
+	then checkto
+	elif [ -e $topath ]
+	then die "'$to' already exists"
+	fi
+else
+	# the user isn't an admin, so do all the checks
+	checkto
+	gitolite creator "$from" $GL_USER ||
+		die "'$from' does not exist or you are not allowed to delete it"
+fi
+
+# ----------------------------------------------------------------------
+
+mv $GL_REPO_BASE/$from.git $topath
+[ $? -ne 0 ] && exit 1
+
+$takeover && echo $GL_USER > $topath/gl-creator
+
+# Rebuild projects.list
+gitolite trigger POST_COMPILE
+
+echo "$from renamed to $to" >&2
+
+exit
diff --git a/modules/services/jitsi.nix b/modules/services/jitsi.nix
new file mode 100644
index 0000000..ab02bb4
--- /dev/null
+++ b/modules/services/jitsi.nix
@@ -0,0 +1,38 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.jitsi;
+in
+{
+  options.modules.services.jitsi = {
+    enable = mkEnableOption "jitsi";
+    hostName = mkOption { type = types.str; default = config.networking.hostName; };
+    tls.acmeHost = mkOption { type = types.str; default = cfg.hostName; };
+  };
+
+  config = mkIf cfg.enable {
+    services.jitsi-meet = {
+      enable = true;
+      hostName = cfg.hostName;
+
+      config = {
+        prejoinPageEnabled = true;
+      };
+
+      interfaceConfig = {
+        SHOW_JITSI_WATERMARK = false;
+      };
+    };
+
+    services.jitsi-videobridge.openFirewall = true;
+
+    services.nginx.virtualHosts.${cfg.hostName} = {
+      enableACME = mkForce false;
+      useACMEHost = cfg.tls.acmeHost;
+      forceSSL = true;
+    };
+
+    networking.firewall.allowedTCPPorts = [ 80 443 ];
+  };
+}
diff --git a/modules/services/ldap.nix b/modules/services/ldap.nix
new file mode 100644
index 0000000..ba19761
--- /dev/null
+++ b/modules/services/ldap.nix
@@ -0,0 +1,76 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.ldap;
+in
+{
+  options.modules.services.ldap = {
+    enable = mkEnableOption "OpenLDAP server";
+    package = mkOption { type = types.package; default = pkgs.openldap; };
+    dc = mkOption { type = types.str; };
+    tld = mkOption { type = types.str; };
+    tls.acmeHost = mkOption { type = types.str; default = "${cfg.dc}.${cfg.tld}"; };
+    secrets.rootPass = mkOption { type = types.str; description = "path to the root password file"; };
+  };
+
+  config = mkIf cfg.enable {
+    services.openldap = {
+      enable = true;
+
+      urlList = [ "ldap:///" "ldaps:///" ];
+
+      settings = {
+        attrs = {
+          olcLogLevel = "conns config";
+
+          olcTLSCACertificateFile = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/full.pem";
+          olcTLSCertificateFile = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/cert.pem";
+          olcTLSCertificateKeyFile = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/key.pem";
+          olcTLSCipherSuite = "HIGH:MEDIUM:+3DES:+RC4:+aNULL";
+          olcTLSCRLCheck = "none";
+          olcTLSVerifyClient = "never";
+          olcTLSProtocolMin = "3.1";
+        };
+
+        children = {
+          "cn=schema".includes = [
+            "${cfg.package}/etc/schema/core.ldif"
+            "${cfg.package}/etc/schema/cosine.ldif"
+            "${cfg.package}/etc/schema/inetorgperson.ldif"
+          ];
+
+          "olcDatabase={1}mdb".attrs = {
+            objectClass = [ "olcDatabaseConfig" "olcMdbConfig" ];
+
+            olcDatabase = "{1}mdb";
+            olcDbDirectory = "/var/lib/openldap/data";
+
+            olcSuffix = "dc=${cfg.dc},dc=${cfg.tld}";
+
+            olcRootDN = "cn=admin,dc=${cfg.dc},dc=${cfg.tld}";
+            olcRootPW.path = cfg.secrets.rootPass;
+
+            olcAccess = [
+              # ''{0}to <changeme>
+              # by <changeme>''
+
+              ''{0}to *
+                  by * none'' # Should be changed to {1}
+            ];
+          };
+        };
+      };
+    };
+
+    systemd.services.openldap = {
+      after = [ "acme-finished-${cfg.tls.acmeHost}.target" ];
+    };
+
+    users.groups.acme.members = [ "openldap" ];
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/openldap"
+    ];
+  };
+}
diff --git a/modules/services/matrix-bridge.nix b/modules/services/matrix-bridge.nix
new file mode 100644
index 0000000..65d8187
--- /dev/null
+++ b/modules/services/matrix-bridge.nix
@@ -0,0 +1,200 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.matrix-bridge;
+in
+{
+  imports = [
+    ../../overlays/mautrix-signal-module.nix
+    ../../overlays/mautrix-whatsapp-module.nix
+  ];
+
+  options.modules.services.matrix-bridge = {
+    enable = mkEnableOption "matrix-bridge";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; default = "matrix.${cfg.domain}"; };
+    secrets.mautrix-envs = mkOption { type = types.str; description = "path to the mautrix-* environment file"; };
+  };
+
+  config = mkIf cfg.enable {
+    services.mautrix-telegram = {
+      enable = true;
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "dendrite.service" ];
+
+      settings = {
+        homeserver.address = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.verify_ssl = true;
+        appservice = {
+          address = "http://localhost:29317";
+          port = 29317;
+          database = "postgres:///mautrix-telegram?host=/run/postgresql";
+          bot_avatar = "mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX";
+          id = "telegram";
+          max_body_size = 1;
+          provisioning.enabled = false;
+        };
+        bridge = {
+          alias_template = "tg_{groupname}";
+          username_templace = "tg_{userid}";
+          allow_matrix_login = true;
+          bot_messages_as_notices = true;
+          catch_up = true;
+          plaintext_highlights = true;
+          startup_sync = true;
+          animated_stickers = {
+            target = "webp";
+            convert_from_webm = true;
+          };
+          permissions = {
+            "@sef:exotic.sh" = "admin";
+            "exotic.sh" = "full";
+          };
+        };
+      };
+    };
+
+    services.mautrix-signal = {
+      enable = true;
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "dendrite.service" ];
+
+      settings = {
+        homeserver.address = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.verify_ssl = true;
+        appservice = {
+          address = "http://localhost:29318";
+          port = 29318;
+          database = "postgres:///mautrix-signal?host=/run/postgresql";
+          bot_avatar = "mxc://maunium.net/wPJgTQbZOtpBFmDNkiNEMDUp";
+          id = "signal";
+          max_body_size = 1;
+          provisioning.enabled = false;
+        };
+
+        signal = {
+          avatar_dir = "/var/lib/signald/avatars";
+          data_dir = "/var/lib/signald/data";
+        };
+
+        bridge = {
+          alias_template = "sig_{groupname}";
+          username_templace = "sig_{userid}";
+          allow_matrix_login = true;
+          catch_up = true;
+          plaintext_highlights = true;
+          startup_sync = true;
+          animated_stickers = {
+            target = "webp";
+            convert_from_webm = true;
+          };
+          permissions = {
+            "@sef:exotic.sh" = "admin";
+            "exotic.sh" = "full";
+          };
+        };
+      };
+    };
+
+    services.mautrix-whatsapp = {
+      enable = true;
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "dendrite.service" ];
+
+      settings = {
+        homeserver.address = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.verify_ssl = true;
+        appservice = {
+          address = "http://localhost:29319";
+          port = 29319;
+          database = {
+            type = "postgres";
+            uri = "postgres://mautrix-whatsapp:@/mautrix-whatsapp?host=/run/postgresql";
+          };
+          bot_avatar = "mxc://maunium.net/NeXNQarUbrlYBiPCpprYsRqr";
+          id = "whatsapp";
+          max_body_size = 1;
+          provisioning.enabled = false;
+        };
+
+        bridge = {
+          alias_template = "wa_{groupname}";
+          username_templace = "wa_{userid}";
+          personal_filtering_spaces = true;
+          delivery_receipts = true;
+          identity_change_notices = true;
+          hystory_sync = {
+            backfill = false; # MSC2716
+            request_full_sync = true;
+          };
+          send_presence_on_typing = true;
+          double_puppet_server_map = { };
+          login_shared_secret_map = { };
+          private_chat_portal_meta = true;
+          mute_bridging = true;
+          pinned_tag = "m.favourite";
+          archive_tag = "m.lowpriority";
+          allow_user_invite = true;
+          disappearing_messages_in_groups = true;
+          url_previews = true;
+          # TODO: https://github.com/matrix-org/dendrite/issues/2723
+          # encryption = {
+          #   allow = true;
+          #   default = true;
+          #   require = true;
+          #   allow_key_sharing = true;
+          # };
+          sync_manual_marked_unread = true;
+          force_active_delivery_receipts = true;
+          parallel_member_sync = true;
+          extev_polls = true;
+          send_whatsapp_edits = true;
+          permissions = {
+            "@sef:exotic.sh" = "admin";
+            "exotic.sh" = "full";
+          };
+        };
+      };
+    };
+
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/private/mautrix-telegram"
+      "/var/lib/private/mautrix-signal"
+      "/var/lib/private/mautrix-whatsapp"
+      "/var/lib/signald"
+    ];
+
+    modules.services.postgresql.enable = true;
+    services.postgresql.ensureDatabases = [ "mautrix-telegram" "mautrix-signal" "mautrix-whatsapp" ];
+    services.postgresql.ensureUsers = [
+      {
+        name = "mautrix-telegram";
+        ensurePermissions."DATABASE \"mautrix-telegram\"" = "ALL PRIVILEGES";
+      }
+      {
+        name = "mautrix-signal";
+        ensurePermissions."DATABASE \"mautrix-signal\"" = "ALL PRIVILEGES";
+      }
+      {
+        name = "mautrix-whatsapp";
+        ensurePermissions."DATABASE \"mautrix-whatsapp\"" = "ALL PRIVILEGES";
+      }
+    ];
+
+    systemd.services.dendrite = {
+      serviceConfig.SupplementaryGroups = [ "mautrix-telegram" "mautrix-signal" "mautrix-whatsapp" ];
+    };
+
+    services.dendrite.settings.app_service_api.config_files = [
+      # Symlinks doesn't seem to work. Provide the actual path.
+      "/persist/var/lib/private/mautrix-telegram/telegram-registration.yaml"
+      "/persist/var/lib/private/mautrix-signal/signal-registration.yaml"
+      "/persist/var/lib/private/mautrix-whatsapp/whatsapp-registration.yaml"
+    ];
+  };
+}
diff --git a/modules/services/matrix-moderation.nix b/modules/services/matrix-moderation.nix
new file mode 100644
index 0000000..c8f0702
--- /dev/null
+++ b/modules/services/matrix-moderation.nix
@@ -0,0 +1,52 @@
+{ config, lib, ... }:
+
+# TODO: rename
+
+with lib;
+let
+  cfg = config.modules.services.matrix-moderation;
+in
+{
+  disabledModules = [
+    "services/matrix/mjolnir.nix"
+  ];
+
+  imports = [
+    ../../overlays/mjolnir-module
+  ];
+
+  options.modules.services.matrix-moderation = {
+    enable = mkEnableOption "matrix-moderation";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; default = "matrix.${cfg.domain}"; };
+    secrets.userPassword = mkOption { type = types.str; description = "path to the mjolnir password"; };
+  };
+
+  config = mkIf cfg.enable {
+
+    services.mjolnir = {
+      enable = true;
+      homeserverUrl = "https://${cfg.realHost}";
+      pantalaimon.enable = true;
+      pantalaimon.username = "abuse";
+      pantalaimon.passwordFile = cfg.secrets.userPassword;
+      managementRoom = "#moderation:${cfg.domain}";
+
+      settings = {
+        homeserverUrl = "http://127.0.0.1:8009";
+        automaticallyRedactForReasons = [
+          "spam"
+          "advertising"
+          "unwanted"
+        ];
+      };
+    };
+
+    systemd.services.mjolnir.after = [ "dendrite.service" ];
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/private/pantalaimon-mjolnir"
+      "/var/lib/mjolnir"
+    ];
+  };
+}
diff --git a/modules/services/metrics.nix b/modules/services/metrics.nix
new file mode 100644
index 0000000..74f7e9a
--- /dev/null
+++ b/modules/services/metrics.nix
@@ -0,0 +1,165 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.metrics;
+in
+{
+  options.modules.services.metrics = {
+    enable = mkEnableOption "metrics";
+    domain = mkOption { type = types.str; };
+    tls.acmeHost = mkOption { type = types.str; default = cfg.domain; };
+  };
+
+  config = mkIf cfg.enable {
+    services.prometheus = {
+      enable = true;
+      port = 9001;
+
+      exporters = {
+        node = {
+          enable = true;
+          enabledCollectors = [ "systemd" ];
+          port = 9002;
+        };
+      };
+
+      scrapeConfigs = [
+        {
+          job_name = "node";
+          static_configs = [{
+            targets = [ "127.0.0.1:${toString config.services.prometheus.exporters.node.port}" ];
+          }];
+        }
+      ];
+    };
+
+    services.loki = {
+      enable = true;
+      configuration = {
+        auth_enabled = false;
+        server.http_listen_port = 3100;
+
+        ingester = {
+          lifecycler = {
+            address = "127.0.0.1";
+            ring.kvstore.store = "inmemory";
+            ring.replication_factor = 1;
+            final_sleep = "0s";
+          };
+          chunk_idle_period = "1h";
+          max_chunk_age = "1h";
+          chunk_target_size = 1048576; # 1.5M
+          chunk_retain_period = "30s";
+          max_transfer_retries = 0;
+        };
+
+        schema_config.configs = [
+          {
+            from = "2023-02-24";
+            store = "boltdb-shipper";
+            object_store = "filesystem";
+            schema = "v11";
+            index = {
+              prefix = "index_";
+              period = "24h";
+            };
+          }
+        ];
+
+        storage_config = {
+          boltdb_shipper = {
+            active_index_directory = "/var/lib/loki/boltdb-shipper-active";
+            cache_location = "/var/lib/loki/boltdb-shipper-cache";
+            cache_ttl = "24h";
+            shared_store = "filesystem";
+          };
+
+          filesystem.directory = "/var/lib/loki/chunks";
+        };
+
+        limits_config = {
+          reject_old_samples = true;
+          reject_old_samples_max_age = "168h";
+        };
+
+        chunk_store_config = {
+          max_look_back_period = "0s";
+        };
+
+        table_manager = {
+          retention_deletes_enabled = false;
+          retention_period = "0s";
+        };
+
+        compactor = {
+          working_directory = "/var/lib/loki";
+          shared_store = "filesystem";
+          compactor_ring.kvstore.store = "inmemory";
+        };
+      };
+    };
+
+    services.promtail = {
+      enable = true;
+      configuration = {
+        server = {
+          http_listen_port = 3031;
+          grpc_listen_port = 0;
+        };
+        positions.filename = "/tmp/positions.yaml";
+        clients = [
+          { url = "http://127.0.0.1:${toString config.services.loki.configuration.server.http_listen_port}/loki/api/v1/push"; }
+        ];
+        scrape_configs = [
+          {
+            job_name = "journal";
+            journal = {
+              max_age = "12h";
+              labels = {
+                job = "systemd-journal";
+                host = config.networking.hostName;
+              };
+            };
+            relabel_configs = [
+              {
+                source_labels = [ "__journal__systemd_unit" ];
+                target_label = "unit";
+              }
+            ];
+          }
+        ];
+      };
+    };
+
+    services.grafana = {
+      enable = true;
+
+      settings.server.http_addr = "127.0.0.1";
+      settings.server.http_port = 2342;
+      settings.server.domain = cfg.domain;
+      settings.security.admin_password = "supersecurepass";
+    };
+
+    services.nginx.virtualHosts.${cfg.domain} = {
+      forceSSL = true;
+      useACMEHost = cfg.tls.acmeHost;
+
+      locations."/" = {
+        proxyPass = "http://localhost:${toString config.services.grafana.settings.server.http_port}";
+        proxyWebsockets = true;
+        extraConfig = ''
+          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+          # proxy_set_header Host $host;
+        '';
+      };
+    };
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/prometheus2"
+      "/var/lib/loki"
+      "/var/lib/grafana"
+    ];
+  };
+}
+
diff --git a/modules/services/misskey/config/default.yml b/modules/services/misskey/config/default.yml
new file mode 100644
index 0000000..cab83b8
--- /dev/null
+++ b/modules/services/misskey/config/default.yml
@@ -0,0 +1,156 @@
+#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+# Misskey configuration
+#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+#   ┌─────┐
+#───┘ URL └─────────────────────────────────────────────────────
+
+# Final accessible URL seen by a user.
+url: https://nand.moe
+
+# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
+# URL SETTINGS AFTER THAT!
+
+#   ┌───────────────────────┐
+#───┘ Port and TLS settings └───────────────────────────────────
+
+#
+# Misskey requires a reverse proxy to support HTTPS connections.
+#
+#                 +----- https://example.tld/ ------------+
+#   +------+      |+-------------+      +----------------+|
+#   | User | ---> || Proxy (443) | ---> | Misskey (3000) ||
+#   +------+      |+-------------+      +----------------+|
+#                 +---------------------------------------+
+#
+#   You need to set up a reverse proxy. (e.g. nginx)
+#   An encrypted connection with HTTPS is highly recommended
+#   because tokens may be transferred in GET requests.
+
+# The port that your Misskey server should listen on.
+port: 3000
+
+#   ┌──────────────────────────┐
+#───┘ PostgreSQL configuration └────────────────────────────────
+
+db:
+  host: localhost
+  port: 5432
+
+  # Database name
+  db: misskey
+
+  # Auth
+  user: misskey
+  # pass: example-misskey-pass
+
+  # Whether disable Caching queries
+  #disableCache: true
+
+  # Extra Connection options
+  #extra:
+  #  ssl: true
+
+#   ┌─────────────────────┐
+#───┘ Redis configuration └─────────────────────────────────────
+
+redis:
+  host: localhost
+  port: 16434
+  family: 4  # 0=Both, 4=IPv4, 6=IPv6
+  #pass: example-pass
+  #prefix: example-prefix
+  #db: 1
+
+#   ┌─────────────────────────────┐
+#───┘ Elasticsearch configuration └─────────────────────────────
+
+#elasticsearch:
+#  host: localhost
+#  port: 9200
+#  ssl: false
+#  user: 
+#  pass: 
+
+#   ┌───────────────┐
+#───┘ ID generation └───────────────────────────────────────────
+
+# You can select the ID generation method.
+# You don't usually need to change this setting, but you can
+# change it according to your preferences.
+
+# Available methods:
+# aid ... Short, Millisecond accuracy
+# meid ... Similar to ObjectID, Millisecond accuracy
+# ulid ... Millisecond accuracy
+# objectid ... This is left for backward compatibility
+
+# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
+# ID SETTINGS AFTER THAT!
+
+id: 'aid'
+
+#   ┌─────────────────────┐
+#───┘ Other configuration └─────────────────────────────────────
+
+# Whether disable HSTS
+#disableHsts: true
+
+# Number of worker processes
+#clusterLimit: 1
+
+# Job concurrency per worker
+# deliverJobConcurrency: 128
+# inboxJobConcurrency: 16
+
+# Job rate limiter
+# deliverJobPerSec: 128
+# inboxJobPerSec: 16
+
+# Job attempts
+# deliverJobMaxAttempts: 12
+# inboxJobMaxAttempts: 8
+
+# IP address family used for outgoing request (ipv4, ipv6 or dual)
+#outgoingAddressFamily: ipv4
+
+# Proxy for HTTP/HTTPS
+#proxy: http://127.0.0.1:3128
+
+proxyBypassHosts:
+  - api.deepl.com
+  - api-free.deepl.com
+  - www.recaptcha.net
+  - hcaptcha.com
+  - challenges.cloudflare.com
+
+# Proxy for SMTP/SMTPS
+#proxySmtp: http://127.0.0.1:3128   # use HTTP/1.1 CONNECT
+#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4
+#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
+
+# Media Proxy
+# Reference Implementation: https://github.com/misskey-dev/media-proxy
+# * Deliver a common cache between instances
+# * Perform image compression (on a different server resource than the main process)
+#mediaProxy: https://example.com/proxy
+
+# Proxy remote files (default: false)
+# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains.
+#proxyRemoteFiles: true
+
+# Movie Thumbnail Generation URL
+# There is no reference implementation.
+# For example, Misskey will point to the following URL:
+#   https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
+#videoThumbnailGenerator: https://example.com
+
+# Sign to ActivityPub GET request (default: true)
+signToActivityPubGet: true
+
+#allowedPrivateNetworks: [
+#  '127.0.0.1/32'
+#]
+
+# Upload or download file size limits (bytes)
+#maxFileSize: 262144000
diff --git a/modules/services/misskey/default.nix b/modules/services/misskey/default.nix
new file mode 100644
index 0000000..355e91f
--- /dev/null
+++ b/modules/services/misskey/default.nix
@@ -0,0 +1,88 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.misskey;
+
+  inherit (lib.my) wrapFile;
+in
+{
+  options.modules.services.misskey = {
+    enable = mkEnableOption "Misskey, an interplanetary microblogging platform [container]";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; };
+  };
+
+  config = mkIf cfg.enable {
+    # TODO: refactor
+
+    # Misskey sets uid/gid to 991 in container, user is created here to
+    # ensure that misskey files directory is accessible by the container user.
+    users = {
+      users.misskey = {
+        description = "Misskey user";
+        group = "misskey";
+        extraGroups = [ "podman" ];
+        isSystemUser = true;
+        uid = 991;
+      };
+      groups.misskey = { gid = 991; };
+    };
+
+    virtualisation.podman.extraPackages = [ pkgs.zfs ];
+
+    # Packaging misskey is too much of a hassle, so we're using containers for now.
+    virtualisation.oci-containers.containers.misskey = {
+      volumes = [
+        "/var/lib/misskey-files:/misskey/files"
+        # TODO: manage this with nix
+        "${wrapFile ".config" ./config}:/misskey/.config:ro"
+      ];
+      image = "misskey/misskey:13.10.3";
+      ports = [ "3000:3000" ];
+      extraOptions = [
+        "--network=host"
+      ];
+    };
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/containers"
+      "/var/lib/misskey-files"
+    ];
+
+    systemd.tmpfiles.rules = [
+      "d /var/lib/misskey-files 0755 misskey misskey -"
+    ];
+
+    services.postgresql.enable = true;
+    services.postgresql.ensureDatabases = [ "misskey" ];
+    services.postgresql.ensureUsers = [
+      {
+        name = "misskey";
+        ensurePermissions."DATABASE misskey" = "ALL PRIVILEGES";
+      }
+    ];
+
+    services.redis.servers.misskey = {
+      enable = true;
+      bind = "127.0.0.1";
+      port = 16434;
+    };
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+      locations."/" = {
+        proxyPass = "http://127.0.0.1:3000";
+        proxyWebsockets = true;
+      };
+
+      extraConfig = ''
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto https;
+      '';
+    };
+  };
+}
diff --git a/modules/services/nginx.nix b/modules/services/nginx.nix
new file mode 100644
index 0000000..f9a5a31
--- /dev/null
+++ b/modules/services/nginx.nix
@@ -0,0 +1,37 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.nginx;
+in
+{
+  options.modules.services.nginx = {
+    enable = mkEnableOption "nginx proxy";
+  };
+  config = mkIf cfg.enable {
+    modules.services.acme.enable = true;
+
+    services.nginx = {
+      enable = true;
+      # prevent 3~5s downtime on update
+      enableReload = true;
+
+      recommendedGzipSettings = true;
+      recommendedOptimisation = true;
+      recommendedProxySettings = true;
+      recommendedTlsSettings = true;
+
+      # catch-all for unknown hosts.
+      virtualHosts."_" = {
+        default = true;
+        rejectSSL = true;
+
+        extraConfig = ''
+          return 444;
+        '';
+      };
+    };
+
+    users.extraUsers.nginx.extraGroups = [ "acme" ];
+  };
+}
diff --git a/modules/services/nixos-mailserver.nix b/modules/services/nixos-mailserver.nix
new file mode 100644
index 0000000..be14c7f
--- /dev/null
+++ b/modules/services/nixos-mailserver.nix
@@ -0,0 +1,106 @@
+{ inputs, config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.nixos-mailserver;
+in
+{
+  imports = [ inputs.nixos-mailserver.nixosModules.mailserver ];
+
+  options.modules.services.nixos-mailserver = {
+    enable = mkEnableOption "nixos-mailserver";
+  };
+
+  config = mkIf cfg.enable {
+    sops.secrets.sefidel-imap-pass = {
+      mode = "0440";
+      owner = "dovecot2";
+      group = "dovecot2";
+    };
+    sops.secrets.internal-imap-pass = {
+      mode = "0440";
+      owner = "dovecot2";
+      group = "dovecot2";
+    };
+
+    systemd.services.dovecot2 = {
+      serviceConfig.SupplementaryGroups = [ "acme" ];
+    };
+
+    services.postfix = {
+      dnsBlacklists = [
+        # TODO: add sources
+        "bl.spamcop.net"
+      ];
+      dnsBlacklistOverrides = ''
+        exotic.sh OK
+        sefidel.net OK
+        sefidel.com OK
+        192.168.0.0/16 OK
+      '';
+    };
+
+    mailserver = {
+      enable = true;
+      fqdn = "mail.exotic.sh";
+      domains = [ "exotic.sh" "nand.moe" "sefidel.com" "sefidel.net" ];
+      mailboxes = {
+        Trash = {
+          auto = "no";
+          specialUse = "Trash";
+        };
+        Junk = {
+          auto = "subscribe";
+          specialUse = "Junk";
+        };
+        Drafts = {
+          auto = "subscribe";
+          specialUse = "Drafts";
+        };
+        Sent = {
+          auto = "subscribe";
+          specialUse = "Sent";
+        };
+      };
+
+      loginAccounts = {
+        "contact@sefidel.com" = {
+          aliases = [ "sefidel" "admin" "admin@sefidel.com" "postmaster" "postmaster@sefidel.com" ];
+          hashedPasswordFile = config.sops.secrets.sefidel-imap-pass.path;
+        };
+        "contact@sefidel.net" = {
+          aliases = [ "sefidel" "dev@sefidel.net" "social@sefidel.net" "media@sefidel.net" "admin" "admin@sefidel.net" "postmaster" "postmaster@sefidel.net" ];
+          hashedPasswordFile = config.sops.secrets.sefidel-imap-pass.path;
+        };
+        "sef@exotic.sh" = {
+          aliases = [ "sef" "sefidel" "sefidel@exotic.sh" "admin" "admin@exotic.sh" "postmaster" "postmaster@exotic.sh" "admin@nand.moe" "postmaster@nand.moe" ];
+          hashedPasswordFile = config.sops.secrets.sefidel-imap-pass.path;
+        };
+        "system@exotic.sh" = {
+          aliases = [ "system@nand.moe" ];
+          hashedPasswordFile = config.sops.secrets.internal-imap-pass.path;
+        };
+      };
+      localDnsResolver = false;
+      certificateScheme = 1;
+      certificateFile = "${config.security.acme.certs."exotic.sh".directory}/cert.pem";
+      keyFile = "${config.security.acme.certs."exotic.sh".directory}/key.pem";
+      enableImap = true;
+      enableImapSsl = true;
+      enableSubmission = true;
+      enableSubmissionSsl = true;
+      virusScanning = false;
+    };
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/dovecot"
+      "/var/lib/rspamd"
+      "/var/lib/redis-rspamd"
+      "/var/vmail"
+      "/var/dkim"
+      "/var/sieve"
+    ];
+
+    networking.firewall.allowedTCPPorts = [ 143 993 465 587 ];
+  };
+}
diff --git a/modules/services/postgresql.nix b/modules/services/postgresql.nix
new file mode 100644
index 0000000..2d5fdf5
--- /dev/null
+++ b/modules/services/postgresql.nix
@@ -0,0 +1,34 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.postgresql;
+in
+{
+  options.modules.services.postgresql = {
+    enable = mkEnableOption "postgresql with laxed limits";
+  };
+
+  config = mkIf cfg.enable {
+    services.postgresql = {
+      enable = true;
+      settings = {
+        max_connections = "300";
+        shared_buffers = "80MB";
+      };
+      authentication = lib.mkForce ''
+        # Generated file; do not edit!
+        # TYPE  DATABASE        USER            ADDRESS                 METHOD
+        local   all             all                                     trust
+        host    all             all             127.0.0.1/32            trust
+        host    all             all             ::1/128                 trust
+      '';
+    };
+    services.postgresqlBackup.enable = true;
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/postgresql"
+      "/var/backup/postgresql"
+    ];
+  };
+}
diff --git a/modules/services/pubnix.nix b/modules/services/pubnix.nix
new file mode 100644
index 0000000..dfe3d58
--- /dev/null
+++ b/modules/services/pubnix.nix
@@ -0,0 +1,20 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.pubnix;
+in
+{
+  options.modules.services.pubnix = {
+    enable = mkEnableOption "serve pubnix shell";
+  };
+
+  config = mkIf cfg.enable {
+    nix.gc.automatic = true;
+    nix.gc.dates = "daily";
+
+    environment.systemPackages = with pkgs; [
+      bsd-finger
+    ];
+  };
+}
diff --git a/modules/services/sefidel-web.nix b/modules/services/sefidel-web.nix
new file mode 100644
index 0000000..fdbcb00
--- /dev/null
+++ b/modules/services/sefidel-web.nix
@@ -0,0 +1,26 @@
+{ inputs, config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.sefidel-web;
+in
+{
+  options.modules.services.sefidel-web = {
+    enable = mkEnableOption "sefidel-web";
+  };
+
+  config = mkIf cfg.enable {
+    services.nginx.virtualHosts."sefidel.net" = {
+      useACMEHost = "sefidel.net";
+      forceSSL = true;
+      # TODO: causes css to be fetched every single time.
+      # This is because heuristic caching is disabled, since Nix removes the last-modified timestamp.
+      root = inputs.sefidel-web.defaultPackage.${config.nixpkgs.system};
+
+      # Fixes the problem above.
+      locations."~ \\.(js|css|woff|woff2?|png|jpe?g|svg)$".extraConfig = ''
+        add_header Cache-Control "public, max-age=14400, must-revalidate";
+      '';
+    };
+  };
+}
diff --git a/modules/services/soju.nix b/modules/services/soju.nix
new file mode 100644
index 0000000..4302538
--- /dev/null
+++ b/modules/services/soju.nix
@@ -0,0 +1,48 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.soju;
+in
+{
+  disabledModules = [
+    "services/networking/soju.nix"
+  ];
+
+  imports = [
+    ../../overlays/soju-module.nix
+  ];
+
+  options.modules.services.soju = {
+    enable = mkEnableOption "soju bouncer";
+
+    hostName = mkOption { type = types.str; default = config.networking.hostName; };
+    port = mkOption { type = types.port; default = 6697; };
+    tls = {
+      enable = mkEnableOption "enable TLS encryption";
+      acmeHost = mkOption { type = types.str; };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.soju = {
+      enable = true;
+      extraGroups = [ "acme" ];
+      hostName = cfg.hostName;
+      listen = [ ":${toString cfg.port}" ];
+    } // optionalAttrs cfg.tls.enable {
+      tlsCertificate = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/cert.pem";
+      tlsCertificateKey = "${config.security.acme.certs.${cfg.tls.acmeHost}.directory}/key.pem";
+    };
+
+    systemd.services.soju = {
+      after = [ "acme-finished-${cfg.tls.acmeHost}.target" ];
+    };
+
+    networking.firewall.allowedTCPPorts = [ cfg.port ];
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/private/soju"
+    ];
+  };
+}
diff --git a/modules/services/userweb.nix b/modules/services/userweb.nix
new file mode 100644
index 0000000..1477f59
--- /dev/null
+++ b/modules/services/userweb.nix
@@ -0,0 +1,36 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.userweb;
+in
+{
+  options.modules.services.userweb = {
+    enable = mkEnableOption "serve user web contents";
+    domain = mkOption { type = types.str; };
+  };
+
+  config = mkIf cfg.enable {
+    modules.services.nginx.enable = true;
+
+    services.nginx.virtualHosts.${cfg.domain} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+
+      serverName = "${cfg.domain} www.${cfg.domain}";
+
+      locations."~ ^/(~u/)(?<user>[\w-]+)(?<user_uri>/.*)?$" = {
+        alias = "/home/$user/public_html$user_uri";
+        index = "index.html index.php index.cgi index.py index.sh index.pl index.lua";
+
+        extraConfig = ''
+          error_page 404 /~$user/404.html;
+        '';
+      };
+
+      extraConfig = ''
+        error_log /var/log/nginx/${cfg.domain}-error.log crit;
+      '';
+    };
+  };
+}
diff --git a/modules/services/vikunja.nix b/modules/services/vikunja.nix
new file mode 100644
index 0000000..eb5adbc
--- /dev/null
+++ b/modules/services/vikunja.nix
@@ -0,0 +1,50 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.vikunja;
+in
+{
+  options.modules.services.vikunja = {
+    enable = mkEnableOption "vikunja";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; };
+  };
+
+  config = mkIf cfg.enable {
+    services.vikunja = {
+      enable = true;
+      frontendHostname = cfg.realHost;
+      frontendScheme = "https";
+
+      settings = {
+        service.enableregistration = false;
+      };
+
+      database = {
+        type = "postgres";
+        user = "vikunja";
+        database = "vikunja";
+        host = "/run/postgresql";
+      };
+    };
+
+    services.postgresql.enable = true;
+    services.postgresql.ensureDatabases = [ "vikunja" ];
+    services.postgresql.ensureUsers = [
+      {
+        name = "vikunja";
+        ensurePermissions."DATABASE vikunja" = "ALL PRIVILEGES";
+      }
+    ];
+
+    environment.persistence."/persist".directories = [
+      "/var/lib/private/vikunja"
+    ];
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+    };
+  };
+}
diff --git a/modules/sops.nix b/modules/sops.nix
new file mode 100644
index 0000000..bd5156e
--- /dev/null
+++ b/modules/sops.nix
@@ -0,0 +1,21 @@
+{ config, lib, inputs, ... }:
+
+with lib;
+let
+  cfg = config.modules.sops;
+
+  secretsFile = ../systems/${config.networking.hostName}/secrets/secrets.yaml;
+in
+{
+  imports = [
+    inputs.sops-nix.nixosModules.sops
+  ];
+
+  options.modules.sops = {
+    enable = mkEnableOption "sops secret manager";
+  };
+
+  config = mkIf cfg.enable {
+    sops.defaultSopsFile = secretsFile;
+  };
+}
diff --git a/overlays/README.md b/overlays/README.md
new file mode 100644
index 0000000..4d257f8
--- /dev/null
+++ b/overlays/README.md
@@ -0,0 +1,4 @@
+infra->overlays
+===============
+
+Nixpkgs overlays or module replacements.
diff --git a/overlays/default.nix b/overlays/default.nix
new file mode 100644
index 0000000..353d5bd
--- /dev/null
+++ b/overlays/default.nix
@@ -0,0 +1,4 @@
+self: super: {
+  mjolnir = super.callPackage ./mjolnir-package { };
+  sliding-sync = super.callPackage ./sliding-sync.nix { };
+}
diff --git a/overlays/git-daemon-module.nix b/overlays/git-daemon-module.nix
new file mode 100644
index 0000000..76b395e
--- /dev/null
+++ b/overlays/git-daemon-module.nix
@@ -0,0 +1,137 @@
+{ config, lib, pkgs, ... }:
+with lib;
+let
+
+  cfg = config.services.gitDaemon;
+
+in
+{
+
+  ###### interface
+
+  options = {
+    services.gitDaemon = {
+
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Enable Git daemon, which allows public hosting of git repositories
+          without any access controls. This is mostly intended for read-only access.
+
+          You can allow write access by setting daemon.receivepack configuration
+          item of the repository to true. This is solely meant for a closed LAN setting
+          where everybody is friendly.
+
+          If you need any access controls, use something else.
+        '';
+      };
+
+      basePath = mkOption {
+        type = types.str;
+        default = "";
+        example = "/srv/git/";
+        description = lib.mdDoc ''
+          Remap all the path requests as relative to the given path. For example,
+          if you set base-path to /srv/git, then if you later try to pull
+          git://example.com/hello.git, Git daemon will interpret the path as /srv/git/hello.git.
+        '';
+      };
+
+      exportAll = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc ''
+          Publish all directories that look like Git repositories (have the objects
+          and refs subdirectories), even if they do not have the git-daemon-export-ok file.
+
+          If disabled, you need to touch .git/git-daemon-export-ok in each repository
+          you want the daemon to publish.
+
+          Warning: enabling this without a repository whitelist or basePath
+          publishes every git repository you have.
+        '';
+      };
+
+      repositories = mkOption {
+        type = types.listOf types.str;
+        default = [ ];
+        example = [ "/srv/git" "/home/user/git/repo2" ];
+        description = lib.mdDoc ''
+          A whitelist of paths of git repositories, or directories containing repositories
+          all of which would be published. Paths must not end in "/".
+
+          Warning: leaving this empty and enabling exportAll publishes all
+          repositories in your filesystem or basePath if specified.
+        '';
+      };
+
+      listenAddress = mkOption {
+        type = types.str;
+        default = "";
+        example = "example.com";
+        description = lib.mdDoc "Listen on a specific IP address or hostname.";
+      };
+
+      port = mkOption {
+        type = types.port;
+        default = 9418;
+        description = lib.mdDoc "Port to listen on.";
+      };
+
+      options = mkOption {
+        type = types.str;
+        default = "";
+        description = lib.mdDoc "Extra configuration options to be passed to Git daemon.";
+      };
+
+      user = mkOption {
+        type = types.str;
+        default = "git";
+        description = lib.mdDoc "User under which Git daemon would be running.";
+      };
+
+      group = mkOption {
+        type = types.str;
+        default = "git";
+        description = lib.mdDoc "Group under which Git daemon would be running.";
+      };
+
+      createUserAndGroup = mkOption {
+        type = types.bool;
+        default = true;
+        description = lib.mdDoc ''
+          Create the specified group and user.
+          Disable this option if you want to use the existing user
+        '';
+      };
+    };
+  };
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+
+    users.users.${cfg.user} = optionalAttrs (cfg.createUserAndGroup == true) {
+      uid = config.ids.uids.git;
+      group = cfg.group;
+      description = "Git daemon user";
+    };
+
+    users.groups.${cfg.group} = optionalAttrs (cfg.createUserAndGroup == true) {
+      gid = config.ids.gids.git;
+    };
+
+    systemd.services.git-daemon = {
+      after = [ "network.target" ];
+      wantedBy = [ "multi-user.target" ];
+      script = "${pkgs.git}/bin/git daemon --reuseaddr "
+        + (optionalString (cfg.basePath != "") "--base-path=${cfg.basePath} ")
+        + (optionalString (cfg.listenAddress != "") "--listen=${cfg.listenAddress} ")
+        + "--port=${toString cfg.port} --user=${cfg.user} --group=${cfg.group} ${cfg.options} "
+        + "--verbose " + (optionalString cfg.exportAll "--export-all ") + concatStringsSep " " cfg.repositories;
+    };
+
+  };
+
+}
diff --git a/overlays/mautrix-signal-module.nix b/overlays/mautrix-signal-module.nix
new file mode 100644
index 0000000..22abbb5
--- /dev/null
+++ b/overlays/mautrix-signal-module.nix
@@ -0,0 +1,196 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  dataDir = "/var/lib/mautrix-signal";
+  registrationFile = "${dataDir}/signal-registration.yaml";
+  cfg = config.services.mautrix-signal;
+  settingsFormat = pkgs.formats.json { };
+  settingsFile =
+    settingsFormat.generate "mautrix-signal-config.json" cfg.settings;
+
+in
+{
+  options = {
+    services.mautrix-signal = {
+      enable = mkEnableOption (lib.mdDoc "Mautrix-Signal, a Matrix-Signal puppeting bridge.");
+
+      settings = mkOption rec {
+        apply = recursiveUpdate default;
+        inherit (settingsFormat) type;
+        default = {
+          homeserver = {
+            software = "standard";
+          };
+
+          appservice = rec {
+            database = "sqlite:///${dataDir}/mautrix-signal.db";
+            database_opts = { };
+            hostname = "0.0.0.0";
+            port = 8080;
+            address = "http://localhost:${toString port}";
+          };
+
+          signal.socket_path = config.services.signald.socketPath;
+
+          bridge = {
+            permissions."*" = "relay";
+            relay.whitelist = [ ];
+            double_puppet_server_map = { };
+            login_shared_secret_map = { };
+          };
+
+          logging = {
+            version = 1;
+
+            formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s";
+
+            handlers.console = {
+              class = "logging.StreamHandler";
+              formatter = "precise";
+            };
+
+            loggers = {
+              mau.level = "INFO";
+
+              # prevent tokens from leaking in the logs:
+              # https://github.com/tulir/mautrix-telegram/issues/351
+              aiohttp.level = "WARNING";
+            };
+
+            # log to console/systemd instead of file
+            root = {
+              level = "INFO";
+              handlers = [ "console" ];
+            };
+          };
+        };
+        example = literalExpression ''
+          {
+            homeserver = {
+              address = "http://localhost:8008";
+              domain = "public-domain.tld";
+            };
+
+            appservice.public = {
+              prefix = "/public";
+              external = "https://public-appservice-address/public";
+            };
+
+            bridge.permissions = {
+              "example.com" = "full";
+              "@admin:example.com" = "admin";
+            };
+          }
+        '';
+        description = lib.mdDoc ''
+          {file}`config.yaml` configuration as a Nix attribute set.
+          Configuration options should match those described in
+          [example-config.yaml](https://github.com/mautrix/signal/blob/master/signal/example-config.yaml).
+
+          Secret tokens should be specified using {option}`environmentFile`
+          instead of this world-readable attribute set.
+        '';
+      };
+
+      environmentFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        description = lib.mdDoc ''
+          File containing environment variables to be passed to the mautrix-signal service,
+          in which secret tokens can be specified securely by defining values for e.g.
+          `MAUTRIX_SIGNAL_APPSERVICE_AS_TOKEN`,
+          `MAUTRIX_SIGNAL_APPSERVICE_HS_TOKEN`,
+
+          These environment variables can also be used to set other options by
+          replacing hierarchy levels by `.`, converting the name to uppercase
+          and prepending `MAUTRIX_SIGNAL_`.
+          For example, the first value above maps to
+          {option}`settings.appservice.as_token`.
+
+          The environment variable values can be prefixed with `json::` to have
+          them be parsed as JSON. For example, `login_shared_secret_map` can be
+          set as follows:
+          `MAUTRIX_SIGNAL_BRIDGE_LOGIN_SHARED_SECRET_MAP=json::{"example.com":"secret"}`.
+        '';
+      };
+
+      serviceDependencies = mkOption {
+        type = with types; listOf str;
+        default = optional config.services.matrix-synapse.enable "matrix-synapse.service";
+        defaultText = literalExpression ''
+          optional config.services.matrix-synapse.enable "matrix-synapse.service"
+        '';
+        description = lib.mdDoc ''
+          List of Systemd services to require and wait for when starting the application service.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.signald.enable = true;
+
+    systemd.services.mautrix-signal = {
+      description = "Mautrix-Signal, a Matrix-Signal puppeting bridge.";
+
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      after = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      path = [ pkgs.lottieconverter pkgs.ffmpeg-full ];
+
+      # mautrix-signal tries to generate a dotfile in the home directory of
+      # the running user if using a postgresql database:
+      #
+      #  File "python3.10/site-packages/asyncpg/connect_utils.py", line 257, in _dot_postgre>
+      #    return (pathlib.Path.home() / '.postgresql' / filename).resolve()
+      #  File "python3.10/pathlib.py", line 1000, in home
+      #    return cls("~").expanduser()
+      #  File "python3.10/pathlib.py", line 1440, in expanduser
+      #    raise RuntimeError("Could not determine home directory.")
+      # RuntimeError: Could not determine home directory.
+      environment.HOME = dataDir;
+
+      preStart = ''
+        # generate the appservice's registration file if absent
+        if [ ! -f '${registrationFile}' ]; then
+          ${pkgs.mautrix-signal}/bin/mautrix-signal \
+            --generate-registration \
+            --base-config='${pkgs.mautrix-signal}/${pkgs.mautrix-signal.pythonModule.sitePackages}/mautrix_signal/example-config.yaml' \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        fi
+      '' + lib.optionalString (pkgs.mautrix-signal ? alembic) ''
+        # run automatic database init and migration scripts
+        ${pkgs.mautrix-signal.alembic}/bin/alembic -x config='${settingsFile}' upgrade head
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+
+        DynamicUser = true;
+        SupplementaryGroups = [ "signald" ];
+        PrivateTmp = true;
+        WorkingDirectory = pkgs.mautrix-signal; # necessary for the database migration scripts to be found
+        StateDirectory = baseNameOf dataDir;
+        UMask = "0027";
+        EnvironmentFile = cfg.environmentFile;
+
+        ExecStart = ''
+          ${pkgs.mautrix-signal}/bin/mautrix-signal \
+            --config='${settingsFile}'
+        '';
+      };
+    };
+  };
+
+  # meta.maintainers = with maintainers; [ boppyt ];
+}
diff --git a/overlays/mautrix-whatsapp-module.nix b/overlays/mautrix-whatsapp-module.nix
new file mode 100644
index 0000000..4cebcb6
--- /dev/null
+++ b/overlays/mautrix-whatsapp-module.nix
@@ -0,0 +1,192 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  dataDir = "/var/lib/mautrix-whatsapp";
+  registrationFile = "${dataDir}/whatsapp-registration.yaml";
+  cfg = config.services.mautrix-whatsapp;
+  settingsFormat = pkgs.formats.json { };
+  settingsFile =
+    settingsFormat.generate "mautrix-whatsapp-config.json" cfg.settings;
+in
+{
+  options = {
+    services.mautrix-whatsapp = {
+      enable = mkEnableOption (lib.mdDoc "Mautrix-Whatsapp, a Matrix-Whatsapp puppeting bridge.");
+
+      settings = mkOption rec {
+        apply = recursiveUpdate default;
+        inherit (settingsFormat) type;
+        default = {
+          homeserver = {
+            software = "standard";
+          };
+
+          appservice = rec {
+            database = {
+              type = "sqlite";
+              uri = "sqlite:///${dataDir}/mautrix-whatsapp.db";
+            };
+            hostname = "0.0.0.0";
+            port = 8080;
+            address = "http://localhost:${toString port}";
+            as_token = "$MAUTRIX_WHATSAPP_APPSERVICE_AS_TOKEN";
+            hs_token = "$MAUTRIX_WHATSAPP_APPSERVICE_HS_TOKEN";
+          };
+
+          bridge = {
+            permissions."*" = "relay";
+            relay.whitelist = [ ];
+            double_puppet_server_map = { };
+            login_shared_secret_map = { };
+          };
+
+          logging = {
+            version = 1;
+
+            formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s";
+
+            handlers.console = {
+              class = "logging.StreamHandler";
+              formatter = "precise";
+            };
+
+            # log to console/systemd instead of file
+            file_name_format = null;
+
+            loggers = {
+              mau.level = "INFO";
+              telethon.level = "INFO";
+
+              # prevent tokens from leaking in the logs:
+              # https://github.com/tulir/mautrix-telegram/issues/351
+              aiohttp.level = "WARNING";
+            };
+          };
+        };
+        example = literalExpression ''
+          {
+            homeserver = {
+              address = "http://localhost:8008";
+              domain = "public-domain.tld";
+            };
+
+            appservice.public = {
+              prefix = "/public";
+              external = "https://public-appservice-address/public";
+            };
+
+            bridge.permissions = {
+              "example.com" = "full";
+              "@admin:example.com" = "admin";
+            };
+          }
+        '';
+        description = lib.mdDoc ''
+          {file}`config.yaml` configuration as a Nix attribute set.
+          Configuration options should match those described in
+          [example-config.yaml](https://github.com/mautrix/whatsapp/blob/master/mautrix_whatsapp/example-config.yaml).
+
+          Secret tokens should be specified using {option}`environmentFile`
+          instead of this world-readable attribute set.
+        '';
+      };
+
+      environmentFile = mkOption {
+        type = types.nullOr types.path;
+        default = null;
+        description = lib.mdDoc ''
+          File containing environment variables to be passed to the mautrix-whatsapp service,
+          in which secret tokens can be specified securely by defining values for e.g.
+          `MAUTRIX_WHATSAPP_APPSERVICE_AS_TOKEN`,
+          `MAUTRIX_WHATSAPP_APPSERVICE_HS_TOKEN`,
+
+          For Mautrix-Whatsapp, only AS_TOKEN and HS_TOKEN is available.
+        '';
+      };
+
+      serviceDependencies = mkOption {
+        type = with types; listOf str;
+        default = optional config.services.matrix-synapse.enable "matrix-synapse.service";
+        defaultText = literalExpression ''
+          optional config.services.matrix-synapse.enable "matrix-synapse.service"
+        '';
+        description = lib.mdDoc ''
+          List of Systemd services to require and wait for when starting the application service.
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.services.mautrix-whatsapp = {
+      description = "Mautrix-Whatsapp, a Matrix-Whatsapp puppeting bridge.";
+
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      after = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      path = [ pkgs.lottieconverter pkgs.ffmpeg-full ];
+
+      # mautrix-whatsapp tries to generate a dotfile in the home directory of
+      # the running user if using a postgresql database:
+      #
+      #  File "python3.10/site-packages/asyncpg/connect_utils.py", line 257, in _dot_postgre>
+      #    return (pathlib.Path.home() / '.postgresql' / filename).resolve()
+      #  File "python3.10/pathlib.py", line 1000, in home
+      #    return cls("~").expanduser()
+      #  File "python3.10/pathlib.py", line 1440, in expanduser
+      #    raise RuntimeError("Could not determine home directory.")
+      # RuntimeError: Could not determine home directory.
+      environment.HOME = dataDir;
+
+      preStart = ''
+        # generate the appservice's registration file if absent
+        if [ ! -f '${registrationFile}' ]; then
+          ${pkgs.mautrix-whatsapp}/bin/mautrix-whatsapp \
+            --generate-registration \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        fi
+
+        ${pkgs.envsubst}/bin/envsubst \
+          -i ${settingsFile} \
+          -o /run/mautrix-whatsapp/config.json
+
+        # wait until dendrite grabs the config
+        sleep 5
+      '' + lib.optionalString (pkgs.mautrix-whatsapp ? alembic) ''
+        # run automatic database init and migration scripts
+        ${pkgs.mautrix-whatsapp.alembic}/bin/alembic -x config='${settingsFile}' upgrade head
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+
+        DynamicUser = true;
+        Group = "mautrix-whatsapp";
+        PrivateTmp = true;
+        WorkingDirectory = pkgs.mautrix-whatsapp; # necessary for the database migration scripts to be found
+        StateDirectory = baseNameOf dataDir;
+        RuntimeDirectory = "mautrix-whatsapp";
+        RuntimeDirectoryMode = "0700";
+        UMask = "0027";
+        EnvironmentFile = cfg.environmentFile;
+
+        ExecStart = ''
+          ${pkgs.mautrix-whatsapp}/bin/mautrix-whatsapp \
+            --config='/run/mautrix-whatsapp/config.json'
+        '';
+      };
+    };
+  };
+
+  # meta.maintainers = with maintainers; [ boppyt ];
+}
diff --git a/overlays/mjolnir-module/default.nix b/overlays/mjolnir-module/default.nix
new file mode 100644
index 0000000..87ed761
--- /dev/null
+++ b/overlays/mjolnir-module/default.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}/libexec/mjolnir/deps/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 (lib.mdDoc "Mjolnir, a moderation tool for Matrix");
+
+    homeserverUrl = mkOption {
+      type = types.str;
+      default = "https://matrix.org";
+      description = lib.mdDoc ''
+        Where the homeserver is located (client-server URL).
+
+        If `pantalaimon.enable` is `true`, this option will become the homeserver to which `pantalaimon` connects.
+        The listen address of `pantalaimon` will then become the `homeserverUrl` of `mjolnir`.
+      '';
+    };
+
+    accessTokenFile = mkOption {
+      type = with types; nullOr path;
+      default = null;
+      description = lib.mdDoc ''
+        File containing the matrix access token for the `mjolnir` user.
+      '';
+    };
+
+    pantalaimon = mkOption {
+      description = lib.mdDoc ''
+        `pantalaimon` options (enables E2E Encryption support).
+
+        This will create a `pantalaimon` instance with the name "mjolnir".
+      '';
+      default = { };
+      type = types.submodule {
+        options = {
+          enable = mkEnableOption (lib.mdDoc ''
+            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 = lib.mdDoc "The username to login with.";
+          };
+
+          passwordFile = mkOption {
+            type = with types; nullOr path;
+            default = null;
+            description = lib.mdDoc ''
+              File containing the matrix password for the `mjolnir` user.
+            '';
+          };
+
+          options = mkOption {
+            type = types.submodule (import ./pantalaimon-options.nix);
+            default = { };
+            description = lib.mdDoc ''
+              passthrough additional options to the `pantalaimon` service.
+            '';
+          };
+        };
+      };
+    };
+
+    dataPath = mkOption {
+      type = types.path;
+      default = "/var/lib/mjolnir";
+      description = lib.mdDoc ''
+        The directory the bot should store various bits of information in.
+      '';
+    };
+
+    managementRoom = mkOption {
+      type = types.str;
+      default = "#moderators:example.org";
+      description = lib.mdDoc ''
+        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: `mjolnir` 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 = lib.mdDoc ''
+        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 = lib.mdDoc ''
+        Additional settings (see [mjolnir default config](https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml) 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.md;
+    maintainers = with maintainers; [ jojosch ];
+  };
+}
diff --git a/overlays/mjolnir-module/mjolnir.md b/overlays/mjolnir-module/mjolnir.md
new file mode 100644
index 0000000..f6994ee
--- /dev/null
+++ b/overlays/mjolnir-module/mjolnir.md
@@ -0,0 +1,110 @@
+# Mjolnir (Matrix Moderation Tool) {#module-services-mjolnir}
+
+This chapter will show you how to set up your own, self-hosted
+[Mjolnir](https://github.com/matrix-org/mjolnir) instance.
+
+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.
+
+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.
+
+See the [README](https://github.com/matrix-org/mjolnir#readme)
+page and the [Moderator's guide](https://github.com/matrix-org/mjolnir/blob/main/docs/moderators.md)
+for additional instructions on how to setup and use Mjolnir.
+
+For [additional settings](#opt-services.mjolnir.settings)
+see [the default configuration](https://github.com/matrix-org/mjolnir/blob/main/config/default.yaml).
+
+## Mjolnir Setup {#module-services-mjolnir-setup}
+
+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 [services.mjolnir.managementRoom](#opt-services.mjolnir.managementRoom).
+
+Next, create a new user for Mjolnir on your homeserver, if not present already.
+
+The Mjolnir Matrix user expects to be free of any rate limiting.
+See [Synapse #6286](https://github.com/matrix-org/synapse/issues/6286)
+for an example on how to achieve this.
+
+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.
+
+Now invite the Mjolnir user to the management room.
+
+It is recommended to use [Pantalaimon](https://github.com/matrix-org/pantalaimon),
+so your management room can be encrypted. This also applies if you are looking to moderate an encrypted room.
+
+To enable the Pantalaimon E2E Proxy for mjolnir, enable
+[services.mjolnir.pantalaimon](#opt-services.mjolnir.pantalaimon.enable). This will
+autoconfigure a new Pantalaimon instance, which will connect to the homeserver
+set in [services.mjolnir.homeserverUrl](#opt-services.mjolnir.homeserverUrl) and Mjolnir itself
+will be configured to connect to the new Pantalaimon instance.
+
+```
+{
+  services.mjolnir = {
+    enable = true;
+    homeserverUrl = "https://matrix.domain.tld";
+    pantalaimon = {
+       enable = true;
+       username = "mjolnir";
+       passwordFile = "/run/secrets/mjolnir-password";
+    };
+    protectedRooms = [
+      "https://matrix.to/#/!xxx:domain.tld"
+    ];
+    managementRoom = "!yyy:domain.tld";
+  };
+}
+```
+
+### Element Matrix Services (EMS) {#module-services-mjolnir-setup-ems}
+
+If you are using a managed ["Element Matrix Services (EMS)"](https://ems.element.io/)
+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.
+
+## Synapse Antispam Module {#module-services-mjolnir-matrix-synapse-antispam}
+
+A Synapse module is also available to apply the same rulesets the bot
+uses across an entire homeserver.
+
+To use the Antispam Module, add `matrix-synapse-plugins.matrix-synapse-mjolnir-antispam`
+to the Synapse plugin list and enable the `mjolnir.Module` module.
+
+```
+{
+  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"
+    '';
+  };
+}
+```
diff --git a/overlays/mjolnir-module/pantalaimon-options.nix b/overlays/mjolnir-module/pantalaimon-options.nix
new file mode 100644
index 0000000..3945a70
--- /dev/null
+++ b/overlays/mjolnir-module/pantalaimon-options.nix
@@ -0,0 +1,70 @@
+{ config, lib, name, ... }:
+
+with lib;
+{
+  options = {
+    dataPath = mkOption {
+      type = types.path;
+      default = "/var/lib/pantalaimon-${name}";
+      description = lib.mdDoc ''
+        The directory where `pantalaimon` should store its state such as the database file.
+      '';
+    };
+
+    logLevel = mkOption {
+      type = types.enum [ "info" "warning" "error" "debug" ];
+      default = "warning";
+      description = lib.mdDoc ''
+        Set the log level of the daemon.
+      '';
+    };
+
+    homeserver = mkOption {
+      type = types.str;
+      example = "https://matrix.org";
+      description = lib.mdDoc ''
+        The URI of the homeserver that the `pantalaimon` proxy should
+        forward requests to, without the matrix API path but including
+        the http(s) schema.
+      '';
+    };
+
+    ssl = mkOption {
+      type = types.bool;
+      default = true;
+      description = lib.mdDoc ''
+        Whether or not SSL verification should be enabled for outgoing
+        connections to the homeserver.
+      '';
+    };
+
+    listenAddress = mkOption {
+      type = types.str;
+      default = "localhost";
+      description = lib.mdDoc ''
+        The address where the daemon will listen to client connections
+        for this homeserver.
+      '';
+    };
+
+    listenPort = mkOption {
+      type = types.port;
+      default = 8009;
+      description = lib.mdDoc ''
+        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 = lib.mdDoc ''
+        Extra configuration options. See
+        [pantalaimon(5)](https://github.com/matrix-org/pantalaimon/blob/master/docs/man/pantalaimon.5.md)
+        for available options.
+      '';
+    };
+  };
+}
diff --git a/overlays/mjolnir-package/default.nix b/overlays/mjolnir-package/default.nix
new file mode 100644
index 0000000..833124b
--- /dev/null
+++ b/overlays/mjolnir-package/default.nix
@@ -0,0 +1,80 @@
+{ lib
+, nixosTests
+, mkYarnPackage
+, fetchYarnDeps
+, fetchFromGitHub
+, makeWrapper
+, nodejs
+, pkgs
+, matrix-sdk-crypto-nodejs
+}:
+
+let
+  pin = lib.importJSON ./pin.json;
+in
+mkYarnPackage rec {
+  pname = "mjolnir";
+  inherit (pin) version;
+
+  src = fetchFromGitHub {
+    owner = "matrix-org";
+    repo = "mjolnir";
+    rev = "v${version}";
+    sha256 = pin.srcSha256;
+  };
+
+  packageJSON = ./package.json;
+  offlineCache = fetchYarnDeps {
+    yarnLock = "${src}/yarn.lock";
+    sha256 = pin.yarnSha256;
+  };
+
+  packageResolutions = {
+    "@matrix-org/matrix-sdk-crypto-nodejs" = "${matrix-sdk-crypto-nodejs}/lib/node_modules/@matrix-org/matrix-sdk-crypto-nodejs";
+  };
+
+  nativeBuildInputs = [
+    makeWrapper
+  ];
+
+  buildPhase = ''
+    runHook preBuild
+    yarn --offline build
+    runHook postBuild
+  '';
+
+  postInstall = ''
+    makeWrapper ${nodejs}/bin/node $out/bin/mjolnir \
+      --add-flags $out/libexec/mjolnir/deps/mjolnir/lib/index.js
+  '';
+
+  doDist = false;
+
+  passthru = {
+    tests = {
+      inherit (nixosTests) mjolnir;
+    };
+    updateScript = ./update.sh;
+  };
+
+  meta = with lib; {
+    description = "A moderation tool for Matrix";
+    homepage = "https://github.com/matrix-org/mjolnir";
+    longDescription = ''
+      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.
+
+      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.
+
+      A Synapse module is also available to apply the same rulesets the bot
+      uses across an entire homeserver.
+    '';
+    license = licenses.asl20;
+    maintainers = with maintainers; [ jojosch ];
+  };
+}
diff --git a/overlays/mjolnir-package/package.json b/overlays/mjolnir-package/package.json
new file mode 100644
index 0000000..f7ed5ab
--- /dev/null
+++ b/overlays/mjolnir-package/package.json
@@ -0,0 +1,69 @@
+{
+  "name": "mjolnir",
+  "version": "1.6.3",
+  "description": "A moderation tool for Matrix",
+  "main": "lib/index.js",
+  "repository": "git@github.com:matrix-org/mjolnir.git",
+  "author": "The Matrix.org Foundation C.I.C.",
+  "license": "Apache-2.0",
+  "private": true,
+  "scripts": {
+    "build": "tsc",
+    "postbuild": "rm -rf lib/test/ && cp -r lib/src/* lib/ && rm -rf lib/src/",
+    "lint": "tslint --project ./tsconfig.json -t stylish",
+    "start:dev": "yarn build && node --async-stack-traces lib/index.js",
+    "test": "ts-mocha --project ./tsconfig.json test/commands/**/*.ts",
+    "test:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json \"test/integration/**/*Test.ts\"",
+    "test:integration:single": "NODE_ENV=harness npx ts-mocha --require test/integration/fixtures.ts --timeout 300000 --project ./tsconfig.json",
+    "test:appservice:integration": "NODE_ENV=harness ts-mocha --async-stack-traces --timeout 300000 --project ./tsconfig.json \"test/appservice/integration/**/*Test.ts\"",
+    "test:appservice:integration:single": "NODE_ENV=harness npx ts-mocha --timeout 300000 --project ./tsconfig.json",
+    "test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts",
+    "version": "sed -i '/# version automated/s/[0-9][0-9]*\\.[0-9][0-9]*\\.[0-9][0-9]*/'$npm_package_version'/' synapse_antispam/setup.py && git add synapse_antispam/setup.py && cat synapse_antispam/setup.py"
+  },
+  "devDependencies": {
+    "@types/config": "^3.3.0",
+    "@types/crypto-js": "^4.0.2",
+    "@types/express": "^4.17.13",
+    "@types/html-to-text": "^8.0.1",
+    "@types/humanize-duration": "^3.27.1",
+    "@types/js-yaml": "^4.0.5",
+    "@types/jsdom": "^16.2.11",
+    "@types/mocha": "^9.0.0",
+    "@types/nedb": "^1.8.12",
+    "@types/node": "^16.7.10",
+    "@types/pg": "^8.6.5",
+    "@types/request": "^2.48.8",
+    "@types/shell-quote": "1.7.1",
+    "crypto-js": "^4.1.1",
+    "eslint": "^7.32",
+    "expect": "^27.0.6",
+    "mocha": "^9.0.1",
+    "ts-mocha": "^9.0.2",
+    "tslint": "^6.1.3",
+    "typescript": "^4.8.4",
+    "typescript-formatter": "^7.2"
+  },
+  "dependencies": {
+    "@sentry/node": "^7.17.2",
+    "@sentry/tracing": "^7.17.2",
+    "await-lock": "^2.2.2",
+    "body-parser": "^1.20.1",
+    "config": "^3.3.8",
+    "express": "^4.17",
+    "html-to-text": "^8.0.0",
+    "humanize-duration": "^3.27.1",
+    "humanize-duration-ts": "^2.1.1",
+    "js-yaml": "^4.1.0",
+    "jsdom": "^16.6.0",
+    "matrix-appservice-bridge": "8.0.0",
+    "parse-duration": "^1.0.2",
+    "pg": "^8.8.0",
+    "prom-client": "^14.1.0",
+    "shell-quote": "^1.7.3",
+    "ulidx": "^0.3.0",
+    "yaml": "^2.1.1"
+  },
+  "engines": {
+    "node": ">=16.0.0"
+  }
+}
diff --git a/overlays/mjolnir-package/pin.json b/overlays/mjolnir-package/pin.json
new file mode 100644
index 0000000..73953b8
--- /dev/null
+++ b/overlays/mjolnir-package/pin.json
@@ -0,0 +1,5 @@
+{
+  "version": "1.6.4",
+  "srcSha256": "sha256-/vnojWLpu/fktqPUhAdL1QTESxDwFrBVYAkyF79Fj9w=",
+  "yarnSha256": "sha256-B4s0CYr5Ihoh4gkckwZ3z0Nb4LMET48WvRXuhk3fpQM="
+}
diff --git a/overlays/mjolnir-package/update.sh b/overlays/mjolnir-package/update.sh
new file mode 100755
index 0000000..1ada429
--- /dev/null
+++ b/overlays/mjolnir-package/update.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p nix curl jq prefetch-yarn-deps nix-prefetch-github
+
+if [ "$#" -gt 1 ] || [[ "$1" == -* ]]; then
+  echo "Regenerates packaging data for mjolnir."
+  echo "Usage: $0 [git release tag]"
+  exit 1
+fi
+
+version=$1
+
+set -euo pipefail
+
+if [ -z "$version" ]; then
+  version=$(curl "https://api.github.com/repos/matrix-org/mjolnir/releases/latest" | jq -r '.tag_name')
+fi
+
+src="https://raw.githubusercontent.com/matrix-org/mjolnir/$version"
+src_hash=$(nix-prefetch-github matrix-org mjolnir --rev ${version} | jq -r .sha256)
+
+tmpdir=$(mktemp -d)
+trap 'rm -rf "$tmpdir"' EXIT
+
+pushd $tmpdir
+curl -O "$src/yarn.lock"
+yarn_hash=$(prefetch-yarn-deps yarn.lock)
+popd
+
+curl -O "$src/package.json"
+cat > pin.json << EOF
+{
+  "version": "$version",
+  "srcHash": "$src_hash",
+  "yarnHash": "$yarn_hash"
+}
+EOF
diff --git a/overlays/sliding-sync-module.nix b/overlays/sliding-sync-module.nix
new file mode 100644
index 0000000..692818b
--- /dev/null
+++ b/overlays/sliding-sync-module.nix
@@ -0,0 +1,87 @@
+{ config, lib, pkgs, ... }:
+let
+  cfg = config.services.sliding-sync;
+in
+{
+  # TODO: add default values
+  options.services.sliding-sync = {
+    enable = lib.mkEnableOption (lib.mdDoc "matrix.org sliding-sync");
+    server = lib.mkOption {
+      type = lib.types.str;
+      # default = "https://matrix-client.matrix.org" # TODO: required?
+      description = lib.mdDoc ''
+        The destination homeserver to talk to (CS API HTTPS URL)
+      '';
+    };
+    db = lib.mkOption {
+      type = lib.types.str;
+      description = lib.mdDoc ''
+        The postgres connection string: https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING
+      '';
+    };
+    bindAddr = lib.mkOption {
+      type = lib.types.str;
+      default = "0.0.0.0:8008";
+      description = lib.mdDoc ''
+        The interface and port to listen on.
+      '';
+    };
+    secret = lib.mkOption {
+      type = lib.types.str;
+      description = lib.mdDoc ''
+        A secret to use to encrypt access tokens.
+        Must remain the same for the lifetime of the database.
+      '';
+    };
+    pprof = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = lib.mdDoc ''
+        The bind addr for pprof debugging e.g ':6060'.
+        If not set, does not listen.
+      '';
+    };
+    prom = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = lib.mdDoc ''
+        The bind addr for Prometheus metrics,
+        which will be accessible at /metrics at this address.
+      '';
+    };
+    jaegerUrl = lib.mkOption {
+      type = lib.types.nullOr lib.types.str;
+      default = null;
+      description = lib.mdDoc ''
+        The Jaeger URL to send spans to e.g http://localhost:14268/api/traces
+        If unset does not send OTLP traces.
+      '';
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.sliding-sync = {
+      description = "MSC3575 Matrix Sliding Sync Proxy";
+      after = [
+        "network.target"
+      ];
+      wantedBy = [ "multi-user.target" ];
+      serviceConfig = {
+        Type = "simple";
+        DynamicUser = true;
+        ExecStart =
+          "${pkgs.sliding-sync}/bin/syncv3";
+        Restart = "on-failure";
+      };
+      environment = {
+        SYNCV3_SERVER = cfg.server;
+        SYNCV3_DB = cfg.db;
+        SYNCV3_SECRET = cfg.secret;
+        SYNCV3_BINDADDR = cfg.bindAddr;
+        SYNCV3_PPROF = cfg.pprof;
+        SYNCV3_PROM = cfg.prom;
+        SYNCV3_JAEGER_URL = cfg.jaegerUrl;
+      };
+    };
+  };
+}
diff --git a/overlays/sliding-sync.nix b/overlays/sliding-sync.nix
new file mode 100644
index 0000000..50c058d
--- /dev/null
+++ b/overlays/sliding-sync.nix
@@ -0,0 +1,22 @@
+{ lib, buildGoModule, fetchFromGitHub }:
+
+# TODO: needs:
+# - tests
+# - `meta` attribute
+
+buildGoModule rec {
+  pname = "sliding-sync";
+  version = "0.99.1";
+  src = fetchFromGitHub {
+    owner = "matrix-org";
+    repo = "sliding-sync";
+    rev = "v${version}";
+    sha256 = "sha256-g1yMGb8taToEFG6N057yPcdZB855r0f6EwnJ98FIiic=";
+  };
+
+  vendorHash = "sha256-FmibAVjKeJUrMSlhoE7onLoa4EVjQvjDI4oU4PB5LBE=";
+
+  subPackages = [
+    "cmd/syncv3"
+  ];
+}
diff --git a/overlays/soju-module.nix b/overlays/soju-module.nix
new file mode 100644
index 0000000..d14082c
--- /dev/null
+++ b/overlays/soju-module.nix
@@ -0,0 +1,132 @@
+# Not an overlay, module replacement
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+  cfg = config.services.soju;
+  stateDir = "/var/lib/soju";
+  listenCfg = concatMapStringsSep "\n" (l: "listen ${l}") cfg.listen;
+  tlsCfg = optionalString (cfg.tlsCertificate != null)
+    "tls ${cfg.tlsCertificate} ${cfg.tlsCertificateKey}";
+  logCfg = optionalString cfg.enableMessageLogging
+    "log fs ${stateDir}/logs";
+
+  configFile = pkgs.writeText "soju.conf" ''
+    ${listenCfg}
+    hostname ${cfg.hostName}
+    ${tlsCfg}
+    db sqlite3 ${stateDir}/soju.db
+    ${logCfg}
+    http-origin ${concatStringsSep " " cfg.httpOrigins}
+    accept-proxy-ip ${concatStringsSep " " cfg.acceptProxyIP}
+
+    ${cfg.extraConfig}
+  '';
+in
+{
+  ###### interface
+
+  options.services.soju = {
+    enable = mkEnableOption (lib.mdDoc "soju");
+
+    listen = mkOption {
+      type = types.listOf types.str;
+      default = [ ":6697" ];
+      description = lib.mdDoc ''
+        Where soju should listen for incoming connections. See the
+        `listen` directive in
+        {manpage}`soju(1)`.
+      '';
+    };
+
+    hostName = mkOption {
+      type = types.str;
+      default = config.networking.hostName;
+      defaultText = literalExpression "config.networking.hostName";
+      description = lib.mdDoc "Server hostname.";
+    };
+
+    tlsCertificate = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/var/host.cert";
+      description = lib.mdDoc "Path to server TLS certificate.";
+    };
+
+    tlsCertificateKey = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      example = "/var/host.key";
+      description = lib.mdDoc "Path to server TLS certificate key.";
+    };
+
+    enableMessageLogging = mkOption {
+      type = types.bool;
+      default = true;
+      description = lib.mdDoc "Whether to enable message logging.";
+    };
+
+    httpOrigins = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc ''
+        List of allowed HTTP origins for WebSocket listeners. The parameters are
+        interpreted as shell patterns, see
+        {manpage}`glob(7)`.
+      '';
+    };
+
+    acceptProxyIP = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc ''
+        Allow the specified IPs to act as a proxy. Proxys have the ability to
+        overwrite the remote and local connection addresses (via the X-Forwarded-\*
+        HTTP header fields). The special name "localhost" accepts the loopback
+        addresses 127.0.0.0/8 and ::1/128. By default, all IPs are rejected.
+      '';
+    };
+
+    extraConfig = mkOption {
+      type = types.lines;
+      default = "";
+      description = lib.mdDoc "Lines added verbatim to the configuration file.";
+    };
+
+    extraGroups = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      description = lib.mdDoc "Extra groups for the dynamic user.";
+    };
+  };
+
+  ###### implementation
+
+  config = mkIf cfg.enable {
+    assertions = [
+      {
+        assertion = (cfg.tlsCertificate != null) == (cfg.tlsCertificateKey != null);
+        message = ''
+          services.soju.tlsCertificate and services.soju.tlsCertificateKey
+          must both be specified to enable TLS.
+        '';
+      }
+    ];
+
+    systemd.services.soju = {
+      description = "soju IRC bouncer";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "network-online.target" ];
+      serviceConfig = {
+        DynamicUser = true;
+        SupplementaryGroups = cfg.extraGroups;
+        Restart = "always";
+        ExecStart = "${pkgs.soju}/bin/soju -config ${configFile}";
+        StateDirectory = "soju";
+      };
+    };
+  };
+
+  meta.maintainers = with maintainers; [ malvo ];
+}
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..1a7a229
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,5 @@
+infra->scripts
+==============
+
+Various scripts for managing users.
+Currently unused, might be used when we open registrations.
diff --git a/scripts/manage-user b/scripts/manage-user
new file mode 100755
index 0000000..43c7526
--- /dev/null
+++ b/scripts/manage-user
@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+
+domain='exotic.sh'
+group="$(echo $domain | awk -F. '{printf $1}')"
+
+cmd="$1"
+user="$2"
+email="$3"
+ssh_pubkey="$4"
+
+die() {
+    echo "$@" >&2; exit 1;
+}
+
+confirm() {
+    printf '%s\n' "$@"
+    select yn in "Yes" "No"; do
+      case $yn in
+        Yes) return 0; break;;
+        No) return 1; break;;
+      esac;
+    done
+}
+
+help() {
+    printf '%s user management\n\n' $domain
+    printf 'Usage: %s [args]\n' "$(basename "$0")"
+    printf '\tadd <user> <email> <ssh-pubkey> - add user\n'
+    printf '\tdel <user> - delete user\n'
+    printf '\tlist - list users\n'
+    printf '\thelp - display this help command\n'
+}
+
+add_user() {
+    echo ""
+}
+
+del_user() {
+    echo ""
+}
+
+list_users() {
+    echo 'TODO: list LDAP'
+}
+
+
+[ "$(id -u)" -ne 0 ] && die 'This script should be run as root!'
+
+case "$cmd" in
+  add) [ $# -lt 3 ] && die 'Insufficient arguments!'
+      if ! id -u "$user" >/dev/null 2>&1; then
+          printf 'Creating user:\n'
+          printf '\tUsername: %s\n' $user
+          printf '\tEmail: %s\n' $email
+          printf '\tSSH: %s\n\n' $ssh_pubkey
+          if confirm 'Are those details correct?'; then
+              add_user
+          else
+              echo 'Aborting.'
+          fi
+      else
+          die "User '$user' already exists!"
+      fi
+      ;;
+  del) [ $# -lt 2 ] && die 'Insufficient arguments!'
+      if id -u "$user" >/dev/null 2>&1; then
+          printf 'Deleting user: %s\n' $user
+          if confirm 'Are you sure?'; then
+              del_user
+          else
+              echo 'Aborting.'
+          fi
+      else
+          die "User '$user' doesn't exist!"
+      fi
+      ;;
+  list) list_users;;
+  help|*) help;;
+esac
diff --git a/systems/.sops.yaml b/systems/.sops.yaml
new file mode 100644
index 0000000..11331c6
--- /dev/null
+++ b/systems/.sops.yaml
@@ -0,0 +1,10 @@
+keys:
+  - &sefidel age1jt8xg0lvzj5q4f7fn7nw670qsszm3kv3caa654eh62azra4x44zss4fad8
+  - &sefidel_pgp 8BDFDFB56842239382A0441B9238BC709E05516A
+  - &host_cobalt age14a2amn7memzvctf2nnrt6uj458x3g4jpcvs04tlkww2z02p05syqawxrwh
+creation_rules:
+  - path_regex: cobalt/secrets/[^/]+\.yaml$
+    key_groups:
+    - age:
+      - *sefidel
+      - *host_cobalt
diff --git a/systems/cobalt/default.nix b/systems/cobalt/default.nix
new file mode 100644
index 0000000..0a5cfe0
--- /dev/null
+++ b/systems/cobalt/default.nix
@@ -0,0 +1,303 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+let
+  ipv4 = {
+    address = "95.216.74.104";
+    gateway = "95.216.74.65";
+    netmask = "255.255.255.192";
+    prefixLength = 26; # https://www.pawprint.net/designresources/netmask-converter.php
+  };
+  ipv6 = {
+    address = "2a01:4f9:2b:a98::";
+    gateway = "fe80::1";
+    prefixLength = 64;
+  };
+  networkInterface = "eth0";
+  hostName = "cobalt";
+  hostId = "712ae82a";
+  hostAddr = "cobalt.exotic.sh";
+
+  poorObfuscation = y: x: "${x}@${y}";
+in
+{
+  deployment = {
+    targetHost = hostAddr;
+    targetPort = 22;
+    targetUser = "root";
+  };
+
+  imports = [ ./hardware-configuration.nix ];
+
+  boot.supportedFilesystems = [ "zfs" ];
+  networking.hostId = hostId;
+
+  boot.loader.grub.enable = true;
+  # boot.loader.grub.version = 2;
+  boot.loader.grub.efiSupport = false;
+  # boot.loader.grub.device = "nodev";
+
+  # This should be done automatically, but explicitly declare it just in case.
+  boot.loader.grub.copyKernels = true;
+  # Make sure that you've listed all of the boot partitions here.
+  boot.loader.grub.mirroredBoots = [
+    { path = "/boot"; devices = [ "/dev/disk/by-id/ata-ST4000NM0245-1Z2107_ZC17GW7G" ]; }
+    { path = "/boot-fallback"; devices = [ "/dev/disk/by-id/ata-ST4000NM0245-1Z2107_ZC17GWB2" ]; }
+  ];
+
+  # Boot normally when one of the boot partitions are missing
+  fileSystems."/boot".options = [ "nofail" ];
+  fileSystems."/boot-fallback".options = [ "nofail" ];
+
+  # Erase your darlings
+  # boot.initrd.postDeviceCommands = lib.mkAfter ''nix systemd environ
+  # zfs rollback -r rpool/local/root@blank
+  # '';
+
+  # NOTE: replace these to boot.initrd.availableKernelModules?
+  boot.kernelModules = [ "e1000e" ];
+  boot.initrd.kernelModules = [ "e1000e" ];
+
+  boot.kernelParams = [
+    # See <https:#www.kernel.org/doc/Documentation/filesystems/nfs/nfsroot.txt> for documentation.
+    # ip=<client-ip>:<server-ip>:<gw-ip>:<netmask>:<hostname>:<device>:<autoconf>:<dns0-ip>:<dns1-ip>:<ntp0-ip>
+    # The server ip refers to the NFS server -- not needed in this case.
+    "ip=${ipv4.address}::${ipv4.gateway}:${ipv4.netmask}:${hostName}-initrd:${networkInterface}:off:8.8.8.8"
+  ];
+
+  boot.initrd.network.enable = true;
+  boot.initrd.network.ssh = {
+    enable = true;
+
+    # Using the same port as the actual SSH will cause clients to throw errors
+    # related to host key mismatch.
+    port = 2222;
+
+    # This takes 'path's, not 'string's.
+    hostKeys = [
+      /boot/initrd-ssh-key
+      /boot-fallback/initrd-ssh-key
+    ];
+
+    # Public ssh key to log into the initrd ssh
+    authorizedKeys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN14b5Fu+StHeMXq4ClyLG4G+/vCAfS7adxceEFria/ openpgp:0x1D5BCD11" ];
+  };
+  boot.initrd.network.postCommands = ''
+    cat <<EOF > /root/.profile
+    if pgrep -x "zfs" > /dev/null
+    then
+      zfs load-key -a
+      killall zfs
+    else
+      echo "ZFS is not running -- this could be a sign of failure."
+    fi
+    EOF
+  '';
+
+
+  networking.hostName = hostName;
+
+  networking.useDHCP = false;
+  networking.interfaces.${networkInterface} = {
+    ipv4 = { addresses = [{ address = ipv4.address; prefixLength = ipv4.prefixLength; }]; };
+    ipv6 = { addresses = [{ address = ipv6.address; prefixLength = ipv6.prefixLength; }]; };
+  };
+  networking.defaultGateway = ipv4.gateway;
+  networking.defaultGateway6 = { address = ipv6.gateway; interface = networkInterface; };
+  networking.nameservers = [ "8.8.8.8" ];
+
+  networking.firewall.enable = true;
+
+  time.timeZone = "UTC";
+
+  users.users.root.initialHashedPassword = ""; # FIXME: use proper secret
+  users.users.root.openssh.authorizedKeys.keys = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN14b5Fu+StHeMXq4ClyLG4G+/vCAfS7adxceEFria/ openpgp:0x1D5BCD11" ];
+  services.openssh.enable = true;
+  services.openssh.settings.permitRootLogin = "prohibit-password";
+
+  services.openssh.hostKeys = [
+    {
+      path = "/persist/ssh/ssh_host_ed25519_key";
+      type = "ed25519";
+    }
+    {
+      path = "/persist/ssh/ssh_host_rsa_key";
+      type = "rsa";
+      bits = 4096;
+    }
+  ];
+
+  # impermanence requirement
+  fileSystems."/persist".neededForBoot = true;
+
+  environment.systemPackages = with pkgs; [
+    bsd-finger
+  ];
+
+  sops.secrets.acme-envs = {
+    owner = "acme";
+  };
+  sops.secrets.matrix-server-key = { };
+  sops.secrets.dendrite-envs = { };
+  sops.secrets.sliding-sync-secret = { };
+  sops.secrets.mjolnir-password = { owner = "mjolnir"; };
+  sops.secrets.mautrix-telegram-envs = { };
+  sops.secrets.turn-secret = { };
+  # sops.secrets.openldap-admin-key = {
+  # owner = "openldap";
+  # };
+
+  modules = {
+    sops.enable = true;
+
+    services.metrics = {
+      enable = true;
+      domain = "status.exotic.sh";
+      tls.acmeHost = "exotic.sh";
+    };
+
+    services.coredns.enable = false;
+    services.nginx.enable = true;
+    services.acme = {
+      enable = true;
+      email = poorObfuscation "exotic.sh" "postmaster";
+
+      certs = {
+        "exotic.sh" = {
+          subDomains = [
+            "git"
+            "matrix"
+            "*.labs"
+            "social"
+            "bouncer"
+            "meet"
+            "chat"
+            "turn"
+            "status"
+            "mail"
+            "todo"
+          ];
+        };
+        "nand.moe" = {
+          subDomains = [ ];
+        };
+        "sefidel.net" = {
+          subDomains = [ ];
+        };
+      };
+
+      secrets.acme-credentials = config.sops.secrets.acme-envs.path;
+    };
+
+    services.gitolite = {
+      enable = true;
+      adminPubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN14b5Fu+StHeMXq4ClyLG4G+/vCAfS7adxceEFria/ openpgp:0x1D5BCD11";
+    };
+    services.gitDaemon.enable = true;
+    services.cgit = {
+      enable = true;
+      domain = "exotic.sh";
+      realHost = "git.exotic.sh";
+      settings.description = "Exotic place.";
+    };
+    services.fail2ban.enable = true;
+    services.postgresql.enable = true;
+    services.dendrite = {
+      enable = true;
+      domain = "exotic.sh";
+      realHost = "matrix.exotic.sh";
+      slidingSyncHost = "slidingsync.labs.exotic.sh";
+      turn = {
+        enable = true;
+        domain = "turn.exotic.sh";
+        shared_secret = "$TURN_SECRET"; # dendrite envs
+      };
+      secrets = {
+        matrix-server-key = config.sops.secrets.matrix-server-key.path;
+        dendrite-envs = config.sops.secrets.dendrite-envs.path;
+        sliding-sync-secret = config.sops.secrets.sliding-sync-secret.path;
+      };
+    };
+    services.coturn = {
+      enable = true;
+      domain = "turn.exotic.sh";
+      tls.acmeHost = "exotic.sh";
+      shared_secret = config.sops.secrets.turn-secret.path;
+    };
+    services.matrix-moderation = {
+      enable = true;
+      domain = "exotic.sh";
+      secrets.userPassword = config.sops.secrets.mjolnir-password.path;
+    };
+    services.matrix-bridge = {
+      enable = true;
+      domain = "exotic.sh";
+      secrets.mautrix-envs = config.sops.secrets.mautrix-telegram-envs.path;
+    };
+    services.element-web = {
+      enable = true;
+      hostName = "chat.exotic.sh";
+      matrix = {
+        baseUrl = "https://matrix.exotic.sh";
+        serverName = "exotic.sh";
+      };
+      tls.acmeHost = "exotic.sh";
+      jitsi.domain = "meet.exotic.sh";
+    };
+    services.akkoma = {
+      enable = true;
+      domain = "exotic.sh";
+      realHost = "social.exotic.sh";
+      instanceName = "exotic.sh social";
+    };
+    services.misskey = {
+      enable = true;
+      domain = "nand.moe";
+      realHost = "nand.moe";
+    };
+    services.soju = {
+      enable = true;
+      hostName = "bouncer.exotic.sh";
+      tls.enable = true;
+      tls.acmeHost = "exotic.sh";
+    };
+    services.vikunja = {
+      enable = true;
+      domain = "exotic.sh";
+      realHost = "todo.exotic.sh";
+    };
+
+
+    services.nixos-mailserver.enable = true; # TODO: replace with dovecot.nix?
+
+    services.jitsi = {
+      enable = true;
+      hostName = "meet.exotic.sh";
+      tls.acmeHost = "exotic.sh";
+    };
+
+    services.ldap = {
+      enable = false;
+      dc = "exotic";
+      tld = "sh";
+      tls.acmeHost = "exotic.sh";
+      secrets.rootPass = config.sops.secrets.openldap-admin-key.path;
+    };
+    services.pubnix.enable = false;
+    services.userweb = {
+      enable = false;
+      domain = "exotic.sh";
+    };
+
+    services.sefidel-web.enable = true;
+  };
+
+  # This value determines the NixOS release from which the default
+  # settings for stateful data, like file locations and database versions
+  # on your system were taken. It‘s perfectly fine and recommended to leave
+  # this value at the release version of the first install of this system.
+  # Before changing this value read the documentation for this option
+  # (e.g. man configuration.nix or on https://nixos.org/nixos/options.html).
+  system.stateVersion = "23.05"; # Did you read the comment?
+}
+
diff --git a/systems/cobalt/hardware-configuration.nix b/systems/cobalt/hardware-configuration.nix
new file mode 100644
index 0000000..95ecb96
--- /dev/null
+++ b/systems/cobalt/hardware-configuration.nix
@@ -0,0 +1,65 @@
+# Do not modify this file!  It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations.  Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ config, lib, pkgs, modulesPath, ... }:
+
+{
+  imports =
+    [
+      (modulesPath + "/installer/scan/not-detected.nix")
+    ];
+
+  boot.initrd.availableKernelModules = [ "xhci_pci" "ahci" "sd_mod" ];
+  boot.initrd.kernelModules = [ ];
+  boot.kernelModules = [ "kvm-intel" ];
+  boot.extraModulePackages = [ ];
+
+  fileSystems."/" =
+    {
+      device = "rpool/local/root";
+      fsType = "zfs";
+    };
+
+  fileSystems."/boot" =
+    {
+      device = "/dev/disk/by-uuid/445A-0C55";
+      fsType = "vfat";
+    };
+
+  fileSystems."/boot-fallback" =
+    {
+      device = "/dev/disk/by-uuid/445C-198F";
+      fsType = "vfat";
+    };
+
+  fileSystems."/nix" =
+    {
+      device = "rpool/local/nix";
+      fsType = "zfs";
+    };
+
+  fileSystems."/home" =
+    {
+      device = "rpool/safe/home";
+      fsType = "zfs";
+    };
+
+  fileSystems."/persist" =
+    {
+      device = "rpool/safe/persist";
+      fsType = "zfs";
+    };
+
+  swapDevices = [ ];
+
+  # Enables DHCP on each ethernet and wireless interface. In case of scripted networking
+  # (the default) this is the recommended approach. When using systemd-networkd it's
+  # still possible to use this option, but it's recommended to use it in conjunction
+  # with explicit per-interface declarations with `networking.interfaces.<interface>.useDHCP`.
+  networking.useDHCP = lib.mkDefault false;
+  # networking.interfaces.enp0s31f6.useDHCP = lib.mkDefault true;
+
+  nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
+  powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
+  hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
+}
diff --git a/systems/cobalt/secrets/secrets.yaml b/systems/cobalt/secrets/secrets.yaml
new file mode 100644
index 0000000..8e0c0e5
--- /dev/null
+++ b/systems/cobalt/secrets/secrets.yaml
@@ -0,0 +1,39 @@
+acme-envs: ENC[AES256_GCM,data:9IvoY1E2VLikZgPcNnEl2e33SMgLOJsX7aVTEbld1ggl8Z77a2iau17d/ZLWs0+u,iv:gr2iHuYmtZp2eWhX0E0OKolIn/Nm5+9hJqFTYZagV4c=,tag:9mqUFzqe8+3T+Nwbu5V0Fg==,type:str]
+matrix-server-key: ENC[AES256_GCM,data:MsPH8g0bc0cY+k5XvVIyi1hDpX2up7+noU9P4Dfm3Z9f1eXv7SaJhlRnR40qrk4sQN1uG0R/ro7S2z9EswX0iZx10PfjWF8Igrc9w9b2+EM/gb0O3dGskPAnDrm9JYh+gF6SsqmwJUEYh+Lx1EfuGMTWpnXZjLrimIWCfmz/1qxJECQds/aQwuA=,iv:B8yzXZ1IUVVvxFQ0MzzS5LSHZXQirnXiLoOru4S2H78=,tag:TNp1Y3C/iYgq5itqXGIt/g==,type:str]
+mjolnir-password: ENC[AES256_GCM,data:dyM2VVxn1PFRXy5dgfvq3EuWyGDhDZvJOd1sTnKE5q0Arv1y,iv:DD80um8QXLybj1w4ZsxPbv3+s2NrQfpPDAEpkztkMFo=,tag:3ZEJ7V+ICh2Ip5gZt06zjA==,type:str]
+mautrix-telegram-envs: ENC[AES256_GCM,data:hDfEI6tshgPHn/yPHkqO9/VZnB/vTnuyopVm2/0CKoWska66UXOHbXwvO6UXXUZldzkvKe6min3O9xjEGBC+SZdLWmfi6AxJvoKnmU2MzufNkrDG4k0turK7LwwVnm8ADZd9tEII+P3h42t78jhkgerailjP1rtVKleAZpr+ioRGhCTAE6tVWnhV4jveUwNkml+0AhMc3aY0N2PLvedw8p/jsX6Yujn2baKc7HOkvAasctqq/p8kdiTZCA+ASIVC15iR+KtGu2V7vXjDuzYU7Xv7+RbXN7rUTCBIJwyc3XxpwOmD46XR+/2iSZ3nL0PmDftbIEaA9LatjUap7mRFOwX5JruAl1XxAGdG965HZFyTHp1UggjjWvoHQmyb1xJXD5ZE+EU8obkFV0N1o7Q4XIF6/REz9+01JrOd1F5GxjoAo29JV+1lhdxGykrFcA63VLsXdsUgZflWH7Rxol2nOMI5t88+zohYJ5OpeLlRD0gVaX8dvgSzvzmZaNxASW+9tHOQBSzkZsM6jEon6ZXeAa0oh/ee+W82jHhe/ohlj0UV7TtpW/QWs3NR3mv1x41TMBawnVQ7sb9UH/sbdbajJYEAkSD/32ETSjXiQJsmhGEj5kRBNAQA16s1o0M24+UnpHo71jqQnRQpOUtFhLQqakOWvSdM4KgIXExc87Fkw+bQxLtpIup+T4WpJkCe5f5rB/jHvob33oitzjS1mam9UTtzwQjTuR/P4V8IORygDHlcq8W2PtDhWOdgtWI9cfIlqIIgu6+7Rg4+1dq5aw8j06eWrNH2NElPMji0opN+onT3VflgwGPXHlemId20Z2TXNuE6LlnUhyyxdh0rw3tVuCDcodzj4HfXk9mbjlTufyGKY41K/A6QTotiHANojizuTyulyjI3Gx1Wvzv8+Ib84SZGl9ZSsg7g6Ig5U12IJhNtZ/vMj8MHpRjxnyRmZ4jsQi9UctDKrhHjulD1WJyhBveoEv8L79c6e0xZYAwkSlxvdaOPK3OMGbbtbH8vy7WYXSwHZomwJ7BrsrQ/5lWav2xXzgM7ASnEg/rKBPr9YQYOBA==,iv:K2qdi99cjom4mzcNH26qqX3gQNSvZzuAh9QY4zmZv9I=,tag:e9/4biFz6mVsGHV/YOA1jg==,type:str]
+dendrite-envs: ENC[AES256_GCM,data:67FnrGQUZWFfHAoUM/idTZlBX7aek3fbPkswB9+3pjLNQuXpIWYoa2vpdGt7zec2n9o9z0V3LdlkookjS95aPpZmKYwPaKkH2L7Jaxw=,iv:c4lEReLizcQeTTiG7cJwd+2sBH+EKBGycKeoDgJ/394=,tag:zBBxIcXn+8Q90BkPidltfQ==,type:str]
+sliding-sync-secret: ENC[AES256_GCM,data:lNIlUtNbXw1/w44m7RwqmvOTmc4MYfag7Nvo0izTM6Al7eOZXjEXGgfr7b4PB3QNewghN4LEEj1ae8HqpivT/w==,iv:Qw2XLjYp3/78OuEakUDJ0Sp3fmjw36IJTg8UKTZ7UEU=,tag:tp3gACPEZNMzj+aDP3Ax4A==,type:str]
+turn-secret: ENC[AES256_GCM,data:JA5/BlGwH6yIjYsFZGa8Nm8XVbOBKpre+NFybniOtlmbSx89ldKBvuqF2ZoPltJS+vzQ/+wxM/VorhF7M+s4jA==,iv:rK5SFj4VOzgfaP/LIzWTVFyCBmklGMSyd9iWbet2CVc=,tag:QycYCHH72bMMX5UubDHTlg==,type:str]
+openldap-admin-key: ENC[AES256_GCM,data:WBBDPFDW6Q4sJ5+/pK8kAe6iFgJ8gGgi3eCVNvZB,iv:1rnmhu29UGsXLxD9Ptbv7P67EAYgKVk1dlkM6p0L4vA=,tag:yNRrHMI2yT8Oo7qkwxSeUg==,type:str]
+sefidel-imap-pass: ENC[AES256_GCM,data:rx9hZb+BARs9gB+XLLRMLWDSx67KqkKB1/4nOOtU9i56uagMprFEeDnh8pEaioZbNlqjJRO8kWTBBvWZ,iv:WxKLp0VmwfxVFZt9cnZUbp4wn5WEHubImp8fQy2bXyg=,tag:Vzh0Ntz8iFaSIEf2wjbOKg==,type:str]
+internal-imap-pass: ENC[AES256_GCM,data:ydjz/NthnJZFLrR1M+p0xEy5xhM8MbPtqE10r0s1DWDFZoyXwRRrIYefFZheW29EjY3VBfr3zWcRIbNm,iv:6hU/dHADbn4pNi0vlJG8BoyQW1ohByINSO6y+nJddfY=,tag:j67D2stmq2A+ulhFIYkZPA==,type:str]
+sops:
+    kms: []
+    gcp_kms: []
+    azure_kv: []
+    hc_vault: []
+    age:
+        - recipient: age1jt8xg0lvzj5q4f7fn7nw670qsszm3kv3caa654eh62azra4x44zss4fad8
+          enc: |
+            -----BEGIN AGE ENCRYPTED FILE-----
+            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAvbE1CVEFaWXQ4M0lxR1dH
+            c2dJMGFxTS85ZDhlamRyQWl2Y3VxcXhVdm5RCml3MWl4dkZQK1Z5T3JqTnBLaVU1
+            R25PdXJBVWczMGt6RkNWWllXOFJicDAKLS0tIElJYytrUHhzb2xHeTJTRy82bFJR
+            bm11bEpUaFJjcHB0c2pRendiakRpS2cKNa9ZrFkOLfOqEEN9ATktvrQgANceDj2c
+            mkUjhxPfti4jNE2c6gsq3DegJT/08QpFJYYuAx/sO2R8wld3kFVyrQ==
+            -----END AGE ENCRYPTED FILE-----
+        - recipient: age14a2amn7memzvctf2nnrt6uj458x3g4jpcvs04tlkww2z02p05syqawxrwh
+          enc: |
+            -----BEGIN AGE ENCRYPTED FILE-----
+            YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4TEpsUmRQNUxDUU00VENS
+            MTgxN2ZjM0NYOEdaem5yaW1XaHdlandsQmtNCk5YSzdHdTFqa1BOSkZnajJJenhY
+            SFdnKzI2WmhONnJ2UWJjRWNGNUhuT0kKLS0tIHNRcVo2VXJEY0tSVUEwVkF0Z2l6
+            cUpBZ01CMEFjNnNuWjlYejVKajkwcGMKehqYCZP0zZHDTfJrC/5LYiE/3doa0OiM
+            OKXhOuUX8HF8RfkyiOSMpntxuNX2jSvd9sQRYnHkUvgm793+IuQjrg==
+            -----END AGE ENCRYPTED FILE-----
+    lastmodified: "2023-03-27T15:19:33Z"
+    mac: ENC[AES256_GCM,data:CyVH0paaTqnff98h5CSCas3YYYYAxEtyYdkjyFBfN/Nwfpe3e71O6YwLZgzAZoiaN+1FuF0kls5WmvDNdx95rEC4yvxQACA75iRyP95B5Q9iN9SGGld0Ii8wPY6s0QkJX+OL7mCllH/gC0J2gOpnPxRB9k5v5FXtKHmJtj5kfaI=,iv:ytWBOy2VTWtVlPbrXiHF5BNxbCmQ194x6aeMh1pd7vc=,tag:0J77TO1y8OTXzdODqANkEw==,type:str]
+    pgp: []
+    unencrypted_suffix: _unencrypted
+    version: 3.7.3