diff --git a/configuration/default.nix b/configuration/default.nix
index c9c71ec..f874733 100644
--- a/configuration/default.nix
+++ b/configuration/default.nix
@@ -17,11 +17,12 @@
 
     ./services/backups.nix
     ./services/battery-manager.nix
-    ./services/conduit.nix
+    ./services/conduit
     ./services/crowdsec.nix
     ./services/foundryvtt.nix
     ./services/gitea.nix
     ./services/metrics
+    ./services/minecraft.nix
     ./services/nextcloud.nix
     ./services/webserver.nix
     ./services/wireguard.nix
@@ -70,8 +71,6 @@
         8448
         # starbound
         21025
-        # Minecraft
-        25565
 
         config.services.coturn.listening-port
         config.services.coturn.tls-listening-port
@@ -80,9 +79,6 @@
       ];
 
       allowedUDPPorts = [
-        # More minecraft
-        25565
-
         config.services.coturn.listening-port
         config.services.coturn.tls-listening-port
         config.services.coturn.alt-listening-port
diff --git a/configuration/services/conduit.nix b/configuration/services/conduit/default.nix
similarity index 62%
rename from configuration/services/conduit.nix
rename to configuration/services/conduit/default.nix
index 4e53241..c3803f4 100644
--- a/configuration/services/conduit.nix
+++ b/configuration/services/conduit/default.nix
@@ -1,5 +1,4 @@
 {
-  pkgs,
   config,
   lib,
   ...
@@ -12,6 +11,11 @@ let
   turn-realm = "turn.${config.services.nginx.domain}";
 in
 {
+  imports = [
+    ./heisenbridge.nix
+    ./matrix-hookshot.nix
+  ];
+
   services.matrix-conduit = {
     enable = true;
     settings.global = {
@@ -40,91 +44,6 @@ in
     };
   };
 
-  systemd.services.heisenbridge =
-    let
-      replaceSecretBin = "${pkgs.replace-secret}/bin/replace-secret";
-      registrationFile = builtins.toFile "heisenbridge-registration.yaml" (
-        builtins.toJSON {
-          id = "heisenbridge";
-          url = "http://127.0.0.1:9898";
-          as_token = "@AS_TOKEN@";
-          hs_token = "@HS_TOKEN@";
-          rate_limited = false;
-          sender_localpart = "heisenbridge";
-          namespaces = {
-            users = [
-              {
-                regex = "@irc_.*";
-                exclusive = true;
-              }
-              {
-                regex = "@heisenbridge:.*";
-                exclusive = true;
-              }
-            ];
-            aliases = [ ];
-            rooms = [ ];
-          };
-        }
-      );
-
-      # TODO(tlater): Starting with systemd 253 it will become possible
-      # to do the credential setup as part of ExecStartPre/preStart
-      # instead.
-      #
-      # This will also make it possible to actually set caps on the
-      # heisenbridge process using systemd, so that we can run the
-      # identd process.
-      execScript = pkgs.writeShellScript "heisenbridge" ''
-        cp ${registrationFile} "$RUNTIME_DIRECTORY/heisenbridge-registration.yaml"
-        chmod 600 $RUNTIME_DIRECTORY/heisenbridge-registration.yaml
-        ${replaceSecretBin} '@AS_TOKEN@' "$CREDENTIALS_DIRECTORY/heisenbridge_as-token" "$RUNTIME_DIRECTORY/heisenbridge-registration.yaml"
-        ${replaceSecretBin} '@HS_TOKEN@' "$CREDENTIALS_DIRECTORY/heisenbridge_hs-token" "$RUNTIME_DIRECTORY/heisenbridge-registration.yaml"
-        chmod 400 $RUNTIME_DIRECTORY/heisenbridge-registration.yaml
-
-        ${pkgs.heisenbridge}/bin/heisenbridge \
-            --config $RUNTIME_DIRECTORY/heisenbridge-registration.yaml \
-            --owner @tlater:matrix.tlater.net \
-            'http://localhost:${toString cfg.settings.global.port}'
-      '';
-    in
-    {
-      description = "Matrix<->IRC bridge";
-      wantedBy = [ "multi-user.target" ];
-      after = [ "conduit.service" ];
-
-      serviceConfig = {
-        Type = "simple";
-
-        LoadCredential = "heisenbridge:/run/secrets/heisenbridge";
-
-        ExecStart = execScript;
-
-        DynamicUser = true;
-        RuntimeDirectory = "heisenbridge";
-        RuntimeDirectoryMode = "0700";
-
-        RestrictNamespaces = true;
-        PrivateUsers = true;
-        ProtectHostname = true;
-        ProtectClock = true;
-        ProtectKernelTunables = true;
-        ProtectKernelModules = true;
-        ProtectKernelLogs = true;
-        ProtectControlGroups = true;
-        RestrictAddressFamilies = [ "AF_INET AF_INET6" ];
-        LockPersonality = true;
-        RestrictRealtime = true;
-        ProtectProc = "invisible";
-        ProcSubset = "pid";
-        UMask = 77;
-
-        # For the identd port
-        # CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];
-        # AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
-      };
-    };
-
   # Pass in the TURN secret via EnvironmentFile, not supported by
   # upstream module currently.
   #
diff --git a/configuration/services/conduit/heisenbridge.nix b/configuration/services/conduit/heisenbridge.nix
new file mode 100644
index 0000000..4243ae8
--- /dev/null
+++ b/configuration/services/conduit/heisenbridge.nix
@@ -0,0 +1,78 @@
+{
+  pkgs,
+  lib,
+  config,
+  ...
+}:
+let
+  conduitCfg = config.services.matrix-conduit;
+  matrixLib = pkgs.callPackage ./lib.nix { };
+in
+{
+  systemd.services.heisenbridge =
+    let
+      registration = matrixLib.writeRegistrationScript {
+        id = "heisenbridge";
+        url = "http://127.0.0.1:9898";
+        sender_localpart = "heisenbridge";
+
+        namespaces = {
+          users = [
+            {
+              regex = "@irc_.*";
+              exclusive = true;
+            }
+            {
+              regex = "@heisenbridge:.*";
+              exclusive = true;
+            }
+          ];
+
+          aliases = [ ];
+          rooms = [ ];
+        };
+      };
+    in
+    {
+      description = "Matrix<->IRC bridge";
+      wantedBy = [ "multi-user.target" ];
+      after = [ "conduit.service" ];
+
+      serviceConfig = {
+        Type = "exec";
+
+        LoadCredential = "heisenbridge:/run/secrets/heisenbridge";
+
+        inherit (registration) ExecStartPre;
+        ExecStart = lib.concatStringsSep " " [
+          "${lib.getExe pkgs.heisenbridge}"
+          "--config ${registration.runtimeRegistration}"
+          "--owner @tlater:matrix.tlater.net"
+          "http://localhost:${toString conduitCfg.settings.global.port}"
+        ];
+
+        DynamicUser = true;
+        RuntimeDirectory = "heisenbridge";
+        RuntimeDirectoryMode = "0700";
+
+        RestrictNamespaces = true;
+        PrivateUsers = true;
+        ProtectHostname = true;
+        ProtectClock = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectKernelLogs = true;
+        ProtectControlGroups = true;
+        RestrictAddressFamilies = [ "AF_INET AF_INET6" ];
+        LockPersonality = true;
+        RestrictRealtime = true;
+        ProtectProc = "invisible";
+        ProcSubset = "pid";
+        UMask = 77;
+
+        # For the identd port
+        # CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
+        # AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
+      };
+    };
+}
diff --git a/configuration/services/conduit/lib.nix b/configuration/services/conduit/lib.nix
new file mode 100644
index 0000000..8ff377d
--- /dev/null
+++ b/configuration/services/conduit/lib.nix
@@ -0,0 +1,63 @@
+{
+  lib,
+  writeShellScript,
+  formats,
+  replace-secret,
+}:
+let
+  replaceSecretBin = "${lib.getExe replace-secret}";
+in
+{
+  # Write a script that will set up the service's registration.yaml
+  # with secrets from systemd credentials.
+  #
+  # The credentials should be named `${id}_as-token` and
+  # `${id}_hs-token`.
+  #
+  # This registration file needs to be manually added to conduit by
+  # messaging the admin with the yaml file.
+  writeRegistrationScript =
+    {
+      id, # Must be unique among all registered appservices/bots
+      url, # The URL on which the service listens
+      sender_localpart,
+      rate_limited ? false,
+      namespaces ? {
+        aliases = [ ];
+        rooms = [ ];
+        users = [ ];
+      },
+      extraSettings ? { },
+      # The location to place the file; assumes systemd runtime dir
+      runtimeRegistration ? "$RUNTIME_DIRECTORY/${id}-registration.yaml",
+    }:
+    let
+      registrationFile =
+        (formats.yaml { }).generate "${id}-registration.yaml" {
+          inherit
+            id
+            url
+            sender_localpart
+            rate_limited
+            namespaces
+            ;
+
+          as_token = "@AS_TOKEN@";
+          hs_token = "@HS_TOKEN@";
+        }
+        // extraSettings;
+    in
+    {
+      inherit runtimeRegistration;
+      ExecStartPre = writeShellScript "${id}-registration-setup.sh" ''
+        cp -f ${registrationFile} "${runtimeRegistration}"
+        chmod 600 "${runtimeRegistration}"
+
+        # Write actual secrets into config
+        ${replaceSecretBin} '@AS_TOKEN@' "$CREDENTIALS_DIRECTORY/${id}_as-token" "${runtimeRegistration}"
+        ${replaceSecretBin} '@HS_TOKEN@' "$CREDENTIALS_DIRECTORY/${id}_hs-token" "${runtimeRegistration}"
+
+        chmod 400 "${runtimeRegistration}"
+      '';
+    };
+}
diff --git a/configuration/services/conduit/matrix-hookshot.nix b/configuration/services/conduit/matrix-hookshot.nix
new file mode 100644
index 0000000..33da8ec
--- /dev/null
+++ b/configuration/services/conduit/matrix-hookshot.nix
@@ -0,0 +1,142 @@
+{
+  pkgs,
+  lib,
+  config,
+  ...
+}:
+let
+  matrixLib = pkgs.callPackage ./lib.nix { };
+
+  cfg = config.services.matrix-hookshot;
+  conduitCfg = config.services.matrix-conduit;
+
+  address = "${config.services.matrix-hookshot.settings.bridge.bindAddress}";
+  domain = "matrix.${config.services.nginx.domain}";
+  port = config.services.matrix-hookshot.settings.bridge.port;
+
+  registration = matrixLib.writeRegistrationScript {
+    id = "matrix-hookshot";
+    url = "${address}:${toString port}";
+    sender_localpart = "hookshot";
+
+    namespaces = {
+      aliases = [ ];
+      rooms = [ ];
+      users = [
+        {
+          regex = "@${cfg.settings.generic.userIdPrefix}.*:${domain}";
+          exclusive = true;
+        }
+      ];
+    };
+
+    # Encryption support
+    extraSettings = {
+      "de.sorunome.msc2409.push_ephemeral" = true;
+      push_ephemeral = true;
+      "org.matrix.msc3202" = true;
+    };
+
+    runtimeRegistration = "/var/lib/matrix-hookshot/registration.yaml";
+  };
+in
+{
+  systemd.services.matrix-hookshot = {
+    serviceConfig = {
+      Type = lib.mkForce "exec";
+
+      LoadCredential = "matrix-hookshot:/run/secrets/matrix-hookshot";
+      inherit (registration) ExecStartPre;
+
+      # Some library in matrix-hookshot wants a home directory
+      Environment = [ "HOME=/var/lib/matrix-hookshot" ];
+
+      DynamicUser = true;
+      StateDirectory = "matrix-hookshot";
+      RuntimeDirectory = "matrix-hookshot";
+      RuntimeDirectoryMode = "0700";
+
+      RestrictNamespaces = true;
+      PrivateUsers = true;
+      ProtectHostname = true;
+      ProtectClock = true;
+      ProtectKernelTunables = true;
+      ProtectKernelModules = true;
+      ProtectKernelLogs = true;
+      ProtectControlGroups = true;
+      RestrictAddressFamilies = [ "AF_INET AF_INET6" ];
+      LockPersonality = true;
+      RestrictRealtime = true;
+      ProtectProc = "invisible";
+      ProcSubset = "pid";
+      UMask = 77;
+    };
+  };
+
+  services.matrix-hookshot = {
+    enable = true;
+
+    serviceDependencies = [
+      "conduit.service"
+    ];
+
+    registrationFile = "/var/lib/matrix-hookshot/registration.yaml";
+
+    settings = {
+      bridge = {
+        inherit domain;
+        url = "http://localhost:${toString conduitCfg.settings.global.port}";
+        mediaUrl = conduitCfg.settings.global.well_known.client;
+        port = 9993;
+        bindAddress = "127.0.0.1";
+      };
+
+      generic = {
+        enabled = true;
+        outbound = false;
+        # Only allow webhooks from localhost for the moment
+        urlPrefix = "${cfg.settings.bridge.url}/webhook";
+        userIdPrefix = "_webhooks_";
+      };
+
+      encryption.storagePath = "/var/lib/matrix-hookshot/cryptostore";
+
+      permissions = [
+        {
+          actor = "matrix.tlater.net";
+          services = [
+            {
+              service = "*";
+              level = "notifications";
+            }
+          ];
+        }
+        {
+          actor = "@tlater:matrix.tlater.net";
+          services = [
+            {
+              service = "*";
+              level = "admin";
+            }
+          ];
+        }
+      ];
+
+      listeners = [
+        {
+          port = 9000;
+          resources = [
+            "webhooks"
+          ];
+        }
+        {
+          port = 9001;
+          resources = [
+            "metrics"
+            "provisioning"
+          ];
+        }
+      ];
+    };
+  };
+}
diff --git a/configuration/services/minecraft.nix b/configuration/services/minecraft.nix
new file mode 100644
index 0000000..0477f44
--- /dev/null
+++ b/configuration/services/minecraft.nix
@@ -0,0 +1,83 @@
+{
+  pkgs,
+  lib,
+  config,
+  ...
+}:
+{
+  services.minecraft-server = {
+    enable = true;
+    eula = true;
+    # jvmOpts are set using a file for forge
+    # jvmOpts = "-Xmx8G -Xms8G";
+    openFirewall = true;
+
+    declarative = true;
+
+    whitelist = {
+      tlater = "140d177a-966f-41b8-a4c0-e305babd291b";
+      romino25 = "59cd1648-14a4-4bcf-8f5a-2e1bde678f2c";
+      lasi25 = "0ab6e3d1-544a-47e7-8538-2e6c248e49a4";
+    };
+
+    serverProperties = {
+      allow-flight = true;
+      difficulty = "hard";
+      motd = "tlater.net";
+      spawn-protection = 1;
+      white-list = true;
+      enable-query = true;
+      enable-status = true;
+
+      # Allows the server to write chunks without hogging the main
+      # thread...
+      sync-chunk-writes = false;
+      # Disables chat reporting, because we don't need any of that
+      # drama on a lil' friends-only server.
+      enforce-secure-profile = false;
+    };
+
+    package = pkgs.writeShellApplication {
+      name = "minecraft-server";
+      runtimeInputs = with pkgs; [ jdk17_headless ];
+
+      text = ''
+        exec /var/lib/minecraft/run.sh $@
+      '';
+    };
+  };
+
+  systemd.services.minecraft-server = {
+    path = with pkgs; [ jdk17_headless ];
+
+    # Since we read from our own HTTP server, we need to wait for it
+    # to be up
+    after = [ "nginx.service" ];
+
+    serviceConfig = {
+      # Use packwiz to install mods
+      ExecStartPre = [
+        "${pkgs.jdk17_headless}/bin/java -jar ${config.services.minecraft-server.dataDir}/packwiz-installer-bootstrap.jar -g -s server 'https://minecraft.${config.services.nginx.domain}/cobblemon-pack/pack.toml'"
+      ];
+      # Forge requires some bonus JVM options, which they include in a
+      # little `run.sh` script
+      ExecStart = lib.mkForce "${config.services.minecraft-server.dataDir}/run.sh --nogui";
+    };
+  };
+
+  systemd.tmpfiles.settings."10-minecraft" = {
+    "/srv/minecraft".d = {
+      user = "nginx";
+      group = "minecraft";
+      mode = "0775";
+    };
+  };
+
+  services.nginx.virtualHosts."minecraft.${config.services.nginx.domain}" = {
+    forceSSL = true;
+    useACMEHost = "tlater.net";
+    enableHSTS = true;
+
+    root = "/srv/minecraft";
+  };
+}
diff --git a/configuration/sops.nix b/configuration/sops.nix
index bbfb888..3a1c3d8 100644
--- a/configuration/sops.nix
+++ b/configuration/sops.nix
@@ -1,14 +1,15 @@
+{ config, lib, ... }:
 {
   sops = {
     defaultSopsFile = ../keys/production.yaml;
 
     secrets = {
-      "battery-manager/email" = {
+      "battery-manager/email" = lib.mkIf config.services.batteryManager.enable {
         owner = "battery-manager";
         group = "battery-manager";
       };
 
-      "battery-manager/password" = {
+      "battery-manager/password" = lib.mkIf config.services.batteryManager.enable {
         owner = "battery-manager";
         group = "battery-manager";
       };
@@ -34,6 +35,10 @@
       "heisenbridge/as-token" = { };
       "heisenbridge/hs-token" = { };
 
+      # Matrix-hookshot
+      "matrix-hookshot/as-token" = { };
+      "matrix-hookshot/hs-token" = { };
+
       # Nextcloud
       "nextcloud/tlater" = {
         owner = "nextcloud";
diff --git a/keys/staging.yaml b/keys/staging.yaml
index 67e47ad..876d60e 100644
--- a/keys/staging.yaml
+++ b/keys/staging.yaml
@@ -16,6 +16,9 @@ steam:
 heisenbridge:
     as-token: ENC[AES256_GCM,data:tXbOeo7nv8I=,iv:wJAKcOXX9nGIw4n38ThOoj29u7dUWhsxSQG/p79JlEw=,tag:rTVaGS2UuWcea1uBa8YX2g==,type:str]
     hs-token: ENC[AES256_GCM,data:VBwvwomv0Xg=,iv:q6INtJ+rg+QiXj8uBdBzQYQZUBBXp+9odxDHwvu8Jxc=,tag:XKhm8nxygAkKaiVPJ2Fcdg==,type:str]
+matrix-hookshot:
+    as-token: ENC[AES256_GCM,data:uSUOo4f2KqA=,iv:Xb9G8Ecv6m59m51kDw2bOfq3SMJt4g9/6/EdH74R+KM=,tag:K9MSfO2c2Y4rlf0eYrmTnw==,type:str]
+    hs-token: ENC[AES256_GCM,data:0KsyA06InL4=,iv:zAR0Y1fk8SyodcSLBHlQ8I+BAmttz9Hkd8Q3OREFqs4=,tag:t1Et8N/3seq95DeGoUd7Sw==,type:str]
 wireguard:
     server-key: ENC[AES256_GCM,data:FvY897XdKoa/mckE8JQLCkklsnYD6Wz1wpsu5t3uhEnW3iarnDQxF9msuYU=,iv:jqGXfekM+Vs+J9b5nlZ5Skd1ZKHajoUo2Dc4tMYPm1w=,tag:EehikjI/FCU8wqtpvJRamQ==,type:str]
 restic:
@@ -34,8 +37,8 @@ sops:
     azure_kv: []
     hc_vault: []
     age: []
-    lastmodified: "2025-02-01T10:16:31Z"
-    mac: ENC[AES256_GCM,data:N4RQHOyWvSXW16fepQvRznNbmGerct03kptyiY3IoTpYaJ+43cyFjW15ZqfpaRFyV66QIeqmceqV8c4eP8YSndj6e55e04w0RCyqREXQlFPR6Eh5elaBenokoJhjF6BCsq+xX1C+LUEcxiR/dgy5cwA3mAD/dLCm+G11a06EG6k=,iv:wt5fEOVP6CXHCzmMH9hNCQDDgPa66bLMOa39Eipux9Y=,tag:kWZPnWD1stANVAmWmvOjCg==,type:str]
+    lastmodified: "2025-02-07T17:43:24Z"
+    mac: ENC[AES256_GCM,data:akmD/bfgeTyFzW1quvM16cdj0fC6+CbJ8WyX9173H11yKGxvE1USQYcErpl1SHOx9Jk8LVb7f+MsUm2fjQF1MEq6xaWI74jem12lZ9CGXFaTL7e87JvfbK7pV+aKpxSBBNFyJgbYm30ibdUwxwKmNVfPb1e0HT9qwenvoV7RobM=,iv:mKqOW0ULXL711uczUbRf9NPo6uPTQoS/IbR46S+JID4=,tag:vE6NYzYLbQHDImov1XGTcg==,type:str]
     pgp:
         - created_at: "2025-01-21T17:55:30Z"
           enc: |-