diff --git a/configuration/default.nix b/configuration/default.nix
index dc05cd3..6c6f29a 100644
--- a/configuration/default.nix
+++ b/configuration/default.nix
@@ -16,9 +16,10 @@
 
     ./services/backups.nix
     ./services/conduit.nix
+    ./services/fail2ban.nix
     ./services/foundryvtt.nix
     ./services/gitea.nix
-    ./services/metrics.nix
+    ./services/metrics
     ./services/nextcloud.nix
     ./services/webserver.nix
     ./services/wireguard.nix
@@ -146,27 +147,6 @@
     acceptTerms = true;
   };
 
-  services.fail2ban = {
-    enable = true;
-    extraPackages = [pkgs.ipset];
-    banaction = "iptables-ipset-proto6-allports";
-    bantime-increment.enable = true;
-
-    jails = {
-      nginx-botsearch = ''
-        enabled = true
-        logpath = /var/log/nginx/access.log
-      '';
-    };
-
-    ignoreIP = [
-      "127.0.0.0/8"
-      "10.0.0.0/8"
-      "172.16.0.0/12"
-      "192.168.0.0/16"
-    ];
-  };
-
   # Remove some unneeded packages
   environment.defaultPackages = [];
 
diff --git a/configuration/services/fail2ban.nix b/configuration/services/fail2ban.nix
new file mode 100644
index 0000000..032addb
--- /dev/null
+++ b/configuration/services/fail2ban.nix
@@ -0,0 +1,46 @@
+{pkgs, ...}: {
+  services.fail2ban = {
+    enable = true;
+    extraPackages = [pkgs.ipset];
+    banaction = "iptables-ipset-proto6-allports";
+    bantime-increment.enable = true;
+
+    jails = {
+      nginx-botsearch = ''
+        enabled = true
+        logpath = /var/log/nginx/access.log
+      '';
+    };
+
+    ignoreIP = [
+      "127.0.0.0/8"
+      "10.0.0.0/8"
+      "172.16.0.0/12"
+      "192.168.0.0/16"
+    ];
+  };
+
+  # Allow metrics services to connect to the socket as well
+  users.groups.fail2ban = {};
+  systemd.services.fail2ban.serviceConfig = {
+    RestrictAddressFamilies = [
+      "AF_UNIX" # AF_INET and AF_INET6 are added by the generic config
+    ];
+
+    ExecStartPost =
+      "+"
+      + (pkgs.writeShellScript "fail2ban-post-start" ''
+        while ! [ -S /var/run/fail2ban/fail2ban.sock ]; do
+            sleep 1
+        done
+
+        while ! ${pkgs.netcat}/bin/nc -zU /var/run/fail2ban/fail2ban.sock; do
+            sleep 1
+        done
+
+        ${pkgs.coreutils}/bin/chown root:fail2ban /var/run/fail2ban /var/run/fail2ban/fail2ban.sock
+        ${pkgs.coreutils}/bin/chmod 660 /var/run/fail2ban/fail2ban.sock
+        ${pkgs.coreutils}/bin/chmod 710 /var/run/fail2ban
+      '');
+  };
+}
diff --git a/configuration/services/metrics.nix b/configuration/services/metrics/default.nix
similarity index 76%
rename from configuration/services/metrics.nix
rename to configuration/services/metrics/default.nix
index 51a654d..b563b14 100644
--- a/configuration/services/metrics.nix
+++ b/configuration/services/metrics/default.nix
@@ -7,6 +7,10 @@
   domain = "metrics.${config.services.nginx.domain}";
   yaml = pkgs.formats.yaml {};
 in {
+  imports = [
+    ./exporters.nix
+  ];
+
   services.victoriametrics.enable = true;
 
   services.grafana = {
@@ -62,6 +66,26 @@ in {
     };
   };
 
+  services.prometheus.local-exporters = {
+    prometheus-fail2ban-exporter = rec {
+      enable = true;
+      after = ["fail2ban.service"];
+
+      port = 9191;
+      listenAddress = "127.0.0.1";
+
+      serviceConfig = {
+        Group = "fail2ban";
+
+        ExecStart = lib.concatStringsSep " " [
+          "${pkgs.local.prometheus-fail2ban-exporter}/bin/fail2ban-prometheus-exporter"
+          "--collector.f2b.socket=/var/run/fail2ban/fail2ban.sock"
+          "--web.listen-address='${listenAddress}:${toString port}'"
+        ];
+      };
+    };
+  };
+
   systemd.services.export-to-victoriametrics = let
     promscrape = yaml.generate "prometheus.yml" {
       scrape_configs = [
@@ -72,7 +96,7 @@ in {
               targets =
                 lib.mapAttrsToList (name: exporter: "${exporter.listenAddress}:${toString exporter.port}")
                 (lib.filterAttrs (name: exporter: (builtins.isAttrs exporter) && exporter.enable)
-                  config.services.prometheus.exporters);
+                  (config.services.prometheus.exporters // config.services.prometheus.local-exporters));
             }
           ];
         }
diff --git a/configuration/services/metrics/exporters.nix b/configuration/services/metrics/exporters.nix
new file mode 100644
index 0000000..7f5dd20
--- /dev/null
+++ b/configuration/services/metrics/exporters.nix
@@ -0,0 +1,45 @@
+{
+  config,
+  lib,
+  ...
+}: {
+  options.services.prometheus.local-exporters = lib.mkOption {
+    type = lib.types.anything;
+  };
+
+  config.systemd.services = lib.mapAttrs (_: exporter:
+    lib.mkMerge [
+      {
+        wantedBy = ["multi-user.target"];
+        after = ["network.target"];
+
+        serviceConfig = {
+          Restart = "always";
+          PrivateTmp = true;
+          WorkingDirectory = "/tmp";
+          DynamicUser = true;
+          LockPersonality = true;
+          MemoryDenyWriteExecute = true;
+          NonNewPrivileges = true;
+          PrivateDevices = true;
+          ProtectClock = true;
+          ProtectControlGroups = true;
+          ProtectHome = true;
+          ProtectHostname = true;
+          ProtectKernelLogs = true;
+          ProtectKernelModules = true;
+          ProtectKernelTunables = true;
+          ProtectSystem = "strict";
+          RemoveIPC = true;
+          RestrictAddressFamilies = ["AF_INET" "AF_INET6"];
+          RestrictNamespaces = true;
+          RestrictRealtime = true;
+          RestrictSUIDSGID = true;
+          SystemCallArchitectures = "native";
+          UMask = "0077";
+        };
+      }
+      (removeAttrs exporter ["port" "listenAddress"])
+    ])
+  config.services.prometheus.local-exporters;
+}
diff --git a/flake.nix b/flake.nix
index b6db610..d8ff1a8 100644
--- a/flake.nix
+++ b/flake.nix
@@ -78,7 +78,7 @@
     # Utility scripts #
     ###################
     packages.${system} = let
-      inherit (nixpkgs.legacyPackages.${system}) writeShellScript;
+      inherit (nixpkgs.legacyPackages.${system}) writeShellScript writeShellScriptBin;
       vm = nixpkgs.lib.nixosSystem {
         inherit system;
         specialArgs.flake-inputs = inputs;
@@ -106,6 +106,14 @@
           "${vm.config.system.build.vm}/bin/run-tlaternet-vm"
         '';
 
+      update-pkgs = let
+        nvfetcher-bin = "${nvfetcher.packages.${system}.default}/bin/nvfetcher";
+      in
+        writeShellScriptBin "update-pkgs" ''
+          cd "$(git rev-parse --show-toplevel)/pkgs"
+          ${nvfetcher-bin} -o _sources_pkgs -c nvfetcher.toml
+        '';
+
       update-nextcloud-apps = let
         nvfetcher-bin = "${nvfetcher.packages.${system}.default}/bin/nvfetcher";
       in
diff --git a/pkgs/_sources_pkgs/generated.json b/pkgs/_sources_pkgs/generated.json
new file mode 100644
index 0000000..b3faf9a
--- /dev/null
+++ b/pkgs/_sources_pkgs/generated.json
@@ -0,0 +1,21 @@
+{
+    "prometheus-fail2ban-exporter": {
+        "cargoLocks": null,
+        "date": null,
+        "extract": null,
+        "name": "prometheus-fail2ban-exporter",
+        "passthru": null,
+        "pinned": false,
+        "src": {
+            "deepClone": false,
+            "fetchSubmodules": false,
+            "leaveDotGit": false,
+            "name": null,
+            "rev": "v0.10.0",
+            "sha256": "sha256-8nIW1XaHCBqQCoLkV1ZYE3NTbVZ6c+UOqYD08XQiv+4=",
+            "type": "git",
+            "url": "https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter"
+        },
+        "version": "v0.10.0"
+    }
+}
\ No newline at end of file
diff --git a/pkgs/_sources_pkgs/generated.nix b/pkgs/_sources_pkgs/generated.nix
new file mode 100644
index 0000000..bb015b4
--- /dev/null
+++ b/pkgs/_sources_pkgs/generated.nix
@@ -0,0 +1,16 @@
+# This file was generated by nvfetcher, please do not modify it manually.
+{ fetchgit, fetchurl, fetchFromGitHub, dockerTools }:
+{
+  prometheus-fail2ban-exporter = {
+    pname = "prometheus-fail2ban-exporter";
+    version = "v0.10.0";
+    src = fetchgit {
+      url = "https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter";
+      rev = "v0.10.0";
+      fetchSubmodules = false;
+      deepClone = false;
+      leaveDotGit = false;
+      sha256 = "sha256-8nIW1XaHCBqQCoLkV1ZYE3NTbVZ6c+UOqYD08XQiv+4=";
+    };
+  };
+}
diff --git a/pkgs/default.nix b/pkgs/default.nix
index 3818a26..3130ae0 100644
--- a/pkgs/default.nix
+++ b/pkgs/default.nix
@@ -7,6 +7,9 @@
 in
   {
     starbound = callPackage ./starbound {};
+    prometheus-fail2ban-exporter = callPackage ./prometheus/fail2ban-exporter.nix {
+      sources = pkgs.callPackage ./_sources_pkgs/generated.nix {};
+    };
   }
   // (
     # Add nextcloud apps
diff --git a/pkgs/nvfetcher.toml b/pkgs/nvfetcher.toml
new file mode 100644
index 0000000..8c53200
--- /dev/null
+++ b/pkgs/nvfetcher.toml
@@ -0,0 +1,3 @@
+[prometheus-fail2ban-exporter]
+src.manual = "v0.10.0" # No gitlab support in nvfetcher
+fetch.git = "https://gitlab.com/hectorjsmith/fail2ban-prometheus-exporter"
diff --git a/pkgs/prometheus/fail2ban-exporter.nix b/pkgs/prometheus/fail2ban-exporter.nix
new file mode 100644
index 0000000..50b4973
--- /dev/null
+++ b/pkgs/prometheus/fail2ban-exporter.nix
@@ -0,0 +1,8 @@
+{
+  buildGoModule,
+  sources,
+}:
+buildGoModule {
+  inherit (sources.prometheus-fail2ban-exporter) pname src version;
+  vendorHash = "sha256-qU6opwhhvzbQOhfGVyiVgKhfCSB0Z4eSRAJnv6ht2I0=";
+}