diff --git a/.sops.yaml b/.sops.yaml
new file mode 100644
index 0000000..fd14552
--- /dev/null
+++ b/.sops.yaml
@@ -0,0 +1,7 @@
+keys:
+  - &tlater 535B61015823443941C744DD12264F6BBDFABA89
+
+creation_rules:
+  - key_groups:
+      - pgp:
+          - *tlater
diff --git a/configuration/default.nix b/configuration/default.nix
index 8600070..a53c286 100644
--- a/configuration/default.nix
+++ b/configuration/default.nix
@@ -1,9 +1,12 @@
 { config, pkgs, lib, ... }:
 
-{
+let inherit (lib.attrsets) mapAttrs;
+
+in {
   imports = [
     ./services/gitea.nix
     ./services/minecraft.nix
+    ./services/monitoring.nix
     ./services/nextcloud.nix
     ./services/webserver.nix
     ./ids.nix
@@ -34,6 +37,16 @@
 
   time.timeZone = "Europe/London";
 
+  sops = {
+    gnupg = {
+      home = "/var/lib/sops";
+      sshKeyPaths = [ ];
+    };
+
+    defaultSopsFile = "/etc/sops/secrets.yaml";
+    validateSopsFiles = false;
+  };
+
   users.users.tlater = {
     isNormalUser = true;
     extraGroups = [ "wheel" ];
@@ -57,6 +70,13 @@
     recommendedProxySettings = true;
     clientMaxBodySize = "10G";
     domain = "tlater.net";
+    commonHttpConfig = ''
+      log_format custom '$remote_addr - $remote_user [$time_local] '
+                        '"$request" $status $body_bytes_sent '
+                        '"$http_referrer" "$http_user_agent" '
+                        '$upstream_response_time $request_length $request_time';
+      access_log /var/log/nginx/access.log custom;
+    '';
 
     virtualHosts = let
       host = port: extra:
@@ -73,9 +93,20 @@
       "${domain}" = host 3002 { serverAliases = [ "www.${domain}" ]; };
       "gitea.${domain}" = host 3000 { };
       "nextcloud.${domain}" = host 3001 { };
+      "grafana.${domain}" = host 3003 { };
     };
   };
 
+  # Allow nginxlog group users to read the nginx log
+  users.groups.nginxlog.gid = null;
+  systemd.services.nginx.serviceConfig = {
+    SupplementaryGroups = [ "nginxlog" ];
+    LogsDirectoryMode = lib.mkOverride 99 "0751";
+    ExecStartPost = [
+      "+${pkgs.coreutils}/bin/chown nginx:nginxlog \${LOGS_DIRECTORY}/access.log \${LOGS_DIRECTORY}/error.log"
+    ];
+  };
+
   security.acme = {
     email = "tm@tlater.net";
     acceptTerms = true;
diff --git a/configuration/services/monitoring.nix b/configuration/services/monitoring.nix
new file mode 100644
index 0000000..c3cb286
--- /dev/null
+++ b/configuration/services/monitoring.nix
@@ -0,0 +1,186 @@
+{ config, lib, pkgs, ... }:
+
+let
+  inherit (builtins) attrNames concatStringsSep;
+  inherit (lib) stringAfter;
+  inherit (lib.attrsets) filterAttrs mapAttrsToList;
+  inherit (pkgs) openssl writeText;
+
+  domain = "grafana.${config.services.nginx.domain}";
+  keydir = "/run/tempsecrets.d";
+  certdir = "/run/tempcerts.d";
+  nonTlsExporters =
+    filterAttrs (_: exporter: exporter.enable && exporter.extraFlags == [ ])
+    config.services.prometheus.exporters;
+  tlsExporters =
+    filterAttrs (_: exporter: exporter.enable && exporter.extraFlags != [ ])
+    config.services.prometheus.exporters;
+in {
+  services.grafana = {
+    inherit domain;
+
+    enable = true;
+    port = 3003;
+
+    security = {
+      adminUser = "tlater";
+      adminPasswordFile = "/run/secrets/grafana-admin-pass";
+    };
+
+    extraOptions = {
+      # All services grafana is allowed to source from
+      SECURITY_DATA_SOURCE_PROXY_WHITELIST = "localhost:4000";
+
+      # We want this to always go through the nixos config
+      SECURITY_DISABLE_INITIAL_ADMIN_CREATION = "true";
+
+      # Our nginx host only forwards this through https, so we can use
+      # cookie_secure
+      SECURITY_COOKIE_SECURE = "true";
+
+      # These security settings aren't set by default yet, but
+      # probably will be in the future
+      SECURITY_COOKIE_SAMESITE = "true";
+      SECURITY_X_XSS_PROTECTION = "true";
+    };
+
+    provision = {
+      enable = true;
+      datasources = [{
+        name = "Prometheus";
+        type = "prometheus";
+        url = "https://localhost:4000";
+        jsonData = {
+          tlsAuth = true;
+          tlsAuthWithCACert = true;
+        };
+        # Currently, Grafana doesn't support specifying key/cert from
+        # a file, which makes this very tricky to automate.
+        #
+        # We'd need to set jsonSecureData, which would be
+        # world-readable, and completely break authentication.
+        #
+        # See this discussion:
+        # https://github.com/grafana/grafana/discussions/44296
+        #
+        # For now, hand-add key/cert every time the server restarts,
+        # if this becomes more permanent, maybe write a script that
+        # updates the key via API?
+        editable = true;
+      }];
+    };
+  };
+
+  services.prometheus = let
+    # See https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md#web-configuration
+    makeTlsConfig = client: server:
+      writeText "web.yml" ''
+        tls_server_config:
+          key_file: ${keydir}/${server}.pem
+          cert_file: ${certdir}/${server}.pem
+
+          client_auth_type: RequireAndVerifyClientCert
+          client_ca_file: ${certdir}/${client}.pem
+      '';
+  in {
+    enable = true;
+    port = 4000;
+    extraFlags =
+      [ "--web.config.file=${makeTlsConfig "grafana" "prometheus"}" ];
+
+    # From the documentation:
+    #
+    # > When credentials are stored in external files (password_file,
+    # > bearer_token_file, etc), they will not be visible to promtool
+    # > and it will report errors, despite a correct configuration.
+    checkConfig = false;
+
+    exporters = {
+      node = {
+        enable = true;
+        enabledCollectors = [ "systemd" ];
+        port = 4001;
+        extraFlags =
+          [ "--web.config=${makeTlsConfig "prometheus" "node-exporter"}" ];
+      };
+
+      nginxlog = {
+        enable = true;
+        group = "nginxlog";
+        port = 4002;
+        # Note: No way to enable TLS/auth here
+        settings.namespaces = [{
+          name = "nginx";
+          format = concatStringsSep " " [
+            "$remote_addr - $remote_user [$time_local]"
+            ''"$request" $status $body_bytes_sent''
+            ''"$http_referrer" "$http_user_agent"''
+            "$upstream_response_time $request_length $request_time"
+          ];
+          source.files = [ "/var/log/nginx/access.log" ];
+        }];
+      };
+    };
+
+    scrapeConfigs = (mapAttrsToList (name: exporter: {
+      job_name = name;
+      scheme = "https";
+      tls_config = {
+        ca_file = "${certdir}/${name}-exporter.pem";
+        cert_file = "${certdir}/prometheus.pem";
+        key_file = "${keydir}/prometheus.pem";
+        server_name = "localhost";
+      };
+      static_configs =
+        [{ targets = [ "127.0.0.1:${toString exporter.port}" ]; }];
+    }) tlsExporters) ++ mapAttrsToList (name: exporter: {
+      job_name = name;
+      scheme = "http";
+      static_configs =
+        [{ targets = [ "127.0.0.1:${toString exporter.port}" ]; }];
+    }) nonTlsExporters;
+  };
+
+  system.activationScripts = {
+    # This will seem a bit strange, and it probably *is*; The
+    # keys/certs here are only used for the various prometheus/grafana
+    # services to authenticate against each other.
+    #
+    # Since they aren't used to actually encrypt anything but
+    # communication that happens once, it's not necessary to keep the
+    # keys around. They're only used internally, and frequently
+    # switching them doesn't cause any issues. In fact, a single-use
+    # key protocol would probably be more secure.
+    #
+    # Sadly, neither of these services support anything more usable
+    # than https, so we need to generate keys. We opt to regenerate
+    # them at each system activation.
+    #
+    # CN=localhost is not really a risk here - this only matters if an
+    # attacker can spoof a service on the correct port somehow, in
+    # which case they either have root or full access to that server's
+    # user anyway. Since we use TLS auth, no secrets would be leaked,
+    # so in the worst case this exploit would enable an attacker to
+    # DoS that specific data source... Which they could do by taking
+    # over the service already anyway.
+    setupMonitoringAuth = let
+      opensslBin = "${openssl}/bin/openssl";
+      services = [ "grafana" "prometheus" ]
+        ++ (map (name: "${name}-exporter") (attrNames tlsExporters));
+    in stringAfter ([ "specialfs" "users" "groups" ]) (''
+      [ -e /run/current-system ] || echo setting up monitoring secrets...
+      specialMount ramfs '${keydir}' nodev,nosuid,mode=0751 ramfs
+      specialMount ramfs '${certdir}' nodev,nosuid,mode=0751 ramfs
+    '' + concatStringsSep "\n" (map (service: ''
+      ${opensslBin} req -batch -x509 -newkey ed25519 -nodes \
+          -subj '/CN=localhost' \
+          -addext "subjectAltName = DNS:localhost" \
+          -keyout '${keydir}/${service}.pem' \
+          -out '${certdir}/${service}.pem'
+
+      chown ${service}:${service} '${keydir}/${service}.pem'
+      chmod u=r '${keydir}/${service}.pem'
+      chmod =r '${certdir}/${service}.pem'
+    '') services));
+  };
+}
diff --git a/flake.lock b/flake.lock
index 11de3e3..983023a 100644
--- a/flake.lock
+++ b/flake.lock
@@ -73,6 +73,7 @@
         "flake-utils": "flake-utils",
         "nixos-hardware": "nixos-hardware",
         "nixpkgs": "nixpkgs",
+        "sops-nix": "sops-nix",
         "tlaternet-templates": "tlaternet-templates",
         "tlaternet-webserver": "tlaternet-webserver"
       }
@@ -102,6 +103,26 @@
         "type": "github"
       }
     },
+    "sops-nix": {
+      "inputs": {
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1641374494,
+        "narHash": "sha256-a56G6Um43+0+n+yNYhRCh/mSvDdRVzQHSKcFaDEB9/8=",
+        "owner": "Mic92",
+        "repo": "sops-nix",
+        "rev": "7edb4b080023ef12f39262a3aa7aab31015a7a0e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "Mic92",
+        "repo": "sops-nix",
+        "type": "github"
+      }
+    },
     "tlaternet-templates": {
       "inputs": {
         "flake-utils": [
diff --git a/flake.nix b/flake.nix
index 8a0025f..d2473f7 100644
--- a/flake.nix
+++ b/flake.nix
@@ -5,6 +5,10 @@
     nixpkgs.url = "github:nixos/nixpkgs/nixos-21.11";
     nixos-hardware.url = "github:nixos/nixos-hardware/master";
     flake-utils.url = "github:numtide/flake-utils";
+    sops-nix = {
+      url = "github:Mic92/sops-nix";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
     tlaternet-webserver = {
       url = "git+https://gitea.tlater.net/tlaternet/tlaternet.git";
       inputs = {
@@ -21,8 +25,8 @@
     };
   };
 
-  outputs = { self, nixpkgs, nixos-hardware, flake-utils, tlaternet-webserver
-    , tlaternet-templates, ... }@inputs:
+  outputs = { self, nixpkgs, nixos-hardware, flake-utils, sops-nix
+    , tlaternet-webserver, tlaternet-templates, ... }@inputs:
     let
       overlays = [
         (final: prev: {
@@ -35,6 +39,7 @@
             local-lib = self.lib.${prev.system};
           };
         })
+        sops-nix.overlay
       ];
 
     in {
@@ -44,6 +49,8 @@
           inherit system;
 
           modules = [
+            sops-nix.nixosModules.sops
+
             ({ modulesPath, ... }: {
               imports = [ (modulesPath + "/profiles/headless.nix") ];
               nixpkgs.overlays = overlays;
@@ -61,6 +68,8 @@
           inherit system;
 
           modules = [
+            sops-nix.nixosModule
+
             ({ modulesPath, ... }: {
               imports = [ (modulesPath + "/profiles/headless.nix") ];
               nixpkgs.overlays = overlays;
@@ -78,6 +87,13 @@
               # can easily test locally with the VM.
               services.nginx.domain = lib.mkOverride 99 "localhost";
 
+              # Use a default password for the grafana instance for
+              # easy testing.
+              services.grafana.security = {
+                adminPassword = "insecure";
+                adminPasswordFile = lib.mkOverride 99 null;
+              };
+
               # # Set up VM settings to match real VPS
               # virtualisation.memorySize = 3941;
               # virtualisation.cores = 2;
@@ -94,19 +110,25 @@
               nixfmt
               git-lfs
 
+              sops-init-gpg-key
+
               # For the minecraft mod update script
               (python3.withPackages (pypkgs:
                 with pypkgs; [
                   dateutil
                   requests
 
-                  ipython
-                  python-language-server
-                  pyls-black
-                  pyls-isort
-                  pyls-mypy
+                  # ipython
+                  # python-language-server
+                  # pyls-black
+                  # pyls-isort
+                  # pyls-mypy
                 ]))
             ];
+
+            # nativeBuildInputs = [ sops-import-keys-hook ]; Breaks the shellHook somehow
+            sopsPGPKeyDirs = [ "./keys/hosts/" "./keys/users/" ];
+
             shellHook = ''
               export QEMU_OPTS="-m 3941 -smp 2 -curses"
               export QEMU_NET_OPTS="hostfwd=::3022-:2222,hostfwd=::3080-:80,hostfwd=::3443-:443,hostfwd=::3021-:2221,hostfwd=::25565-:25565"
diff --git a/pkgs/minecraft/forge-server.nix b/pkgs/minecraft/forge-server.nix
index 2dea39a..e26a7a5 100644
--- a/pkgs/minecraft/forge-server.nix
+++ b/pkgs/minecraft/forge-server.nix
@@ -9,7 +9,7 @@ let
     url = "${mirror}/${version}/forge-${version}-installer.jar";
     curlOpts = "--globoff";
     # Forge doesn't seem to like newer shas
-    sha1 = "e97821e5431bdcaa46e12048769922e2cdb5e2e1";
+    sha1 = "sha1-oHNpyrgHluRrAXWZJg9j+OInAwA=";
   };
 
   unpackCmd = "mkdir -p src; cp $curSrc src/forge-${version}-installer.jar";