about summary refs log tree commit diff
path: root/modules
diff options
context:
space:
mode:
authorsefidel <contact@sefidel.net>2024-01-24 13:29:27 +0900
committersefidel <contact@sefidel.net>2024-01-24 18:59:54 +0900
commit8e9b074467006c76768efe04cf1fb1ef9d652c67 (patch)
treefad73c7f94a74c77714c260d1fc0a63e2d205b49 /modules
downloadinfra-modules-main.tar.gz
infra-modules-main.zip
initial commit HEAD main
Diffstat (limited to 'modules')
-rw-r--r--modules/README.md9
-rw-r--r--modules/cachix/caches/nix-community.nix12
-rw-r--r--modules/cachix/default.nix23
-rw-r--r--modules/flakes.nix36
-rw-r--r--modules/nix.nix34
-rw-r--r--modules/persistence.nix47
-rw-r--r--modules/security.nix69
-rw-r--r--modules/services/_template.nix13
-rw-r--r--modules/services/acme.nix52
-rw-r--r--modules/services/akkoma/blocklist.toml164
-rw-r--r--modules/services/akkoma/default.nix102
-rw-r--r--modules/services/akkoma/favicon-withbg.pngbin0 -> 17371 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/authentik.nix69
-rw-r--r--modules/services/backup.nix81
-rw-r--r--modules/services/cgit/cgit-exotic.css953
-rw-r--r--modules/services/cgit/default.nix129
-rw-r--r--modules/services/cinny-web.nix34
-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/dovecot.nix18
-rw-r--r--modules/services/element-web.nix47
-rw-r--r--modules/services/fail2ban.nix20
-rw-r--r--modules/services/git-daemon/_git-daemon-module.nix137
-rw-r--r--modules/services/git-daemon/default.nix31
-rw-r--r--modules/services/gitolite/default.nix110
-rw-r--r--modules/services/gitolite/fix-refs9
-rw-r--r--modules/services/gitolite/post-receive19
-rw-r--r--modules/services/gitolite/rename63
-rw-r--r--modules/services/jitsi.nix43
-rw-r--r--modules/services/ldap.nix76
-rw-r--r--modules/services/matrix-bridge/_mautrix-discord-module.nix205
-rw-r--r--modules/services/matrix-bridge/_mautrix-signal-module.nix204
-rw-r--r--modules/services/matrix-bridge/default.nix390
-rw-r--r--modules/services/matrix-homeserver.nix190
-rw-r--r--modules/services/matrix-moderation.nix58
-rw-r--r--modules/services/metrics.nix169
-rw-r--r--modules/services/nebula.nix18
-rw-r--r--modules/services/nginx.nix37
-rw-r--r--modules/services/nixos-mailserver.nix159
-rw-r--r--modules/services/obsidian-livesync.nix63
-rw-r--r--modules/services/postgresql.nix34
-rw-r--r--modules/services/rss.nix64
-rw-r--r--modules/services/searx.nix50
-rw-r--r--modules/services/sefidel-web.nix26
-rw-r--r--modules/services/soju.nix40
-rw-r--r--modules/services/tailscale.nix22
-rw-r--r--modules/services/vikunja.nix50
-rw-r--r--modules/sops.nix19
54 files changed, 4382 insertions, 0 deletions
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..5222457
--- /dev/null
+++ b/modules/cachix/default.nix
@@ -0,0 +1,23 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.cachix;
+
+  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;
+
+  options.modules.cachix = {
+    enable = mkEnableOption "cachix";
+  };
+
+  config = mkIf cfg.enable {
+    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..4297fda
--- /dev/null
+++ b/modules/flakes.nix
@@ -0,0 +1,36 @@
+{ config, inputs, pkgs, 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 = inputs.unstable;
+      registry.nixpkgsSmall.flake = inputs.unstable-small;
+      registry.nixpkgs2111.flake = inputs.nixpkgs-2111;
+
+      nixPath = [
+        "nixpkgs=${nixpkgsPath}"
+        "nixpkgsSmall=${nixpkgsSmallPath}"
+        "nixpkgs2111=${nixpkgs2111Path}"
+        "/nix/var/nix/profiles/per-user/root/channels"
+      ];
+    };
+
+    systemd.tmpfiles.rules = [
+      "L+ ${nixpkgsPath}      - - - - ${inputs.unstable}"
+      "L+ ${nixpkgsSmallPath} - - - - ${inputs.unstable-small}"
+      "L+ ${nixpkgs2111Path}  - - - - ${inputs.nixpkgs-2111}"
+    ];
+  };
+}
diff --git a/modules/nix.nix b/modules/nix.nix
new file mode 100644
index 0000000..8396739
--- /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.str;
+      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/persistence.nix b/modules/persistence.nix
new file mode 100644
index 0000000..e7a8e90
--- /dev/null
+++ b/modules/persistence.nix
@@ -0,0 +1,47 @@
+{ config, inputs, lib, ... }:
+
+
+with lib;
+let
+  cfg = config.modules.persistence;
+in
+{
+  imports = [
+    inputs.impermanence.nixosModules.impermanence
+  ];
+
+  options.modules.persistence = {
+    enable = mkEnableOption "impermanence persistence";
+
+    storagePath = lib.mkOption {
+      type = types.path;
+      description = ''
+        The path to persistent storage where the real
+        files and directories should be stored.
+      '';
+    };
+
+    directories = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+    };
+  };
+
+  config = mkIf cfg.enable {
+    fileSystems.${cfg.storagePath}.neededForBoot = true;
+
+    environment.persistence.${cfg.storagePath}.directories = cfg.directories;
+
+    services.openssh.hostKeys = [
+      {
+        path = "${cfg.storagePath}/ssh/ssh_host_ed25519_key";
+        type = "ed25519";
+      }
+      {
+        path = "${cfg.storagePath}/ssh/ssh_host_rsa_key";
+        type = "rsa";
+        bits = 4096;
+      }
+    ];
+  };
+}
diff --git a/modules/security.nix b/modules/security.nix
new file mode 100644
index 0000000..d345757
--- /dev/null
+++ b/modules/security.nix
@@ -0,0 +1,69 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.security;
+in
+{
+  options.modules.security = {
+    enable = mkEnableOption "Security-related system tweaks";
+  };
+
+  config = mkIf cfg.enable {
+    # 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.tmp.useTmpfs = false;
+    boot.tmp.tmpfsSize = "80%";
+
+    # Purge /tmp on boot. (fallback option)
+    boot.tmp.cleanOnBoot = lib.mkDefault (!config.boot.tmp.useTmpfs);
+
+    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/_template.nix b/modules/services/_template.nix
new file mode 100644
index 0000000..26634a4
--- /dev/null
+++ b/modules/services/_template.nix
@@ -0,0 +1,13 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services._template;
+in
+{
+  options.modules.services._template = {
+    enable = mkEnableOption "";
+  };
+
+  config = mkIf cfg.enable { };
+}
diff --git a/modules/services/acme.nix b/modules/services/acme.nix
new file mode 100644
index 0000000..b3ebb26
--- /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 = "cloudflare";
+          dnsPropagationCheck = true;
+          credentialsFile = cfg.secrets.acme-credentials;
+        } // optionalAttrs (domain != null) {
+          domain = domain;
+        })
+        cfg.certs;
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/acme"
+    ];
+  };
+}
diff --git a/modules/services/akkoma/blocklist.toml b/modules/services/akkoma/blocklist.toml
new file mode 100644
index 0000000..d8f53af
--- /dev/null
+++ b/modules/services/akkoma/blocklist.toml
@@ -0,0 +1,164 @@
+[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.”"
+"hornyjail.pro" = "Obscene speech without content warning"
diff --git a/modules/services/akkoma/default.nix b/modules/services/akkoma/default.nix
new file mode 100644
index 0000000..91aa2e8
--- /dev/null
+++ b/modules/services/akkoma/default.nix
@@ -0,0 +1,102 @@
+{ 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-withbg.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;
+          };
+          ":pleroma"."Pleroma.Captcha" = {
+            enabled = true;
+            method = mkRaw "Pleroma.Captcha.Kocaptcha";
+          };
+        };
+
+      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;
+        '';
+      };
+    };
+    modules.persistence.directories = [
+      "/var/lib/akkoma"
+    ];
+  };
+}
diff --git a/modules/services/akkoma/favicon-withbg.png b/modules/services/akkoma/favicon-withbg.png
new file mode 100644
index 0000000..f9595ce
--- /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..33c7ff3
--- /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 private instance with only me and my friends. Here's the rules:</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>Any account that doesn't abide by the rules specified above will be terminated without prior notice.</p>
+  </body>
+</html>
diff --git a/modules/services/authentik.nix b/modules/services/authentik.nix
new file mode 100644
index 0000000..10241b9
--- /dev/null
+++ b/modules/services/authentik.nix
@@ -0,0 +1,69 @@
+{ inputs, config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.authentik;
+in
+{
+  imports = [ inputs.authentik-nix.nixosModules.default ];
+
+  options.modules.services.authentik = {
+    enable = mkEnableOption "Authentik - Identity Provider";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; default = "authentik.${cfg.domain}"; };
+    email = {
+      host = mkOption { type = types.str; default = "smtp.${cfg.domain}"; };
+      username = mkOption { type = types.str; default = "authentik@${cfg.domain}"; };
+      from = mkOption { type = types.str; default = cfg.email.username; };
+    };
+    secrets = {
+       authentik-envs = mkOption { type = types.path; description = "path to the environment file"; };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.authentik = {
+      enable = true;
+
+      environmentFile = cfg.secrets.authentik-envs;
+
+      settings = {
+        email = {
+          host = cfg.email.host;
+          port = 587;
+          username = cfg.email.username;
+          use_tls = true;
+          use_ssl = false;
+          from = cfg.email.from;
+        };
+
+        cert_discovery_dir = "env://CREDENTIALS_DIRECTORY";
+      };
+      nginx = {
+        # This is configured manually since authentik-nix doesn't support
+        # cases where cert domain != nginx host
+        enable = false;
+        enableACME = false;
+        # host = cfg.realHost;
+      };
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/private/authentik"
+    ];
+
+    systemd.services.authentik-worker.serviceConfig.LoadCredential = [
+      "${cfg.domain}.pem:${config.security.acme.certs.${cfg.domain}.directory}/fullchain.pem"
+      "${cfg.domain}.key:${config.security.acme.certs.${cfg.domain}.directory}/key.pem"
+    ];
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      useACMEHost = cfg.domain;
+      forceSSL = true;
+      locations."/" = {
+        proxyWebsockets = true;
+        proxyPass = "https://localhost:9443";
+      };
+    };
+  };
+}
diff --git a/modules/services/backup.nix b/modules/services/backup.nix
new file mode 100644
index 0000000..9770b43
--- /dev/null
+++ b/modules/services/backup.nix
@@ -0,0 +1,81 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.backup;
+in
+{
+  options.modules.services.backup = {
+    enable = mkEnableOption "borg-based backup solution";
+    name = lib.mkOption {
+      type = lib.types.str;
+      default = "${config.networking.hostName}-rolling";
+      description = ''
+        Name of the backup job
+      '';
+    };
+
+    paths = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      description = ''
+        Paths to back up
+      '';
+    };
+    exclude = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      default = [ ];
+      description = ''
+        Paths to exclude
+      '';
+    };
+    repo = lib.mkOption {
+      type = lib.types.str;
+      description = ''
+        Path to the repository to back up to
+      '';
+    };
+    repoKeyPath = lib.mkOption {
+      type = lib.types.str;
+      description = ''
+        Path to the repository key
+      '';
+    };
+    sshKeyPath = lib.mkOption {
+      type = lib.types.str;
+      description = ''
+        Path to the ssh key
+      '';
+    };
+    rsyncNet = lib.mkOption {
+      type = lib.types.bool;
+      default = false;
+      description = ''
+        Whether to enable rsync.net specific patches
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.borgbackup.jobs.${cfg.name} = {
+      inherit (cfg) paths exclude repo;
+
+      prune.keep = {
+        within = "1d"; # Keep all archives from the last day
+        daily = 7;
+        weekly = 4;
+        monthly = 3;
+      };
+
+      encryption.mode = "repokey-blake2";
+      encryption.passCommand = "cat ${cfg.repoKeyPath}";
+
+      environment.BORG_RSH = "ssh -i ${cfg.sshKeyPath}";
+      environment.BORG_REMOTE_PATH = lib.mkIf cfg.rsyncNet "/usr/local/bin/borg1/borg1";
+      # use borg 1.0+ on rsync.net
+      extraCreateArgs = "--verbose --stats --checkpoint-interval 600";
+      compression = "auto,zstd";
+      startAt = "*-*-* 03:00:00"; # pgsql backup runs on 01:15:00
+      persistentTimer = true;
+    };
+  };
+}
diff --git a/modules/services/cgit/cgit-exotic.css b/modules/services/cgit/cgit-exotic.css
new file mode 100644
index 0000000..32117c7
--- /dev/null
+++ b/modules/services/cgit/cgit-exotic.css
@@ -0,0 +1,953 @@
+div#cgit {
+	padding: 0em;
+	margin: 0em;
+	font-family: sans-serif;
+	font-size: 10pt;
+	color: #333;
+	background: white;
+	padding: 4px;
+}
+
+div#cgit a {
+	color: blue;
+	text-decoration: none;
+}
+
+div#cgit a:hover {
+	text-decoration: underline;
+}
+
+div#cgit table {
+	border-collapse: collapse;
+}
+
+div#cgit table#header {
+	width: 100%;
+	margin-bottom: 1em;
+}
+
+div#cgit table#header td.logo {
+	width: 96px;
+	vertical-align: top;
+}
+
+div#cgit table#header td.main {
+	font-size: 250%;
+	padding-left: 10px;
+	white-space: nowrap;
+}
+
+div#cgit table#header td.main a {
+	color: #000;
+}
+
+div#cgit table#header td.form {
+	text-align: right;
+	vertical-align: bottom;
+	padding-right: 1em;
+	padding-bottom: 2px;
+	white-space: nowrap;
+}
+
+div#cgit table#header td.form form,
+div#cgit table#header td.form input,
+div#cgit table#header td.form select {
+	font-size: 90%;
+}
+
+div#cgit table#header td.sub {
+	color: #777;
+	border-top: solid 1px #ccc;
+	padding-left: 10px;
+}
+
+div#cgit table.tabs {
+	border-bottom: solid 3px #ccc;
+	border-collapse: collapse;
+	margin-top: 2em;
+	margin-bottom: 0px;
+	width: 100%;
+}
+
+div#cgit table.tabs td {
+	padding: 0px 1em;
+	vertical-align: bottom;
+}
+
+div#cgit table.tabs td a {
+	padding: 2px 0.25em;
+	color: #777;
+	font-size: 110%;
+}
+
+div#cgit table.tabs td a.active {
+	color: #000;
+	background-color: #ccc;
+}
+
+div#cgit table.tabs a[href^="http://"]:after, div#cgit table.tabs a[href^="https://"]:after {
+	content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfgAhcJDQY+gm2TAAAAHWlUWHRDb21tZW50AAAAAABDcmVhdGVkIHdpdGggR0lNUGQuZQcAAABbSURBVAhbY2BABs4MU4CwhYHBh2Erww4wrGFQZHjI8B8IgUIscJWyDHcggltQhI4zGDCcRwhChPggHIggP1QoAVmQkSETrGoHsiAEsACtBYN0oDAMbgU6EBcAAL2eHUt4XUU4AAAAAElFTkSuQmCC);
+	opacity: 0.5;
+	margin: 0 0 0 5px;
+}
+
+div#cgit table.tabs td.form {
+	text-align: right;
+}
+
+div#cgit table.tabs td.form form {
+	padding-bottom: 2px;
+	font-size: 90%;
+	white-space: nowrap;
+}
+
+div#cgit table.tabs td.form input,
+div#cgit table.tabs td.form select {
+	font-size: 90%;
+}
+
+div#cgit div.path {
+	margin: 0px;
+	padding: 5px 2em 2px 2em;
+	color: #000;
+	background-color: #eee;
+}
+
+div#cgit div.content {
+	margin: 0px;
+	padding: 2em;
+	border-bottom: solid 3px #ccc;
+}
+
+
+div#cgit table.list {
+	width: 100%;
+	border: none;
+	border-collapse: collapse;
+}
+
+div#cgit table.list tr {
+	background: white;
+}
+
+div#cgit table.list tr.logheader {
+	background: #eee;
+}
+
+div#cgit table.list tr:nth-child(even) {
+	background: #f7f7f7;
+}
+
+div#cgit table.list tr:nth-child(odd) {
+	background: white;
+}
+
+div#cgit table.list tr:hover {
+	background: #eee;
+}
+
+div#cgit table.list tr.nohover {
+	background: white;
+}
+
+div#cgit table.list tr.nohover:hover {
+	background: white;
+}
+
+div#cgit table.list tr.nohover-highlight:hover:nth-child(even) {
+	background: #f7f7f7;
+}
+
+div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) {
+	background: white;
+}
+
+div#cgit table.list th {
+	font-weight: bold;
+	/* color: #888;
+	border-top: dashed 1px #888;
+	border-bottom: dashed 1px #888;
+	*/
+	padding: 0.1em 0.5em 0.05em 0.5em;
+	vertical-align: baseline;
+}
+
+div#cgit table.list td {
+	border: none;
+	padding: 0.1em 0.5em 0.1em 0.5em;
+}
+
+div#cgit table.list td.commitgraph {
+	font-family: monospace;
+	white-space: pre;
+}
+
+div#cgit table.list td.commitgraph .column1 {
+	color: #a00;
+}
+
+div#cgit table.list td.commitgraph .column2 {
+	color: #0a0;
+}
+
+div#cgit table.list td.commitgraph .column3 {
+	color: #aa0;
+}
+
+div#cgit table.list td.commitgraph .column4 {
+	color: #00a;
+}
+
+div#cgit table.list td.commitgraph .column5 {
+	color: #a0a;
+}
+
+div#cgit table.list td.commitgraph .column6 {
+	color: #0aa;
+}
+
+div#cgit table.list td.logsubject {
+	font-family: monospace;
+	font-weight: bold;
+}
+
+div#cgit table.list td.logmsg {
+	font-family: monospace;
+	white-space: pre;
+	padding: 0 0.5em;
+}
+
+div#cgit table.list td a {
+	color: black;
+}
+
+div#cgit table.list td a.ls-dir {
+	font-weight: bold;
+	color: #00f;
+}
+
+div#cgit table.list td a:hover {
+	color: #00f;
+}
+
+div#cgit img {
+	border: none;
+}
+
+div#cgit input#switch-btn {
+	margin: 2px 0px 0px 0px;
+}
+
+div#cgit td#sidebar input.txt {
+	width: 100%;
+	margin: 2px 0px 0px 0px;
+}
+
+div#cgit table#grid {
+	margin: 0px;
+}
+
+div#cgit td#content {
+	vertical-align: top;
+	padding: 1em 2em 1em 1em;
+	border: none;
+}
+
+div#cgit div#summary {
+	vertical-align: top;
+	margin-bottom: 1em;
+}
+
+div#cgit table#downloads {
+	float: right;
+	border-collapse: collapse;
+	border: solid 1px #777;
+	margin-left: 0.5em;
+	margin-bottom: 0.5em;
+}
+
+div#cgit table#downloads th {
+	background-color: #ccc;
+}
+
+div#cgit div#blob {
+	border: solid 1px black;
+}
+
+div#cgit div.error {
+	color: red;
+	font-weight: bold;
+	margin: 1em 2em;
+}
+
+div#cgit a.ls-blob, div#cgit a.ls-dir, div#cgit .ls-mod {
+	font-family: monospace;
+}
+
+div#cgit td.ls-size {
+	text-align: right;
+	font-family: monospace;
+	width: 10em;
+}
+
+div#cgit td.ls-mode {
+	font-family: monospace;
+	width: 10em;
+}
+
+div#cgit table.blob {
+	margin-top: 0.5em;
+	border-top: solid 1px black;
+}
+
+div#cgit table.blob td.hashes,
+div#cgit table.blob td.lines {
+	margin: 0; padding: 0 0 0 0.5em;
+	vertical-align: top;
+	color: black;
+}
+
+div#cgit table.blob td.linenumbers {
+	margin: 0; padding: 0 0.5em 0 0.5em;
+	vertical-align: top;
+	text-align: right;
+	border-right: 1px solid gray;
+}
+
+div#cgit table.blob pre {
+	padding: 0; margin: 0;
+}
+
+div#cgit table.blob td.linenumbers a,
+div#cgit table.ssdiff td.lineno a {
+	color: gray;
+	text-align: right;
+	text-decoration: none;
+}
+
+div#cgit table.blob td.linenumbers a:hover,
+div#cgit table.ssdiff td.lineno a:hover {
+	color: black;
+}
+
+div#cgit table.blame td.hashes,
+div#cgit table.blame td.lines,
+div#cgit table.blame td.linenumbers {
+	padding: 0;
+}
+
+div#cgit table.blame td.hashes div.alt,
+div#cgit table.blame td.lines div.alt {
+	padding: 0 0.5em 0 0.5em;
+}
+
+div#cgit table.blame td.linenumbers div.alt {
+	padding: 0 0.5em 0 0;
+}
+
+div#cgit table.blame div.alt:nth-child(even) {
+	background: #eee;
+}
+
+div#cgit table.blame div.alt:nth-child(odd) {
+	background: white;
+}
+
+div#cgit table.blame td.lines > div {
+	position: relative;
+}
+
+div#cgit table.blame td.lines > div > pre {
+	padding: 0 0 0 0.5em;
+	position: absolute;
+	top: 0;
+}
+
+div#cgit table.blame .oid {
+	font-size: 100%;
+}
+
+div#cgit table.bin-blob {
+	margin-top: 0.5em;
+	border: solid 1px black;
+}
+
+div#cgit table.bin-blob th {
+	font-family: monospace;
+	white-space: pre;
+	border: solid 1px #777;
+	padding: 0.5em 1em;
+}
+
+div#cgit table.bin-blob td {
+	font-family: monospace;
+	white-space: pre;
+	border-left: solid 1px #777;
+	padding: 0em 1em;
+}
+
+div#cgit table.nowrap td {
+	white-space: nowrap;
+}
+
+div#cgit table.commit-info {
+	border-collapse: collapse;
+	margin-top: 1.5em;
+}
+
+div#cgit div.cgit-panel {
+	float: right;
+	margin-top: 1.5em;
+}
+
+div#cgit div.cgit-panel table {
+	border-collapse: collapse;
+	border: solid 1px #aaa;
+	background-color: #eee;
+}
+
+div#cgit div.cgit-panel th {
+	text-align: center;
+}
+
+div#cgit div.cgit-panel td {
+	padding: 0.25em 0.5em;
+}
+
+div#cgit div.cgit-panel td.label {
+	padding-right: 0.5em;
+}
+
+div#cgit div.cgit-panel td.ctrl {
+	padding-left: 0.5em;
+}
+
+div#cgit table.commit-info th {
+	text-align: left;
+	font-weight: normal;
+	padding: 0.1em 1em 0.1em 0.1em;
+	vertical-align: top;
+}
+
+div#cgit table.commit-info td {
+	font-weight: normal;
+	padding: 0.1em 1em 0.1em 0.1em;
+}
+
+div#cgit div.commit-subject {
+	font-weight: bold;
+	font-size: 125%;
+	margin: 1.5em 0em 0.5em 0em;
+	padding: 0em;
+}
+
+div#cgit div.notes-header {
+	font-weight: bold;
+	padding-top: 1.5em;
+}
+
+div#cgit div.notes {
+	white-space: pre;
+	font-family: monospace;
+	border: solid 1px #ee9;
+	background-color: #ffd;
+	padding: 0.3em 2em 0.3em 1em;
+	float: left;
+}
+
+div#cgit div.notes-footer {
+	clear: left;
+}
+
+div#cgit div.diffstat-header {
+	font-weight: bold;
+	padding-top: 1.5em;
+}
+
+div#cgit table.diffstat {
+	border-collapse: collapse;
+	border: solid 1px #aaa;
+	background-color: #eee;
+}
+
+div#cgit table.diffstat th {
+	font-weight: normal;
+	text-align: left;
+	text-decoration: underline;
+	padding: 0.1em 1em 0.1em 0.1em;
+	font-size: 100%;
+}
+
+div#cgit table.diffstat td {
+	padding: 0.2em 0.2em 0.1em 0.1em;
+	font-size: 100%;
+	border: none;
+}
+
+div#cgit table.diffstat td.mode {
+	white-space: nowrap;
+}
+
+div#cgit table.diffstat td span.modechange {
+	padding-left: 1em;
+	color: red;
+}
+
+div#cgit table.diffstat td.add a {
+	color: green;
+}
+
+div#cgit table.diffstat td.del a {
+	color: red;
+}
+
+div#cgit table.diffstat td.upd a {
+	color: blue;
+}
+
+div#cgit table.diffstat td.graph {
+	width: 500px;
+	vertical-align: middle;
+}
+
+div#cgit table.diffstat td.graph table {
+	border: none;
+}
+
+div#cgit table.diffstat td.graph td {
+	padding: 0px;
+	border: 0px;
+	height: 7pt;
+}
+
+div#cgit table.diffstat td.graph td.add {
+	background-color: #5c5;
+}
+
+div#cgit table.diffstat td.graph td.rem {
+	background-color: #c55;
+}
+
+div#cgit div.diffstat-summary {
+	color: #888;
+	padding-top: 0.5em;
+}
+
+div#cgit table.diff {
+	width: 100%;
+}
+
+div#cgit table.diff td span.head {
+	font-weight: bold;
+	color: black;
+}
+
+div#cgit table.diff td span.hunk {
+	color: #009;
+}
+
+div#cgit table.diff td span.add {
+	color: green;
+}
+
+div#cgit table.diff td span.del {
+	color: red;
+}
+
+div#cgit .oid {
+	font-family: monospace;
+	font-size: 90%;
+}
+
+div#cgit .left {
+	text-align: left;
+}
+
+div#cgit .right {
+	text-align: right;
+}
+
+div#cgit table.list td.reposection {
+	font-style: italic;
+	color: #888;
+}
+
+div#cgit a.button {
+	font-size: 80%;
+}
+
+div#cgit a.primary {
+	font-size: 100%;
+}
+
+div#cgit a.secondary {
+	font-size: 90%;
+}
+
+div#cgit td.toplevel-repo {
+
+}
+
+div#cgit table.list td.sublevel-repo {
+	padding-left: 1.5em;
+}
+
+div#cgit ul.pager {
+	list-style-type: none;
+	text-align: center;
+	margin: 1em 0em 0em 0em;
+	padding: 0;
+}
+
+div#cgit ul.pager li {
+	display: inline-block;
+	margin: 0.25em 0.5em;
+}
+
+div#cgit ul.pager a {
+	color: #777;
+}
+
+div#cgit ul.pager .current {
+	font-weight: bold;
+}
+
+div#cgit span.age-mins {
+	font-weight: bold;
+	color: #080;
+}
+
+div#cgit span.age-hours {
+	color: #080;
+}
+
+div#cgit span.age-days {
+	color: #040;
+}
+
+div#cgit span.age-weeks {
+	color: #444;
+}
+
+div#cgit span.age-months {
+	color: #888;
+}
+
+div#cgit span.age-years {
+	color: #bbb;
+}
+
+div#cgit span.insertions {
+	color: #080;
+}
+
+div#cgit span.deletions {
+	color: #800;
+}
+
+div#cgit div.footer {
+	margin-top: 0.5em;
+	text-align: center;
+	font-size: 80%;
+	color: #ccc;
+}
+
+div#cgit div.footer a {
+	color: #ccc;
+	text-decoration: none;
+}
+
+div#cgit div.footer a:hover {
+	text-decoration: underline;
+}
+
+div#cgit a.branch-deco {
+	color: #000;
+	padding: 0px 0.25em;
+	background-color: #88ff88;
+	border: solid 1px #007700;
+}
+
+div#cgit a.tag-deco {
+	color: #000;
+	padding: 0px 0.25em;
+	background-color: #ffff88;
+	border: solid 1px #777700;
+}
+
+div#cgit a.tag-annotated-deco {
+	color: #000;
+	padding: 0px 0.25em;
+	background-color: #ffcc88;
+	border: solid 1px #777700;
+}
+
+div#cgit a.remote-deco {
+	color: #000;
+	padding: 0px 0.25em;
+	background-color: #ccccff;
+	border: solid 1px #000077;
+}
+
+div#cgit a.deco {
+	color: #000;
+	padding: 0px 0.25em;
+	background-color: #ff8888;
+	border: solid 1px #770000;
+}
+
+div#cgit div.commit-subject a.branch-deco,
+div#cgit div.commit-subject a.tag-deco,
+div#cgit div.commit-subject a.tag-annotated-deco,
+div#cgit div.commit-subject a.remote-deco,
+div#cgit div.commit-subject a.deco {
+	font-size: 75%;
+}
+
+div#cgit table.stats {
+	border: solid 1px black;
+	border-collapse: collapse;
+}
+
+div#cgit table.stats th {
+	text-align: left;
+	padding: 1px 0.5em;
+	background-color: #eee;
+	border: solid 1px black;
+}
+
+div#cgit table.stats td {
+	text-align: right;
+	padding: 1px 0.5em;
+	border: solid 1px black;
+}
+
+div#cgit table.stats td.total {
+	font-weight: bold;
+	text-align: left;
+}
+
+div#cgit table.stats td.sum {
+	color: #c00;
+	font-weight: bold;
+/*	background-color: #eee; */
+}
+
+div#cgit table.stats td.left {
+	text-align: left;
+}
+
+div#cgit table.vgraph {
+	border-collapse: separate;
+	border: solid 1px black;
+	height: 200px;
+}
+
+div#cgit table.vgraph th {
+	background-color: #eee;
+	font-weight: bold;
+	border: solid 1px white;
+	padding: 1px 0.5em;
+}
+
+div#cgit table.vgraph td {
+	vertical-align: bottom;
+	padding: 0px 10px;
+}
+
+div#cgit table.vgraph div.bar {
+	background-color: #eee;
+}
+
+div#cgit table.hgraph {
+	border: solid 1px black;
+	width: 800px;
+}
+
+div#cgit table.hgraph th {
+	background-color: #eee;
+	font-weight: bold;
+	border: solid 1px black;
+	padding: 1px 0.5em;
+}
+
+div#cgit table.hgraph td {
+	vertical-align: middle;
+	padding: 2px 2px;
+}
+
+div#cgit table.hgraph div.bar {
+	background-color: #eee;
+	height: 1em;
+}
+
+div#cgit table.ssdiff {
+	width: 100%;
+}
+
+div#cgit table.ssdiff td {
+	font-size: 75%;
+	font-family: monospace;
+	white-space: pre;
+	padding: 1px 4px 1px 4px;
+	border-left: solid 1px #aaa;
+	border-right: solid 1px #aaa;
+}
+
+div#cgit table.ssdiff td.add {
+	color: black;
+	background: #cfc;
+	min-width: 50%;
+}
+
+div#cgit table.ssdiff td.add_dark {
+	color: black;
+	background: #aca;
+	min-width: 50%;
+}
+
+div#cgit table.ssdiff span.add {
+	background: #cfc;
+	font-weight: bold;
+}
+
+div#cgit table.ssdiff td.del {
+	color: black;
+	background: #fcc;
+	min-width: 50%;
+}
+
+div#cgit table.ssdiff td.del_dark {
+	color: black;
+	background: #caa;
+	min-width: 50%;
+}
+
+div#cgit table.ssdiff span.del {
+	background: #fcc;
+	font-weight: bold;
+}
+
+div#cgit table.ssdiff td.changed {
+	color: black;
+	background: #ffc;
+	min-width: 50%;
+}
+
+div#cgit table.ssdiff td.changed_dark {
+	color: black;
+	background: #cca;
+	min-width: 50%;
+}
+
+div#cgit table.ssdiff td.lineno {
+	color: black;
+	background: #eee;
+	text-align: right;
+	width: 3em;
+	min-width: 3em;
+}
+
+div#cgit table.ssdiff td.hunk {
+	color: black;
+	background: #ccf;
+	border-top: solid 1px #aaa;
+	border-bottom: solid 1px #aaa;
+}
+
+div#cgit table.ssdiff td.head {
+	border-top: solid 1px #aaa;
+	border-bottom: solid 1px #aaa;
+}
+
+div#cgit table.ssdiff td.head div.head {
+	font-weight: bold;
+	color: black;
+}
+
+div#cgit table.ssdiff td.foot {
+	border-top: solid 1px #aaa;
+	border-left: none;
+	border-right: none;
+	border-bottom: none;
+}
+
+div#cgit table.ssdiff td.space {
+	border: none;
+}
+
+div#cgit table.ssdiff td.space div {
+	min-height: 3em;
+}
+
+/*
+ * BEGIN exotic.sh MODIFICATIONS
+ */
+
+* { line-height: 1.25em; }
+
+div#cgit {
+	margin: auto;
+	font-family: monospace;
+	display: table;
+}
+
+div#cgit table#header td.logo {
+	display: none;
+}
+
+div#cgit table#header td.main {
+	font-size: 1.2em;
+	font-weight: bold;
+	/*padding-left: 10px;*/
+}
+
+div#cgit table#header td.sub {
+	border-top: none;
+}
+
+
+div#cgit table.tabs {
+	border-bottom: none;
+}
+
+
+div#cgit table.list th a {
+	color: inherit;
+}
+
+div#cgit table.list tr:nth-child(even) {
+	background: inherit;
+}
+
+div#cgit table.list tr:hover {
+  background: inherit;
+}
+div#cgit table.list tr.nohover-highlight:hover:nth-child(even) {
+  background: inherit;
+}
+
+div#cgit table.blob td.linenumbers:nth-last-child(3) {
+  display: none;
+}
+
+div#cgit div.content {
+	min-width: 60em;
+	border-bottom: none;
+}
+
+div#cgit div.content div#summary {
+	display: table;
+	margin-left: auto;
+	margin-right: auto;
+}
+
+div#cgit table.blob td.linenumbers a:target {
+  color: goldenrod;
+  text-decoration: underline;
+  outline: none;
+}
+
+div#cgit div.footer {
+	font-size: 1em;
+}
diff --git a/modules/services/cgit/default.nix b/modules/services/cgit/default.nix
new file mode 100644
index 0000000..7ee279f
--- /dev/null
+++ b/modules/services/cgit/default.nix
@@ -0,0 +1,129 @@
+{ 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";
+            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-exotic.css" = {
+          alias = "${./cgit-exotic.css}";
+          extraConfig = ''
+            # add_header Cache-Control "public, max-age=14400, must-revalidate";
+          '';
+        };
+        "@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.settings.title}
+      root-desc=${cfg.settings.description}
+
+      css=/cgit-exotic.css
+
+      snapshots=tar.gz zip
+
+      enable-git-config=1
+      remove-suffix=1
+
+      enable-index-links=1
+      enable-index-owner=0
+      enable-git-clone=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/cinny-web.nix b/modules/services/cinny-web.nix
new file mode 100644
index 0000000..e796ff8
--- /dev/null
+++ b/modules/services/cinny-web.nix
@@ -0,0 +1,34 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.cinny-web;
+in
+{
+  options.modules.services.cinny-web = {
+    enable = mkEnableOption "cinny-web";
+    package = mkOption { type = types.package; default = pkgs.cinny; };
+    hostName = mkOption { type = types.str; default = config.networking.hostName; };
+    matrix.serverName = mkOption { type = types.str; default = config.networking.hostName; };
+    tls.acmeHost = mkOption { type = types.str; default = cfg.hostName; };
+  };
+
+  config = mkIf cfg.enable {
+    services.nginx.virtualHosts.${cfg.hostName} = {
+      useACMEHost = cfg.tls.acmeHost;
+      forceSSL = true;
+
+      root = cfg.package.override {
+        conf = {
+          # Index of the default homeserver from `homeserverList`
+          defaultHomeserver = 0;
+          homeserverList = [ cfg.matrix.serverName ];
+        };
+      };
+
+      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/coredns/_corefile.nix b/modules/services/coredns/_corefile.nix
new file mode 100644
index 0000000..84e2165
--- /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/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..281ca11
--- /dev/null
+++ b/modules/services/fail2ban.nix
@@ -0,0 +1,20 @@
+{ 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;
+    };
+    modules.persistence.directories = [
+      "/var/lib/fail2ban"
+    ];
+  };
+}
diff --git a/modules/services/git-daemon/_git-daemon-module.nix b/modules/services/git-daemon/_git-daemon-module.nix
new file mode 100644
index 0000000..76b395e
--- /dev/null
+++ b/modules/services/git-daemon/_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/modules/services/git-daemon/default.nix b/modules/services/git-daemon/default.nix
new file mode 100644
index 0000000..800cd3c
--- /dev/null
+++ b/modules/services/git-daemon/default.nix
@@ -0,0 +1,31 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.gitDaemon;
+in
+{
+  # TODO: uncouple with gitolite
+
+  disabledModules = [
+    "services/networking/git-daemon.nix"
+  ];
+
+  imports = [
+    ./_git-daemon-module.nix
+  ];
+
+  options.modules.services.gitDaemon = {
+    enable = mkEnableOption "git daemon";
+  };
+
+  config = mkIf cfg.enable {
+    services.gitDaemon = {
+      enable = true;
+      createUserAndGroup = false;
+      basePath = config.services.gitolite.dataDir + "/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..31cf755
--- /dev/null
+++ b/modules/services/gitolite/default.nix
@@ -0,0 +1,110 @@
+{ 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 reasons, $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');
+
+      '';
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/gitolite"
+    ];
+
+    system.activationScripts.gitolite-create-local = ''
+      mkdir -p /var/lib/gitolite/local/triggers
+      mkdir -p /var/lib/gitolite/local/commands
+      mkdir -p /var/lib/gitolite/local/hooks/common
+      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}"
+      "C /var/lib/gitolite/local/hooks/common/post-receive 755 - - - ${./post-receive}"
+    ];
+
+
+    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/post-receive b/modules/services/gitolite/post-receive
new file mode 100644
index 0000000..2f72ae9
--- /dev/null
+++ b/modules/services/gitolite/post-receive
@@ -0,0 +1,19 @@
+#!/bin/sh
+#
+# An example hook to update the "agefile" for CGit's idle time calculation.
+#
+# This hook assumes that you are using the default agefile location of
+# "info/web/last-modified".  If you change the value in your cgitrc then you
+# must also change it here.
+#
+# To install the hook, copy (or link) it to the file "hooks/post-receive" in
+# each of your repositories.
+#
+
+agefile="$(git rev-parse --git-dir)"/info/web/last-modified
+
+mkdir -p "$(dirname "$agefile")" &&
+git for-each-ref \
+	--sort=-authordate --count=1 \
+	--format='%(authordate:iso8601)' \
+	>"$agefile"
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..1152ac0
--- /dev/null
+++ b/modules/services/jitsi.nix
@@ -0,0 +1,43 @@
+{ 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 ];
+
+    modules.persistence.directories = [
+      "/var/lib/prosody"
+      "/var/lib/jitsi-meet"
+    ];
+  };
+}
diff --git a/modules/services/ldap.nix b/modules/services/ldap.nix
new file mode 100644
index 0000000..e75d739
--- /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.path; 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" ];
+
+    modules.persistence.directories = [
+      "/var/lib/openldap"
+    ];
+  };
+}
diff --git a/modules/services/matrix-bridge/_mautrix-discord-module.nix b/modules/services/matrix-bridge/_mautrix-discord-module.nix
new file mode 100644
index 0000000..36cafe6
--- /dev/null
+++ b/modules/services/matrix-bridge/_mautrix-discord-module.nix
@@ -0,0 +1,205 @@
+{ config, pkgs, lib, ... }:
+
+with lib;
+
+let
+  dataDir = "/var/lib/mautrix-discord";
+  registrationFile = "${dataDir}/discord-registration.yaml";
+  cfg = config.services.mautrix-discord;
+  settingsFormat = pkgs.formats.json { };
+  settingsFile = "${dataDir}/config.json";
+  settingsFileUnsubstituted = settingsFormat.generate "mautrix-discord-config.json" cfg.settings;
+
+in
+{
+  options = {
+    services.mautrix-discord = {
+      enable = mkEnableOption (lib.mdDoc "Mautrix-discord, a Matrix-discord puppeting bridge.");
+
+      package = mkOption { type = types.package; default = pkgs.mautrix-discord; };
+
+      settings = mkOption rec {
+        apply = recursiveUpdate default;
+        inherit (settingsFormat) type;
+        default = {
+          homeserver = {
+            software = "standard";
+          };
+
+          appservice = rec {
+            database = "sqlite:///${dataDir}/mautrix-discord.db";
+            database_opts = { };
+            hostname = "localhost";
+            port = 8080;
+            address = "http://localhost:${toString port}";
+          };
+
+          bridge = {
+            permissions."*" = "relay";
+            relay.whitelist = [ ];
+            double_puppet_server_map = { };
+            login_shared_secret_map = { };
+          };
+
+          logging = {
+            min_level = "debug";
+            writers = [
+              {
+                type = "stdout";
+                format = "pretty-colored";
+              }
+            ];
+          };
+        };
+        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/discord/blob/master/discord/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-discord service,
+          in which secret tokens can be specified securely by defining values for e.g.
+          `MAUTRIX_DISCORD_APPSERVICE_AS_TOKEN`,
+          `MAUTRIX_DISCORD_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_DISCORD_`.
+          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_DISCORD_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 {
+    users.users.mautrix-discord = {
+      isSystemUser = true;
+      group = "mautrix-discord";
+      home = dataDir;
+      description = "Mautrix-Discord bridge user";
+    };
+
+    users.groups.mautrix-discord = {};
+
+    systemd.services.mautrix-discord = {
+      description = "Mautrix-discord, a Matrix-discord puppeting bridge.";
+
+      wantedBy = [ "multi-user.target" ];
+      wants = [ "network-online.target" ] ++ cfg.serviceDependencies;
+      after = [ "network-online.target" ] ++ cfg.serviceDependencies;
+
+      preStart = ''
+        # substitute the settings file by environment variables
+        # in this case read from EnvironmentFile
+        test -f '${settingsFile}' && rm -f '${settingsFile}'
+        old_umask=$(umask)
+        umask 0177
+        ${pkgs.envsubst}/bin/envsubst \
+          -o '${settingsFile}' \
+          -i '${settingsFileUnsubstituted}' \
+        umask $old_umask
+
+        # generate the appservice's registration file if absent
+        if [ ! -f '${registrationFile}' ]; then
+          ${pkgs.mautrix-discord}/bin/mautrix-discord \
+            --generate-registration \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        fi
+        chmod 640 ${registrationFile}
+
+        umask 0177
+        ${pkgs.yq}/bin/yq -s '.[0].appservice.as_token = .[1].as_token
+          | .[0].appservice.hs_token = .[1].hs_token
+          | .[0]' '${settingsFile}' '${registrationFile}' \
+          > '${settingsFile}.tmp'
+        mv '${settingsFile}.tmp' '${settingsFile}'
+        umask $old_umask
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "on-failure";
+
+        LockPersonality = true;
+        MemoryDenyWriteExecute = true;
+        NoNewPrivileges = true;
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectKernelLogs = true;
+        ProtectControlGroups = true;
+        ProtectClock = true;
+        ProtectHostname = true;
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+
+        SystemCallArchitectures = "native";
+        SystemCallErrorNumber = "EPERM";
+        SystemCallFilter = ["@system-service"];
+
+        PrivateTmp = true;
+        PrivateDevices = true;
+        PrivateUsers = true;
+
+        User = "mautrix-discord";
+        Group = "mautrix-discord";
+        WorkingDirectory = dataDir;
+        StateDirectory = baseNameOf dataDir;
+        UMask = "0027";
+        EnvironmentFile = cfg.environmentFile;
+
+        ExecStart = ''
+          ${cfg.package}/bin/mautrix-discord \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        '';
+      };
+      restartTriggers = [settingsFileUnsubstituted];
+    };
+  };
+}
diff --git a/modules/services/matrix-bridge/_mautrix-signal-module.nix b/modules/services/matrix-bridge/_mautrix-signal-module.nix
new file mode 100644
index 0000000..983d635
--- /dev/null
+++ b/modules/services/matrix-bridge/_mautrix-signal-module.nix
@@ -0,0 +1,204 @@
+{ 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 = "${dataDir}/config.json";
+  settingsFileUnsubstituted =
+    settingsFormat.generate "mautrix-signal-config.json" cfg.settings;
+
+in
+{
+  # NOTE(2024-01-11): Upstream has been moved to a Go version.
+  # Environment-based credential setting might not work.
+  options = {
+    services.mautrix-signal = {
+      enable = mkEnableOption (lib.mdDoc "Mautrix-Signal, a Matrix-Signal puppeting bridge.");
+
+      package = mkOption { type = types.package; default = pkgs.mautrix-signal; };
+
+      settings = mkOption rec {
+        apply = recursiveUpdate default;
+        inherit (settingsFormat) type;
+        default = {
+          homeserver = {
+            software = "standard";
+          };
+
+          appservice = rec {
+            database = "sqlite:///${dataDir}/mautrix-signal.db";
+            database_opts = { };
+            hostname = "localhost";
+            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 = {
+            min_level = "debug";
+            writers = [
+              {
+                type = "stdout";
+                format = "pretty-colored";
+              }
+            ];
+          };
+        };
+        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 ];
+
+      # TODO(2023-01-11): Still relevant in Go version?
+      # 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 = ''
+        # substitute the settings file by environment variables
+        # in this case read from EnvironmentFile
+        test -f '${settingsFile}' && rm -f '${settingsFile}'
+        old_umask=$(umask)
+        umask 0177
+        ${pkgs.envsubst}/bin/envsubst \
+          -o '${settingsFile}' \
+          -i '${settingsFileUnsubstituted}' \
+        umask $old_umask
+
+        # generate the appservice's registration file if absent
+        if [ ! -f '${registrationFile}' ]; then
+          ${cfg.package}/bin/mautrix-signal \
+            --generate-registration \
+            --config='${settingsFile}' \
+            --registration='${registrationFile}'
+        fi
+        chmod 640 ${registrationFile}
+
+        umask 0177
+        ${pkgs.yq}/bin/yq -s '.[0].appservice.as_token = .[1].as_token
+          | .[0].appservice.hs_token = .[1].hs_token
+          | .[0]' '${settingsFile}' '${registrationFile}' \
+          > '${settingsFile}.tmp'
+        mv '${settingsFile}.tmp' '${settingsFile}'
+        umask $old_umask
+      '';
+
+      serviceConfig = {
+        Type = "simple";
+        Restart = "always";
+
+        ProtectSystem = "strict";
+        ProtectHome = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+
+        DynamicUser = true;
+        SupplementaryGroups = [ "signald" ];
+        PrivateTmp = true;
+        WorkingDirectory = cfg.package; # necessary for the database migration scripts to be found
+        StateDirectory = baseNameOf dataDir;
+        UMask = "0027";
+        EnvironmentFile = cfg.environmentFile;
+
+        ExecStart = ''
+          ${cfg.package}/bin/mautrix-signal \
+            --config='${settingsFile}'
+        '';
+
+      restartTriggers = [settingsFileUnsubstituted];
+      };
+    };
+  };
+
+  # meta.maintainers = with maintainers; [ boppyt ];
+}
diff --git a/modules/services/matrix-bridge/default.nix b/modules/services/matrix-bridge/default.nix
new file mode 100644
index 0000000..4d53223
--- /dev/null
+++ b/modules/services/matrix-bridge/default.nix
@@ -0,0 +1,390 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.matrix-bridge;
+in
+{
+  imports = [
+    ./_mautrix-signal-module.nix
+    ./_mautrix-discord-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.path; description = "path to the mautrix-* environment file"; };
+  };
+
+  config = mkIf cfg.enable {
+    sops.secrets.double-puppet-as-token = { };
+    sops.secrets.double-puppet-hs-token = { };
+
+    sops.templates."double-puppet-registration.yaml".content = ''
+      id: doublepuppet
+      url:
+      as_token: ${config.sops.placeholder.double-puppet-as-token}
+      hs_token: ${config.sops.placeholder.double-puppet-hs-token}
+      sender_localpart: 55e126746dad19e50d9c4e646b6f5ac9ba21b346a24b840330cd8d8a1d65ce80
+      rate_limited: false
+      namespaces:
+        users:
+          - regex: '@.*:exotic\.sh'
+            exclusive: false
+    '';
+
+    services.mautrix-telegram = {
+      enable = true;
+
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "matrix-synapse.service" ];
+
+      settings = {
+        homeserver.address = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.verify_ssl = true;
+        appservice = {
+          address = "http://localhost:29317";
+          hostname = "localhost";
+          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;
+          };
+          encryption = {
+            allow = true;
+            default = true;
+            require = true;
+            allow_key_sharing = true;
+          };
+          # NOTE: python bridge - managed via env variable
+          # login_shared_secret_map = {
+          #   "${cfg.domain}" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
+          # };
+          permissions = {
+            "@sef:exotic.sh" = "admin";
+            "exotic.sh" = "full";
+          };
+        };
+      };
+    };
+
+    services.mautrix-signal = {
+      enable = true;
+      package = pkgs.mautrix-signal;
+
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "matrix-synapse.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;
+          };
+          encryption = {
+            allow = true;
+            default = true;
+            require = true;
+            allow_key_sharing = true;
+          };
+          login_shared_secret_map = {
+            "${cfg.domain}" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
+          };
+          permissions = {
+            "@sef:exotic.sh" = "admin";
+            "exotic.sh" = "full";
+          };
+        };
+      };
+    };
+
+    services.mautrix-whatsapp = {
+      enable = true;
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "matrix-synapse.service" ];
+
+      settings = {
+        homeserver.address = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.verify_ssl = true;
+        appservice = {
+          address = "http://localhost:29319";
+          hostname = "localhost";
+          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;
+          history_sync = {
+            backfill = false; # MSC2716
+            request_full_sync = true;
+          };
+          send_presence_on_typing = true;
+          double_puppet_server_map = { };
+          login_shared_secret_map = {
+            "${cfg.domain}" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
+          };
+          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;
+          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";
+          };
+        };
+      };
+    };
+
+    services.mautrix-discord = {
+      enable = true;
+      environmentFile = cfg.secrets.mautrix-envs;
+      serviceDependencies = [ "matrix-synapse.service" ];
+
+      settings = {
+        homeserver.address = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.verify_ssl = true;
+        appservice = {
+          address = "http://localhost:29320";
+          port = 29320;
+          database = {
+            type = "postgres";
+            uri = "postgres://mautrix-discord:@/mautrix-discord?host=/run/postgresql";
+          };
+          bot_avatar = "mxc://maunium.net/nIdEykemnwdisvHbpxflpDlC";
+          id = "discord";
+          max_body_size = 1;
+          provisioning.enabled = false;
+        };
+
+        bridge = {
+          username_template = "dsc_{{.}}";
+          delivery_receipts = true;
+          encryption = {
+            allow = true;
+            default = true;
+            require = true;
+            allow_key_sharing = true;
+          };
+          login_shared_secret_map = {
+            "${cfg.domain}" = "as_token:$DOUBLE_PUPPET_AS_TOKEN";
+          };
+          permissions = {
+            "@sef:exotic.sh" = "admin";
+            "exotic.sh" = "full";
+          };
+        };
+      };
+    };
+
+    services.matrix-appservice-irc = {
+      enable = true;
+      registrationUrl = "http://localhost:29321";
+      port = 29321;
+
+      settings = {
+        homeserver.url = "https://${cfg.realHost}";
+        homeserver.domain = cfg.domain;
+        homeserver.dropMatrixMessagesAfterSecs = 600; # 10 minutes
+
+        database.engine = "postgres";
+        database.connectionString = "postgres://matrix-appservice-irc:@/matrix-appservice-irc?host=/run/postgresql";
+
+        ircService.servers = let
+          # nix-community/nur-combined/repos/colinsane/hosts/by-name/servo/services/matrix/irc.nix@b2e96d5
+          ircServer = { name, additionalAddresses ? [], sasl ? true, port ? 6697}:
+            let lowerName = lib.toLower name;
+          in {
+            inherit name additionalAddresses sasl port;
+            ssl = true;
+            # Disable bridging of Matrix bots
+            botConfig.enabled = false;
+            dynamicChannels = {
+              enabled = true;
+              aliasTemplate = "#irc_${lowerName}_$CHANNEL";
+              published = false;
+              federate = false;
+            };
+            ircClients = {
+              nickTemplate = "$DISPLAY[m]";
+              allowNickChanges = true;
+              realNameFormat = "reverse-mxid";
+              lineLimit = 20;
+              # Safeguard: don't flood servers
+              maxClients = 2;
+              idleTimeout = 0;
+              concurrentReconnectLimit = 2;
+              reconnectIntervalMs = 60000;
+              kickOn = {
+                # only kick Matrix user from room when user quits
+                channelJoinFailure = false;
+                ircConnectionFailure = false;
+                userQuit = true;
+              };
+            };
+            matrixClients.userTemplate = "@irc_${lowerName}_$NICK";
+
+            "@sef:exotic.sh" = "admin";
+
+            memberShipLists = {
+              enabled = true;
+              # NOTE: when serving lots of Matrix users, these configs should
+              # be changed to reduce strain on IRC servers
+              global = {
+                ircToMatrix = {
+                  initial = true;
+                  incremental = true;
+                  requireMatrixJoined = false;
+                };
+                matrixToIrc = {
+                  initial = true;
+                  incremental = true;
+                };
+                # always bridge users, even if idle
+                ignoreIdleUsersOnStartup.enabled = false;
+              };
+              bridgeInfoState = {
+                enabled = true;
+                initial = true;
+              };
+            };
+          };
+        in {
+          "irc.libera.chat" = ircServer {
+            name = "libera";
+            # sasl = false;
+          };
+          "irc.oftc.net" = ircServer {
+            name = "oftc";
+            # sasl = false;
+          };
+        };
+      };
+    };
+
+    # HACK: https://github.com/NixOS/nixpkgs/issues/273929
+    systemd.services.matrix-appservice-irc.serviceConfig.SystemCallFilter = lib.mkForce ''
+      @system-service @pkey ~@privileged @resources @chown
+    '';
+
+
+    modules.persistence.directories = [
+      "/var/lib/private/mautrix-telegram"
+      "/var/lib/private/mautrix-signal"
+      "/var/lib/private/mautrix-whatsapp"
+      "/var/lib/private/mautrix-discord"
+      "/var/lib/matrix-appservice-irc"
+      "/var/lib/signald"
+    ];
+
+    modules.services.postgresql.enable = true;
+    services.postgresql.ensureDatabases = [ "mautrix-telegram" "mautrix-signal" "mautrix-whatsapp" "mautrix-discord" "matrix-appservice-irc" ];
+    services.postgresql.ensureUsers = [
+      {
+        name = "mautrix-telegram";
+        ensureDBOwnership = true;
+      }
+      {
+        name = "mautrix-signal";
+        ensureDBOwnership = true;
+      }
+      {
+        name = "mautrix-whatsapp";
+        ensureDBOwnership = true;
+      }
+      {
+        name = "mautrix-discord";
+        ensureDBOwnership = true;
+      }
+      {
+        name = "matrix-appservice-irc";
+        ensureDBOwnership = true;
+      }
+    ];
+
+    systemd.services.matrix-synapse.serviceConfig.LoadCredential = [
+      "mautrix-telegram:/var/lib/mautrix-telegram/telegram-registration.yaml"
+      "mautrix-signal:/var/lib/mautrix-signal/signal-registration.yaml"
+      "mautrix-whatsapp:/var/lib/mautrix-whatsapp/whatsapp-registration.yaml"
+      "mautrix-discord:/var/lib/mautrix-discord/discord-registration.yaml"
+      "double-puppet:${config.sops.templates."double-puppet-registration.yaml".path}"
+      "appservice-irc:/var/lib/matrix-appservice-irc/registration.yml"
+    ];
+
+    services.matrix-synapse.settings.app_service_config_files = [
+      "/run/credentials/matrix-synapse.service/mautrix-telegram"
+      "/run/credentials/matrix-synapse.service/mautrix-signal"
+      "/run/credentials/matrix-synapse.service/mautrix-whatsapp"
+      "/run/credentials/matrix-synapse.service/mautrix-discord"
+      "/run/credentials/matrix-synapse.service/double-puppet"
+      "/run/credentials/matrix-synapse.service/appservice-irc"
+    ];
+  };
+}
diff --git a/modules/services/matrix-homeserver.nix b/modules/services/matrix-homeserver.nix
new file mode 100644
index 0000000..3dc188b
--- /dev/null
+++ b/modules/services/matrix-homeserver.nix
@@ -0,0 +1,190 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.matrix-homeserver;
+
+  httpPort = 8008;
+  slidingSyncPort = 8009;
+  metricsPort = 8010;
+in
+{
+  options.modules.services.matrix-homeserver = {
+    enable = mkEnableOption "matrix homeserver 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 using TURN";
+      domain = mkOption { type = types.str; default = "turn.${cfg.domain}"; };
+      shared_secret = mkOption { type = types.str; };
+    };
+    secrets = {
+      matrix-server-key = mkOption { type = types.path; description = "path to the server key"; };
+      matrix-shared-secret = mkOption { type = types.path; description = "path to the registration shared secret"; };
+      extra-config-path = mkOption { type = types.nullOr types.path; description = "path to the extra configuration file to source"; };
+      sliding-sync-secret = mkOption { type = types.nullOr types.path; description = "path to the sliding sync secret"; };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    services.matrix-synapse = {
+      enable = true;
+      withJemalloc = true;
+      dataDir = "/var/lib/matrix-synapse";
+      settings = {
+        server_name = cfg.domain;
+        public_baseurl = "https://${cfg.realHost}";
+
+        signing_key_path = cfg.secrets.matrix-server-key;
+
+        allow_guest_access = false;
+        enable_registration = false;
+        registration_requires_token = true;
+        registration_shared_secret_path = cfg.secrets.matrix-shared-secret;
+
+        enable_metrics = true;
+        url_preview_enabled = true;
+
+        database = {
+          name = "psycopg2";
+          args.password = "synapse";
+        };
+
+        listeners = [
+          {
+            port = httpPort;
+            resources = [
+              {
+                compress = true;
+                names = [ "client" ];
+              }
+              {
+                compress = false;
+                names = [ "federation" ];
+              }
+            ];
+            type = "http";
+            tls = false;
+            x_forwarded = true;
+          }
+          {
+            port = metricsPort;
+            resources = [{
+              compress = false;
+              names = [ "metrics" ];
+            }];
+            type = "metrics";
+            tls = false;
+          }
+        ];
+
+        trusted_key_servers = [{
+          server_name = "matrix.org";
+          verify_keys = {
+            "ed25519:auto" = "Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw";
+          };
+        }];
+        # Yes, we want to use matrix.org as our trusted key server
+        suppress_key_server_warning = true;
+      } // optionalAttrs (cfg.turn.enable) {
+        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"
+        ];
+      };
+    };
+
+    services.matrix-sliding-sync = {
+      enable = true;
+      createDatabase = true;
+      settings = {
+        SYNCV3_SERVER = "https://${cfg.realHost}";
+        SYNCV3_BINDADDR = "[::1]:${toString slidingSyncPort}";
+      };
+      environmentFile = cfg.secrets.sliding-sync-secret;
+    };
+
+    services.prometheus.scrapeConfigs = [
+      {
+        job_name = "synapse";
+        metrics_path = "/_synapse/metrics";
+        static_configs = [{
+          targets = [ "127.0.0.1:${toString metricsPort}" ];
+        }];
+      }
+    ];
+
+    modules.persistence.directories = [
+      "/var/lib/matrix-synapse"
+    ];
+
+    services.postgresql.enable = true;
+    services.postgresql.initialScript = pkgs.writeText "synapse-init.sql" ''
+      CREATE ROLE "matrix-synapse" WITH LOGIN PASSWORD 'synapse';
+      CREATE DATABASE "matrix-synapse" WITH OWNER "matrix-synapse"
+       TEMPLATE template0
+       LC_COLLATE = "C"
+       LC_CTYPE = "C";
+    '';
+    services.nginx.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-Forwarded-For $remote_addr;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_read_timeout 600;
+        client_max_body_size ${config.services.matrix-synapse.settings.max_upload_size};
+      '';
+
+      locations."~* ^(\\/_matrix|\\/_synapse\\/client)".proxyPass = "http://[::1]:${toString 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.matrix-sliding-sync.settings.SYNCV3_BINDADDR}";
+    };
+
+    networking.firewall.allowedTCPPorts = [ 443 8448 ];
+  };
+}
diff --git a/modules/services/matrix-moderation.nix b/modules/services/matrix-moderation.nix
new file mode 100644
index 0000000..b44cdf3
--- /dev/null
+++ b/modules/services/matrix-moderation.nix
@@ -0,0 +1,58 @@
+{ config, lib, ... }:
+
+# TODO: rename
+
+with lib;
+let
+  cfg = config.modules.services.matrix-moderation;
+in
+{
+  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 = "http://127.0.0.1:8008";
+      pantalaimon.enable = true;
+      # NOTE: this option currently has no effect
+      pantalaimon.options = {
+        listenAddress = "127.0.0.1";
+        listenPort = 8011;
+      };
+      pantalaimon.username = "abuse";
+      pantalaimon.passwordFile = cfg.secrets.userPassword;
+      managementRoom = "#moderation:${cfg.domain}";
+
+      settings = {
+        # TODO: get rid of hardcoded values
+        homeserverUrl = "http://127.0.0.1:8011";
+        automaticallyRedactForReasons = [
+          "spam"
+          "advertising"
+          "unwanted"
+        ];
+      };
+    };
+
+    # TODO: get rid of hardcoded values
+    systemd.services.mjolnir.after = [ "matrix-synapse.service" ];
+
+    # Override the pantalaimon options, since the mjolnir one is broken
+    services.pantalaimon-headless.instances."mjolnir" = {
+      listenAddress = "127.0.0.1";
+      listenPort = 8011;
+    };
+
+    services.matrix-synapse.plugins = with config.services.matrix-synapse.package.plugins; [ matrix-synapse-mjolnir-antispam ];
+
+    modules.persistence.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..5f03389
--- /dev/null
+++ b/modules/services/metrics.nix
@@ -0,0 +1,169 @@
+{ 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; };
+    secrets.adminPassword = mkOption { type = types.path; description = "path to the admin password"; };
+  };
+
+  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";
+
+          retention_period = "120h";
+        };
+
+        chunk_store_config = {
+          max_look_back_period = "0s";
+        };
+
+        table_manager = {
+          retention_deletes_enabled = true;
+          retention_period = "120h";
+        };
+
+        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 = "$__file{${cfg.secrets.adminPassword}}";
+    };
+
+    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;
+        '';
+      };
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/prometheus2"
+      "/var/lib/loki"
+      "/var/lib/grafana"
+    ];
+  };
+}
+
diff --git a/modules/services/nebula.nix b/modules/services/nebula.nix
new file mode 100644
index 0000000..3f8c38a
--- /dev/null
+++ b/modules/services/nebula.nix
@@ -0,0 +1,18 @@
+{ config, options, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.nebula;
+in
+{
+  options.modules.services.nebula = {
+    enable = mkEnableOption "Configure a single-network Nebula";
+
+    networks = options.services.nebula.networks;
+  };
+
+  config = mkIf cfg.enable {
+    # The module is enabled when one or more module is enabled.
+    services.nebula.networks = cfg.networks;
+  };
+}
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..2c78780
--- /dev/null
+++ b/modules/services/nixos-mailserver.nix
@@ -0,0 +1,159 @@
+{ 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";
+    webmail = {
+      enable = mkOption {
+        type = types.bool;
+        default = false;
+        description = lib.mdDoc "Whether to enable roundcube webmail";
+      };
+      domain = mkOption { type = types.str; };
+      realHost = mkOption { type = types.str; };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    sops.secrets.sefidel-imap-pass = {
+      mode = "0440";
+      owner = "dovecot2";
+      group = "dovecot2";
+    };
+    sops.secrets.system-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
+        "b.barracudacentral.org"
+        "bl.spamcop.net"
+      ];
+      dnsBlacklistOverrides = ''
+        exotic.sh OK
+        sefidel.net OK
+        sefidel.com OK
+        192.168.0.0/16 OK
+      '';
+    };
+
+    # HACK: nixos-mailserver sets up reload hook on 'fqdn', which is 'mail.exotic.sh'.
+    # Since our cert is a wildcard cert with domain 'exotic.sh', it is excluded from the hook.
+    security.acme.certs."exotic.sh".reloadServices = [
+      "postfix.service"
+      "dovecot2.service"
+    ];
+
+    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" = {
+          catchAll = [ "sefidel.com" ];
+          hashedPasswordFile = config.sops.secrets.sefidel-imap-pass.path;
+        };
+        "contact@sefidel.net" = {
+          aliases = [ "sefidel" "dev@sefidel.net" "social@sefidel.net" "media@sefidel.net" "public@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" "system@nand.moe" ];
+          hashedPasswordFile = config.sops.secrets.system-imap-pass.path;
+        };
+        "internal@exotic.sh" = {
+          aliases = [ "internal" ];
+          hashedPasswordFile = config.sops.secrets.internal-imap-pass.path;
+        };
+      };
+      localDnsResolver = false;
+      certificateScheme = "manual";
+      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;
+    };
+
+    services.roundcube = mkIf cfg.webmail.enable {
+      enable = true;
+      hostName = cfg.webmail.realHost;
+      database.host = "localhost"; # use localDB, pgsql db/user creation is done automatically.
+
+      plugins = [
+        "archive"
+        "enigma"
+        "help"
+        "markasjunk"
+        "vcard_attachments"
+        "zipdownload"
+      ];
+
+      extraConfig = ''
+        # STARTTLS required for authn, therefore the domain must match the SSL cert.
+        $config['smtp_server'] = 'tls://${config.mailserver.fqdn}';
+      '';
+    };
+
+    services.nginx.virtualHosts.${cfg.webmail.realHost} = {
+      enableACME = mkForce false; # conflicts with useACMEHost
+      forceSSL = true;
+      useACMEHost = cfg.webmail.domain;
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/dovecot"
+      "/var/lib/rspamd"
+      "/var/lib/redis-rspamd"
+      "/var/vmail"
+      "/var/dkim"
+      "/var/sieve"
+      "/var/spool/mail"
+    ];
+
+    networking.firewall.allowedTCPPorts = [ 143 993 465 587 ];
+  };
+}
diff --git a/modules/services/obsidian-livesync.nix b/modules/services/obsidian-livesync.nix
new file mode 100644
index 0000000..189d92f
--- /dev/null
+++ b/modules/services/obsidian-livesync.nix
@@ -0,0 +1,63 @@
+  { config, lib, ... }:
+
+  with lib;
+  let
+    cfg = config.modules.services.obsidian-livesync;
+
+    port = 5984;
+  in
+  {
+    options.modules.services.obsidian-livesync = {
+      enable = mkEnableOption "obsidian-livesync server";
+
+      domain = mkOption { type = types.str; };
+      realHost = mkOption { type = types.str; default = "obsidian-livesync.${cfg.domain}"; };
+    };
+
+    config = mkIf cfg.enable {
+      services.couchdb = {
+        enable = true;
+        configFile = "/var/lib/couchdb/config";
+
+        extraConfig = ''
+          [couchdb]
+          single_node=true
+          max_document_size = 50000000
+
+          [admins]
+          admin = please-change-me
+
+          [chttpd]
+          require_valid_user = true
+          max_http_request_size = 4294967296
+          enable_cors = true
+
+          [chttpd_auth]
+          require_valid_user = true
+          authentication_redirect = /_utils/session.html
+
+          [httpd]
+          WWW-Authenticate = Basic realm="couchdb"
+          bind_address = 127.0.0.1
+          port = ${toString port}
+
+          [cors]
+          origins = app://obsidian.md, capacitor://localhost, http://localhost
+          credentials = true
+          headers = accept, authorization, content-type, origin, referer
+          methods = GET,PUT,POST,HEAD,DELETE
+          max_age = 3600
+      '';
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/couchdb"
+    ];
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      useACMEHost = cfg.domain;
+      forceSSL = true;
+      locations."/".proxyPass = "http://localhost:${toString port}";
+    };
+  };
+}
diff --git a/modules/services/postgresql.nix b/modules/services/postgresql.nix
new file mode 100644
index 0000000..05835a4
--- /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;
+
+    modules.persistence.directories = [
+      "/var/lib/postgresql"
+      "/var/backup/postgresql"
+    ];
+  };
+}
diff --git a/modules/services/rss.nix b/modules/services/rss.nix
new file mode 100644
index 0000000..c9663ee
--- /dev/null
+++ b/modules/services/rss.nix
@@ -0,0 +1,64 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.rss;
+in
+{
+  options.modules.services.rss = {
+    enable = mkEnableOption "RSS Aggregator";
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; default = "rss.${cfg.domain}"; };
+    secrets.admin-password = mkOption { type = types.path; description = "path to file containing admin password"; };
+    bridge = {
+      enable = mkEnableOption "RSS Bridge";
+      domain = mkOption { type = types.str; default = cfg.domain; };
+      realHost = mkOption { type = types.str; default = "rss-bridge.${cfg.bridge.domain}"; };
+      whitelist = mkOption { type = types.listOf types.str; default = []; };
+    };
+  };
+
+  config = mkIf cfg.enable (mkMerge [
+    {
+      services.freshrss = {
+        enable = true;
+        virtualHost = cfg.realHost;
+        baseUrl = "https://${cfg.realHost}";
+
+        defaultUser = "admin";
+        passwordFile = cfg.secrets.admin-password;
+
+        database = {
+          type = "pgsql";
+          host = "/run/postgresql";
+        };
+      };
+
+      modules.persistence.directories = [
+        "/var/lib/freshrss"
+      ];
+
+      services.nginx.virtualHosts.${cfg.realHost} = {
+        forceSSL = true;
+        useACMEHost = cfg.domain;
+      };
+    }
+    (mkIf cfg.bridge.enable {
+      services.rss-bridge = {
+        enable = true;
+        virtualHost = cfg.bridge.realHost;
+      } // optionalAttrs (cfg.bridge.whitelist != []) {
+        whitelist = cfg.bridge.whitelist;
+      };
+
+      modules.persistence.directories = [
+        "/var/lib/rss-bridge"
+      ];
+
+      services.nginx.virtualHosts.${cfg.bridge.realHost} = {
+        forceSSL = true;
+        useACMEHost = cfg.bridge.domain;
+      };
+     })
+  ]);
+}
diff --git a/modules/services/searx.nix b/modules/services/searx.nix
new file mode 100644
index 0000000..98f27d9
--- /dev/null
+++ b/modules/services/searx.nix
@@ -0,0 +1,50 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.searx;
+in
+{
+  options.modules.services.searx = {
+    enable = mkEnableOption "searx metasearch engine";
+    package = mkOption { type = types.package; default = pkgs.searxng; };
+    domain = mkOption { type = types.str; };
+    realHost = mkOption { type = types.str; };
+    secrets.searx-env = mkOption { type = types.path; description = "path to the searx secret envfile"; };
+  };
+
+  config = mkIf cfg.enable {
+    services.searx = {
+      enable = true;
+      package = cfg.package;
+      environmentFile = cfg.secrets.searx-env;
+      runInUwsgi = true;
+      settings = {
+        use_default_settings = true;
+
+        general.instance_name = "exotic.sh search";
+        server.secret_key = "@SEARX_SECRET_KEY@";
+      };
+      uwsgiConfig = {
+        socket = "/run/searx/searx.sock";
+        chmod-socket = "660";
+        cache2 = "name=searx_cache,items=2000,blocks=2000,blocksize=4096,bitmap=1";
+        disable-logging = true; # public service
+      };
+    };
+
+    users.extraUsers.nginx.extraGroups = [ "searx" ];
+
+    services.nginx.virtualHosts.${cfg.realHost} = {
+      forceSSL = true;
+      useACMEHost = cfg.domain;
+      locations."/".extraConfig = ''
+        proxy_set_header Host $host;
+        access_log off; # public service
+        uwsgi_pass unix:/run/searx/searx.sock;
+        include ${pkgs.nginx}/conf/uwsgi_params;
+      '';
+      locations."/static/".alias = "${cfg.package}/share/static/";
+    };
+  };
+}
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..19bb3e6
--- /dev/null
+++ b/modules/services/soju.nix
@@ -0,0 +1,40 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.soju;
+in
+{
+  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;
+      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 = {
+      serviceConfig.SupplementaryGroups = [ "acme" ];
+      after = [ "acme-finished-${cfg.tls.acmeHost}.target" ];
+    };
+
+    networking.firewall.allowedTCPPorts = [ cfg.port ];
+
+    modules.persistence.directories = [
+      "/var/lib/private/soju"
+    ];
+  };
+}
diff --git a/modules/services/tailscale.nix b/modules/services/tailscale.nix
new file mode 100644
index 0000000..97e1217
--- /dev/null
+++ b/modules/services/tailscale.nix
@@ -0,0 +1,22 @@
+{ config, lib, ... }:
+
+with lib;
+let
+  cfg = config.modules.services.tailscale;
+in
+{
+  options.modules.services.tailscale = {
+    enable = mkEnableOption "tailscale";
+  };
+
+  config = mkIf cfg.enable {
+    services.tailscale = {
+      enable = true;
+      useRoutingFeatures = "both";
+    };
+
+    modules.persistence.directories = [
+      "/var/lib/tailscale"
+    ];
+  };
+}
diff --git a/modules/services/vikunja.nix b/modules/services/vikunja.nix
new file mode 100644
index 0000000..c54870b
--- /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";
+        ensureDBOwnership = true;
+      }
+    ];
+
+    modules.persistence.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..2b6cdef
--- /dev/null
+++ b/modules/sops.nix
@@ -0,0 +1,19 @@
+{ config, lib, inputs, ... }:
+
+with lib;
+let
+  cfg = config.modules.sops;
+in
+{
+  imports = [ inputs.sops-nix.nixosModules.sops ];
+
+  options.modules.sops = {
+    enable = mkEnableOption "sops secret manager";
+
+    secretsDir = mkOption { type = lib.types.path; };
+  };
+
+  config = mkIf cfg.enable {
+    sops.defaultSopsFile = "${cfg.secretsDir}/secrets.yaml";
+  };
+}