diff --git a/DEPLOYMENT-NOTES.rst b/DEPLOYMENT-NOTES.rst
index 0a7ea52e0bfb20e77f86797ead1778d614a2a720..5f1b15e933e9c2e98c364bf5969350a494987f61 100644
--- a/DEPLOYMENT-NOTES.rst
+++ b/DEPLOYMENT-NOTES.rst
@@ -1,6 +1,8 @@
 Deployment notes
 ================
 
+- 2021-10-12 The secret in ``private-keys/grafana-slack-url`` needs to be changed to remove the ``SLACKURL=`` prefix.
+
 - 2021-09-30 `Enable alerting <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/merge_requests/185>`_ needs a secret in ``private-keys/grafana-slack-url`` looking like the template in ``morph/grid/local/private-keys/grafana-slack-url`` and pointing to the secret API endpoint URL saved in `this 1Password entry <https://privatestorage.1password.com/vaults/7flqasy5hhhmlbtp5qozd3j4ga/allitems/cgznskz2oix2tyx5xyntwaos5i>`_ (or create a new secret URL at https://www.slack.com/apps/A0F7XDUAZ).
 
 - 2021-09-07 `Manage access to payment metrics <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/merge_requests/146>`_ requires moving and chown'ing the PaymentServer database on the ``payments`` host::
diff --git a/ci-tools/vulnerability-scan b/ci-tools/vulnerability-scan
index 48bf51e071a398f37565717a22b2066d3f905fbe..67e1a21263fa65843b34d185884ea6df2596220a 100755
--- a/ci-tools/vulnerability-scan
+++ b/ci-tools/vulnerability-scan
@@ -32,6 +32,12 @@ else
 fi
 '
 
+# The version (1.9.6) of vulnix in nixos-21.05 incorrectly collapses
+# derivations with the same name+version, but different sets of patches
+# applied. Therefore, we use a recent nixos-unstable version that has a newer
+# version of vulnix included.
+export NIX_PATH=nixpkgs=https://api.github.com/repos/NixOS/nixpkgs/tarball/ee084c02040e864eeeb4cf4f8538d92f7c675671
+
 # vulnix exits with an error status if there are vulnerabilities.  We told
 # GitLab to allow this by setting `allow_failure` to true in the GitLab CI
 # config.  vulnix exit status indicates what vulnix thinks happened.  If we
diff --git a/morph/grid/local/private-keys/README.rst b/morph/grid/local/private-keys/README.rst
index 91670ac1a0ea6ee2c68df71ff196d010bdba8637..8ecd2dd261b02dd757862703944ad970688d3e7e 100644
--- a/morph/grid/local/private-keys/README.rst
+++ b/morph/grid/local/private-keys/README.rst
@@ -23,7 +23,7 @@ grafana-slack-url
 -----------------
 
 This file is read by Grafana's systemd service to set an environment variable with a secret Slack WebHook URL to post alerts to.
-The only line in the file should be ``SLACKURL=`` with the secret URL.
+The only line in the file should be the secret URL.
 Use the url from `this 1Password entry <https://privatestorage.1password.com/vaults/7flqasy5hhhmlbtp5qozd3j4ga/allitems/cgznskz2oix2tyx5xyntwaos5i>`_ or get a new secret URL for your Slack channel at https://www.slack.com/apps/A0F7XDUAZ.
 
 stripe.secret
diff --git a/morph/grid/local/private-keys/grafana-slack-url b/morph/grid/local/private-keys/grafana-slack-url
index cb7dd1aec785a557fef6082a7570bc8c56728f14..0885b7bfe1786d19f845c45d749bafaf12756cb4 100644
--- a/morph/grid/local/private-keys/grafana-slack-url
+++ b/morph/grid/local/private-keys/grafana-slack-url
@@ -1,2 +1,2 @@
-SLACKURL=https://hooks.slack.com/services/x/y/z
+https://hooks.slack.com/services/x/y/z
 
diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix
index 5cec1c4a9dc07f297abab049790dbf970388c91b..da3eed73e59349b4faaf64ebb32c067e952917ae 100644
--- a/nixos/modules/issuer.nix
+++ b/nixos/modules/issuer.nix
@@ -189,7 +189,7 @@ in {
       extraGroups = [ "keys" ];
     };
 
-    # Open 80 and 443 for the certbot HTTP server and the PaymentServer HTTPS server.
+    # Open 80 and 443 for nginx
     networking.firewall.allowedTCPPorts = [
       80
       443
diff --git a/nixos/modules/monitoring/server/grafana-dashboards/meta-monitoring.json b/nixos/modules/monitoring/server/grafana-dashboards/meta-monitoring.json
index c1d27b28965eae82939d2dfefdb7f9a709bfd486..17564492ffc163c2c98a1a5e6ed35bc52d63e6c0 100644
--- a/nixos/modules/monitoring/server/grafana-dashboards/meta-monitoring.json
+++ b/nixos/modules/monitoring/server/grafana-dashboards/meta-monitoring.json
@@ -42,7 +42,7 @@
             },
             "reducer": {
               "params": [],
-              "type": "avg"
+              "type": "count"
             },
             "type": "query"
           }
@@ -52,7 +52,7 @@
         "frequency": "1m",
         "handler": 1,
         "name": "Scraping down",
-        "noDataState": "no_data",
+        "noDataState": "ok",
         "notifications": []
       },
       "aliasColors": {},
@@ -86,7 +86,7 @@
       },
       "lines": false,
       "linewidth": 1,
-      "nullPointMode": "null",
+      "nullPointMode": "null as zero",
       "options": {
         "alertThreshold": false
       },
diff --git a/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json b/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json
index 5ecbcb9b709f7093592e54f368166da064b1ae73..0b22728e178ddd5295afd43a17cd0b9c20c530fd 100644
--- a/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json
+++ b/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json
@@ -49,7 +49,7 @@
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
-        "w": 8,
+        "w": 6,
         "x": 0,
         "y": 1
       },
@@ -193,8 +193,8 @@
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
-        "w": 8,
-        "x": 8,
+        "w": 6,
+        "x": 6,
         "y": 1
       },
       "hiddenSeries": false,
@@ -334,8 +334,8 @@
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
-        "w": 8,
-        "x": 16,
+        "w": 6,
+        "x": 12,
         "y": 1
       },
       "hiddenSeries": false,
@@ -425,6 +425,152 @@
         "alignLevel": null
       }
     },
+    {
+      "alert": {
+        "alertRuleTags": {},
+        "conditions": [
+          {
+            "evaluator": {
+              "params": [
+                0.1
+              ],
+              "type": "gt"
+            },
+            "operator": {
+              "type": "and"
+            },
+            "query": {
+              "params": [
+                "A",
+                "5m",
+                "now"
+              ]
+            },
+            "reducer": {
+              "params": [],
+              "type": "avg"
+            },
+            "type": "query"
+          }
+        ],
+        "executionErrorState": "alerting",
+        "for": "5m",
+        "frequency": "1m",
+        "handler": 1,
+        "name": "Swap usage alert",
+        "noDataState": "no_data",
+        "notifications": []
+      },
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "How much Swap is in use? Relative to available swap.",
+      "fieldConfig": {
+        "defaults": {},
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 6,
+        "x": 18,
+        "y": 1
+      },
+      "hiddenSeries": false,
+      "id": 30,
+      "legend": {
+        "alignAsTable": false,
+        "avg": false,
+        "current": false,
+        "hideEmpty": false,
+        "hideZero": false,
+        "max": false,
+        "min": false,
+        "rightSide": false,
+        "show": false,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "7.5.10",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "exemplar": true,
+          "expr": "1 - node_memory_SwapFree_bytes / node_memory_SwapTotal_bytes",
+          "interval": "",
+          "intervalFactor": 1,
+          "legendFormat": "{{instance}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [
+        {
+          "colorMode": "critical",
+          "fill": true,
+          "line": true,
+          "op": "gt",
+          "value": 0.1,
+          "visible": true
+        }
+      ],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "Swap used %",
+      "tooltip": {
+        "shared": true,
+        "sort": 2,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:98",
+          "format": "percentunit",
+          "label": null,
+          "logBase": 1,
+          "max": "1",
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:99",
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
     {
       "collapsed": false,
       "datasource": null,
diff --git a/nixos/modules/monitoring/server/grafana.nix b/nixos/modules/monitoring/server/grafana.nix
index 1783782ce7e395f9201dd93e2386f4eed4bf003e..1b51abd4b795a7d6dd8c4c4319beecae4162bb53 100644
--- a/nixos/modules/monitoring/server/grafana.nix
+++ b/nixos/modules/monitoring/server/grafana.nix
@@ -67,17 +67,14 @@ in {
       default = false;
       description = ''
         Enables the slack alerter. Expects a file that contains
-        the definition of an environment variable named SLACKURL
-        pointing to the secret Slack Web Hook URL in
-        grafanaSlackUrlFile (see below).
+        the secret Slack Web Hook URL in grafanaSlackUrlFile (see below).
       '';
     };
     grafanaSlackUrlFile = lib.mkOption
     { type = lib.types.path;
       default = /run/keys/grafana-slack-url;
       description = ''
-        Where to find the Grafana Systemd EnvironmentFile that
-        sets the secret SLACKURL environment variable.
+        Where to find the file that containts the slack URL.
       '';
     };
   };
@@ -86,12 +83,6 @@ in {
     # Port 80 for ACME ssl retrieval only. 443 for nginx -> grafana.
     networking.firewall.allowedTCPPorts = [ 80 443 ];
 
-    # We pass the secret Slack URL using an environment variable.
-    systemd.services.grafana.serviceConfig.EnvironmentFile =
-      if cfg.enableSlackAlert
-      then [ cfg.grafanaSlackUrlFile ]
-      else [ ];
-
     services.grafana = {
       enable = true;
       domain = cfg.domain;
@@ -157,7 +148,9 @@ in {
             uploadImage = true;
           };
           secure_settings = {
-            url = "$SLACKURL";
+            # `$__file{}` reads the value from the named file.
+            # See https://grafana.com/docs/grafana/latest/administration/configuration/#file-provider
+            url = "$__file{${toString cfg.grafanaSlackUrlFile}}";
           };
         }]);
       };
diff --git a/nixos/modules/spending.nix b/nixos/modules/spending.nix
index 238fbe8f939c4ddb0c78b9a34e106dbea8e39921..325dd147012b7844a8cb0b4b7071c4cd2cd88f28 100644
--- a/nixos/modules/spending.nix
+++ b/nixos/modules/spending.nix
@@ -127,6 +127,16 @@ in
             # Want a regex instead? try locations."~ /v\d+/"
             proxyPass = "http://unix:${cfg.unixSocket}";
           };
+          locations."/metrics" = {
+            proxyPass = "http://unix:${cfg.unixSocket}";
+            # Only allow our monitoringvpn subnet
+            extraConfig = ''
+              allow 172.23.23.0/24;
+              allow 127.0.0.1;
+              allow ::1;
+              deny all;
+            '';
+          };
           locations."/" = {
             # Return a 404 error for any paths not specified above.
             extraConfig = ''
@@ -135,5 +145,11 @@ in
           };
         };
       };
+
+      # Open 80 and 443 for nginx
+      networking.firewall.allowedTCPPorts = [
+        80
+        443
+      ];
     };
 }
diff --git a/nixos/pkgs/zkap-spending-service/repo.json b/nixos/pkgs/zkap-spending-service/repo.json
index 39aeb8404c890e4781ee77f2a93d85d68acee5c3..69f7a30053de661f2c7829384e9496e49077cfd9 100644
--- a/nixos/pkgs/zkap-spending-service/repo.json
+++ b/nixos/pkgs/zkap-spending-service/repo.json
@@ -1,9 +1,9 @@
 {
   "owner": "privatestorage",
   "repo": "zkap-spending-service",
-  "rev": "e0d63b79213d16f2de6629167ea8f1236ba22e14",
+  "rev": "cbf7509f429ffd6e6cf37a73e4ff84a9c5ce1141",
   "branch": "main",
   "domain": "whetstone.privatestorage.io",
-  "outputHash": "30abb0g9xxn4lp493kj5wmz8kj5q2iqvw40m8llqvb3zamx60gd8cy451ii7z15qbrbx9xmjdfw0k4gviij46fkx1s8nbich5c8qx57",
+  "outputHash": "04g7pcykc2525cg3z7wg5834s7vqn82xaqjvf52l6dnxv3mb9xr93kk505dvxcwhgfbqpim5i479s9kqd8gi7q3lq5wn5fq7rf7lkrj",
   "outputHashAlgo": "sha512"
 }
diff --git a/nixos/pkgs/zkapissuer/default.nix b/nixos/pkgs/zkapissuer/default.nix
index b4f90d3582cd686fbdf62a6267cb1070c05e9c57..efa55ff108e72fb7d78d95c6db46bddcdca1116f 100644
--- a/nixos/pkgs/zkapissuer/default.nix
+++ b/nixos/pkgs/zkapissuer/default.nix
@@ -1,6 +1,7 @@
-{ callPackage }:
+{ callPackage, fetchFromGitHub, lib }:
 let
-  repo = callPackage ./repo.nix { };
+  repo-data = lib.importJSON ./repo.json;
+  repo = fetchFromGitHub (builtins.removeAttrs repo-data [ "branch" ]);
   PaymentServer = (import "${repo}/nix").PaymentServer;
 in
   PaymentServer.components.exes."PaymentServer-exe"
diff --git a/nixos/pkgs/zkapissuer/repo.json b/nixos/pkgs/zkapissuer/repo.json
new file mode 100644
index 0000000000000000000000000000000000000000..0a003dc61620fd92b1a618e9845763e276c9693a
--- /dev/null
+++ b/nixos/pkgs/zkapissuer/repo.json
@@ -0,0 +1,8 @@
+{
+  "owner": "PrivateStorageio",
+  "repo": "PaymentServer",
+  "rev": "e080beb14ec58ffe8e55c35e6dddd46c5082887f",
+  "branch": "main",
+  "outputHashAlgo": "sha256",
+  "outputHash": "1zck9kawbs2lkr3qjipira9gawa4gxlqijqqjrmlvvyp9mr0fgxm"
+}
diff --git a/nixos/pkgs/zkapissuer/repo.nix b/nixos/pkgs/zkapissuer/repo.nix
deleted file mode 100644
index 6646a2e32eb8e5a747e4491ce43f706fee65724c..0000000000000000000000000000000000000000
--- a/nixos/pkgs/zkapissuer/repo.nix
+++ /dev/null
@@ -1,7 +0,0 @@
-{ fetchFromGitHub }:
-fetchFromGitHub {
-  owner = "PrivateStorageio";
-  repo = "PaymentServer";
-  rev = "ff30e85c231a3b5ad76426bbf8801f8f76884367";
-  sha256 = "1spz19f5z96shmfpazj0rv6877xvchf3gl49a4xahjbbsz39x34x";
-}
diff --git a/nixos/tests/spending.nix b/nixos/tests/spending.nix
index c970157b9375e0d99e2be8d4f782992163a6c948..8500471a58ff3f447e03ec1bf9005ff626169113 100644
--- a/nixos/tests/spending.nix
+++ b/nixos/tests/spending.nix
@@ -11,10 +11,14 @@
       services.private-storage-spending.enable = true;
       services.private-storage-spending.domain = "localhost";
     };
+    external = { ... }: {
+      # A node that has no particular configuration, for testing access rules
+      # for external hosts.
+    };
   };
   testScript = { nodes }: let
     revision = nodes.spending.config.passthru.ourpkgs.zkap-spending-service.meta.rev;
-    curl = "${pkgs.curl}/bin/curl -sSf";
+    curl = "${pkgs.curl}/bin/curl -sSf --max-time 5";
   in
     ''
       import json
@@ -25,8 +29,17 @@
       with subtest("Ensure we can ping the spending service"):
         output = spending.succeed("${curl} http://localhost/v1/_ping")
         assert json.loads(output)["status"] == "ok", "Could not ping spending service."
+      with subtest("Ensure external hosts can ping the spending service"):
+        output = external.succeed("${curl} http://spending/v1/_ping")
+        assert json.loads(output)["status"] == "ok", "Could not ping spending service."
       with subtest("Ensure that the spending service version matches the expected version"):
         output = spending.succeed("${curl} http://localhost/v1/_version")
         assert json.loads(output)["revision"] == "${revision}", "Spending service revision does not match."
+      with subtest("Ensure that the spending service generates metrics"):
+        # TODO: We should pass "-H 'accept: application/openmetrics-text'" here.
+        # See https://github.com/prometheus/prometheus/issues/8932
+        output = spending.succeed("${curl} http://localhost/metrics | ${pkgs.prometheus}/bin/promtool check metrics")
+      with subtest("Ensure that the metrics are not accesible from other machines"):
+        output = external.fail("${curl} http://spending/metrics")
     '';
 }
diff --git a/nixpkgs-2105.json b/nixpkgs-2105.json
index bfd07e9db63256d7ff1efdfc77f23c18a06fcff3..55b7cf2d1617db02e5bdcc6944f32549ee92d695 100644
--- a/nixpkgs-2105.json
+++ b/nixpkgs-2105.json
@@ -1,5 +1,5 @@
 {
   "name": "release2105",
-  "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.3468.92609f3d9bc/nixexprs.tar.xz",
-  "sha256": "154bws59vasydyqb8kfi32fyhawqpwpn85y0sn93hz9ch0r6pgvh"
+  "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.3740.ce7a1190a0f/nixexprs.tar.xz",
+  "sha256": "112drvixj81vscj8cncmks311rk2ik5gydpd03d3r0yc939zjskg"
 }
diff --git a/tools/default.nix b/tools/default.nix
index fb44c660e7d4a4a62cec6bb58a008a1bf00429dc..b10bb5f209c44c3ccba5cf509655e6d25fbb88da 100644
--- a/tools/default.nix
+++ b/tools/default.nix
@@ -16,6 +16,7 @@ let
   python-commands = [
     ./update-nixpkgs
     ./update-gitlab-repo
+    ./update-github-repo
   ];
 in
   # This derivation creates a package that wraps our tools to setup an environment
diff --git a/tools/update-github-repo b/tools/update-github-repo
new file mode 100644
index 0000000000000000000000000000000000000000..0e7e1511fc017c360660dc9fb752ff03f315f9bb
--- /dev/null
+++ b/tools/update-github-repo
@@ -0,0 +1,89 @@
+#!/usr/bin/env python
+
+"""
+Update a pinned github repository.
+
+Pass this path to a JSON file and it will update it to the latest
+version of the branch it specifies. You can also pass a different
+branch or repository owner, which will update the file to point at
+the new branch/repository, and update to the latest version.
+"""
+
+import argparse
+import json
+from pathlib import Path
+
+import httpx
+from ps_tools import get_url_hash
+
+HASH_TYPE = "sha512"
+
+ARCHIVE_TEMPLATE = "https://api.github.com/repos/{owner}/{repo}/tarball/{rev}"
+BRANCH_TEMPLATE = (
+    "https://api.github.com/repos/{owner}/{repo}/commits/{branch}"
+)
+
+
+def get_github_commit(config):
+    response = httpx.get(BRANCH_TEMPLATE.format(**config))
+    response.raise_for_status()
+    return response.json()["sha"]
+
+
+def get_github_archive_url(config):
+    return ARCHIVE_TEMPLATE.format(**config)
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "repo_file",
+        metavar="repo-file",
+        type=Path,
+        help="JSON file with pinned configuration.",
+    )
+    parser.add_argument(
+        "--branch",
+        type=str,
+        help="Branch to update to.",
+    )
+    parser.add_argument(
+        "--owner",
+        type=str,
+        help="Repository owner to update to.",
+    )
+    parser.add_argument(
+        "--rev",
+        type=str,
+        help="Revision to pin.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+    )
+    args = parser.parse_args()
+
+    repo_file = args.repo_file
+    config = json.loads(repo_file.read_text())
+
+    for key in ["owner", "branch"]:
+        if getattr(args, key) is not None:
+            config[key] = getattr(args, key)
+
+    if args.rev is not None:
+        config["rev"] = args.rev
+    else:
+        config["rev"] = get_github_commit(config)
+
+    archive_url = get_github_archive_url(config)
+    config.update(get_url_hash(HASH_TYPE, "source", archive_url))
+
+    output = json.dumps(config, indent=2)
+    if args.dry_run:
+        print(output)
+    else:
+        repo_file.write_text(output)
+
+
+if __name__ == "__main__":
+    main()