diff --git a/docs/source/dev/README.rst b/docs/source/dev/README.rst
index f9d10518ac9c352186e894ef8e439b19f7ca3878..deb304ccba7f3490322c014a8ce42421f2cc7333 100644
--- a/docs/source/dev/README.rst
+++ b/docs/source/dev/README.rst
@@ -56,6 +56,18 @@ That will update ``nixpkgs-2015.json`` to the latest release on the nixos-21.05
 To update the channel, the script will need to be updated,
 along with the filenames that have the channel in them.
 
+Gitlab Repositories
+```````````````````
+To update the version of packages we import from gitlab, run:
+
+.. code: shell
+
+   nix-shell --command 'tools/update-gitlab nixos/pkgs/<package>/repo.json'
+
+That will update the package to point at the latest version of the project.\
+The command uses branch and repository owner specified in the ``repo.json`` file,
+but you can override them by passing the ``--branch`` or ``-owner`` arguments to the command.
+A specific revision can also be pinned, by passing ``-rev``.
 
 Interactions
 ------------
diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix
index 15afcc8f600018f2cd50fe7f8a0cf4383da27ead..f977876037c7a3769852f43cab0e5d3e59fce154 100644
--- a/morph/grid/local/grid.nix
+++ b/morph/grid/local/grid.nix
@@ -27,6 +27,8 @@ let
       ../../../nixos/modules/deployment.nix
       # Give it a good SSH configuration.
       ../../../nixos/modules/ssh.nix
+      # Configure things specific to the virtualisation environment.
+      gridlib.hardware-vagrant
     ];
     services.private-storage.sshUsers = ssh-users;
 
@@ -46,7 +48,7 @@ let
       # depend on the format we use.
       mode = "0666";
       text = ''
-        # Include the ssh-users config 
+        # Include the ssh-users config
         builtins.fromJSON (builtins.readFile ./ssh-users.json)
       '';
     };
@@ -68,42 +70,47 @@ let
   payments = {
     imports = [
       gridlib.issuer
-      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.21"; }))
       (gridlib.customize-issuer (grid-config // {
           monitoringvpnIPv4 = "172.23.23.11";
       }))
       grid-module
     ];
+    config = {
+      grid.publicIPv4 = "192.168.67.21";
+    };
   };
 
   storage1 = {
     imports = [
       gridlib.storage
-      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.22"; }))
       (gridlib.customize-storage (grid-config // {
         monitoringvpnIPv4 = "172.23.23.12";
         stateVersion = "19.09";
       }))
       grid-module
     ];
+    config = {
+      grid.publicIPv4 = "192.168.67.22";
+    };
   };
 
   storage2 = {
     imports = [
       gridlib.storage
-      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.23"; }))
       (gridlib.customize-storage (grid-config // {
         monitoringvpnIPv4 = "172.23.23.13";
         stateVersion = "19.09";
       }))
       grid-module
     ];
+    config = {
+      grid.publicIPv4 = "192.168.67.23";
+    };
   };
 
   monitoring = {
     imports = [
       gridlib.monitoring
-      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.24"; }))
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
         inherit (grid-config) letsEncryptAdminEmail;
@@ -114,6 +121,9 @@ let
       })
       grid-module
     ];
+    config = {
+      grid.publicIPv4 = "192.168.67.24";
+    };
   };
 
   # TBD: derive these automatically:
diff --git a/morph/lib/default.nix b/morph/lib/default.nix
index 34f5e8b5171f211ededf38efb25440769113a8e4..78de2506382d023bc76b723eb866efc90f20ef7f 100644
--- a/morph/lib/default.nix
+++ b/morph/lib/default.nix
@@ -5,7 +5,7 @@
   base = import ./base.nix;
 
   hardware-aws = import ./issuer-aws.nix;
-  hardware-virtual = import ./hardware-virtual.nix;
+  hardware-vagrant = import ./hardware-vagrant.nix;
 
   issuer = import ./issuer.nix;
   customize-issuer = import ./customize-issuer.nix;
diff --git a/morph/lib/hardware-vagrant.nix b/morph/lib/hardware-vagrant.nix
new file mode 100644
index 0000000000000000000000000000000000000000..150944cd5b64b7a3eb620cd40ea39e00544779a7
--- /dev/null
+++ b/morph/lib/hardware-vagrant.nix
@@ -0,0 +1,43 @@
+{ config, lib, modulesPath, ... }:
+{
+  imports = [
+    # modulesPath points at the upstream nixos/modules directory.
+    "${modulesPath}/virtualisation/vagrant-guest.nix"
+  ];
+
+  options.grid = {
+    publicIPv4 = lib.mkOption {
+      type = lib.types.str;
+      description = ''
+        The primary IPv4 address of the virtual machine.
+      '';
+    };
+  };
+
+  config = {
+    virtualisation.virtualbox.guest.enable = true;
+
+    boot.loader.grub.device = "/dev/sda";
+
+    boot.initrd.availableKernelModules = [ "ata_piix" "sd_mod" "sr_mod" ];
+    boot.kernel.sysctl = { "vm.swappiness" = 0; };
+
+    # remove the fsck that runs at startup. It will always fail to run, stopping
+    # your boot until you press *.
+    boot.initrd.checkJournalingFS = false;
+
+    networking.interfaces.enp0s8.ipv4.addresses = [{
+      address = config.grid.publicIPv4;
+      prefixLength = 24;
+    }];
+
+    fileSystems."/storage" = { fsType = "tmpfs"; };
+    fileSystems."/" =
+      { device = "/dev/sda1";
+        fsType = "ext4";
+      };
+
+    # We want to push packages with morph without having to sign them
+    nix.trustedUsers = [ "@wheel" "root" "vagrant" ];
+  };
+}
diff --git a/morph/lib/hardware-virtual.nix b/morph/lib/hardware-virtual.nix
deleted file mode 100644
index cf1582792bff77c491210ee5e91f99bfbffbf9f3..0000000000000000000000000000000000000000
--- a/morph/lib/hardware-virtual.nix
+++ /dev/null
@@ -1,36 +0,0 @@
-{ publicIPv4, ... }:
-{
-  imports = [ ./vagrant-guest.nix ];
-
-  virtualisation.virtualbox.guest.enable = true;
-
-  # Use the GRUB 2 boot loader.
-  boot.loader.grub.enable = true;
-  boot.loader.grub.version = 2;
-  boot.loader.grub.device = "/dev/sda";
-
-  boot.initrd.availableKernelModules = [ "ata_piix" "sd_mod" "sr_mod" ];
-  boot.initrd.kernelModules = [ ];
-  boot.kernel.sysctl = { "vm.swappiness" = 0; };
-  boot.kernelModules = [ ];
-  boot.extraModulePackages = [ ];
-
-  # remove the fsck that runs at startup. It will always fail to run, stopping
-  # your boot until you press *.
-  boot.initrd.checkJournalingFS = false;
-
-  networking.interfaces.enp0s8.ipv4.addresses = [{
-    address = publicIPv4;
-    prefixLength = 24;
-  }];
-
-  fileSystems."/storage" = { fsType = "tmpfs"; };
-  fileSystems."/" =
-    { device = "/dev/sda1";
-      fsType = "ext4";
-    };
-  swapDevices = [ ];
-
-  # We want to push packages with morph without having to sign them
-  nix.trustedUsers = [ "@wheel" "root" "vagrant" ];
-}
diff --git a/morph/lib/vagrant-guest.nix b/morph/lib/vagrant-guest.nix
deleted file mode 100644
index 360671f5e8391571d37da6db37b2de8dc02b66bd..0000000000000000000000000000000000000000
--- a/morph/lib/vagrant-guest.nix
+++ /dev/null
@@ -1,91 +0,0 @@
-# Minimal configuration that vagrant depends on
-
-{ config, pkgs, lib, ... }:
-let
-  # Vagrant uses an insecure shared private key by default, but we
-  # don't use the authorizedKeys attribute under users because it should be
-  # removed on first boot and replaced with a random one. This script sets
-  # the correct permissions and installs the temporary key if no
-  # ~/.ssh/authorized_keys exists.
-  install-vagrant-ssh-key = pkgs.writeScriptBin "install-vagrant-ssh-key" ''
-    #!${pkgs.runtimeShell}
-    if [ ! -e ~/.ssh/authorized_keys ]; then
-      mkdir -m 0700 -p ~/.ssh
-      echo "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzIw+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoPkcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NOTd0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcWyLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQ== vagrant insecure public key" >> ~/.ssh/authorized_keys
-      chmod 0600 ~/.ssh/authorized_keys
-    fi
-  '';
-in
-{
-  # Services to enable:
-
-  # Enable the OpenSSH daemon.
-  services.openssh.enable = true;
-
-  # Wireguard kernel module for Kernels < 5.6
-  boot = lib.mkIf (lib.versionOlder pkgs.linuxPackages.kernel.version "5.6") {
-    extraModulePackages = [ config.boot.kernelPackages.wireguard ] ;
-  };
-
-  # Enable DBus
-  services.dbus.enable    = true;
-
-  # Replace ntpd by timesyncd
-  services.timesyncd.enable = true;
-
-  # Packages for Vagrant
-  environment.systemPackages = with pkgs; [
-    findutils
-    gnumake
-    iputils
-    jq
-    nettools
-    netcat
-    nfs-utils
-    rsync
-  ];
-
-  users.users.root = { password = "vagrant"; };
-
-  # Creates a "vagrant" group & user with password-less sudo access
-  users.groups.vagrant = {
-    name = "vagrant";
-    members = [ "vagrant" ];
-  };
-  users.extraUsers.vagrant = {
-    isNormalUser    = true;
-    createHome      = true;
-    group           = "vagrant";
-    extraGroups     = [ "users" "wheel" ];
-    password        = "vagrant";
-    home            = "/home/vagrant";
-    useDefaultShell = true;
-  };
-
-  systemd.services.install-vagrant-ssh-key = {
-    description = "Vagrant SSH key install (if needed)";
-    after = [ "fs.target" ];
-    wants = [ "fs.target" ];
-    wantedBy = [ "multi-user.target" ];
-    serviceConfig = {
-      ExecStart = "${install-vagrant-ssh-key}/bin/install-vagrant-ssh-key";
-      User = "vagrant";
-      # So it won't be (needlessly) restarted:
-      RemainAfterExit = true;
-    };
-  };
-
-  security.sudo.wheelNeedsPassword = false;
-
-  security.sudo.extraConfig =
-    ''
-      Defaults:root,%wheel env_keep+=LOCALE_ARCHIVE
-      Defaults:root,%wheel env_keep+=NIX_PATH
-      Defaults:root,%wheel env_keep+=TERMINFO_DIRS
-      Defaults env_keep+=SSH_AUTH_SOCK
-      Defaults lecture = never
-      root   ALL=(ALL) SETENV: ALL
-      %wheel ALL=(ALL) NOPASSWD: ALL, SETENV: ALL
-    '';
-}
-
diff --git a/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json b/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json
index cb5bc91da7c3adbb1c9377473b053d31d53550f0..5ecbcb9b709f7093592e54f368166da064b1ae73 100644
--- a/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json
+++ b/nixos/modules/monitoring/server/grafana-dashboards/resources-overview.json
@@ -41,7 +41,7 @@
       "description": "Some of our software runs in a single thread, so this shows max CPU per core (instead of averaged over all cores)",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -68,11 +68,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -173,8 +172,8 @@
       "datasource": null,
       "fieldConfig": {
         "defaults": {
-          "custom": {},
-          "displayName": "${__field.labels.instance}"
+          "displayName": "${__field.labels.instance}",
+          "links": []
         },
         "overrides": [
           {
@@ -213,11 +212,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -241,7 +239,7 @@
           "line": true,
           "op": "gt",
           "value": 1,
-          "yaxis": "left"
+          "visible": true
         }
       ],
       "timeFrom": null,
@@ -328,7 +326,7 @@
       "description": "How much RAM is in use? Relative to available system memory.",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -356,11 +354,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -384,7 +381,7 @@
           "line": true,
           "op": "gt",
           "value": 0.8,
-          "yaxis": "left"
+          "visible": true
         }
       ],
       "timeFrom": null,
@@ -448,10 +445,10 @@
       "dashLength": 10,
       "dashes": false,
       "datasource": null,
-      "description": "Shows most saturated network link for every node. Baseline is the reported NIC link speed - that might not be the actual limit.",
+      "description": "Shows most saturated network link for every node. Bit/s.",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -478,11 +475,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -492,14 +488,14 @@
       "steppedLine": false,
       "targets": [
         {
-          "expr": "max by (instance) (rate(node_network_transmit_bytes_total{device!~\"lo|monitoringvpn\"}[5m]) / node_network_speed_bytes)",
+          "expr": "max by (instance) (rate(node_network_transmit_bytes_total{device!~\"lo|monitoringvpn\"}[5m]) * 8)",
           "interval": "",
           "intervalFactor": 4,
           "legendFormat": "{{instance}} out",
           "refId": "A"
         },
         {
-          "expr": "- max by (instance) (rate(node_network_receive_bytes_total{device!~\"lo|monitoringvpn\"}[5m]) / node_network_speed_bytes)",
+          "expr": "- max by (instance) (rate(node_network_receive_bytes_total{device!~\"lo|monitoringvpn\"}[5m]) * 8)",
           "interval": "",
           "intervalFactor": 4,
           "legendFormat": "{{instance}} in",
@@ -510,7 +506,7 @@
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "Throughput %",
+      "title": "Throughput",
       "tooltip": {
         "shared": false,
         "sort": 2,
@@ -527,15 +523,17 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:226",
           "decimals": null,
-          "format": "percentunit",
+          "format": "bps",
           "label": null,
           "logBase": 1,
-          "max": "1",
-          "min": "-1",
+          "max": null,
+          "min": null,
           "show": true
         },
         {
+          "$$hashKey": "object:227",
           "format": "short",
           "label": null,
           "logBase": 1,
@@ -558,7 +556,7 @@
       "description": "Packet and error count. Positive values mean transmit, negative receive.",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -585,11 +583,10 @@
       "linewidth": 1,
       "nullPointMode": "null as zero",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -602,28 +599,28 @@
           "expr": "- rate(node_network_receive_packets_total{device!~\"lo|monitoringvpn\"}[5m])",
           "interval": "",
           "intervalFactor": 4,
-          "legendFormat": "{{instance}} {{device}}",
+          "legendFormat": "{{instance}} in",
           "refId": "A"
         },
         {
           "expr": "- rate(node_network_receive_errs_total{device!~\"lo|monitoringvpn\"}[5m])",
           "interval": "",
           "intervalFactor": 4,
-          "legendFormat": "{{instance}} {{device}}",
+          "legendFormat": "{{instance}} in err",
           "refId": "B"
         },
         {
           "expr": "rate(node_network_transmit_packets_total{device!~\"lo|monitoringvpn\"}[5m])",
           "interval": "",
           "intervalFactor": 4,
-          "legendFormat": "{{instance}} {{device}}",
+          "legendFormat": "{{instance}} out",
           "refId": "C"
         },
         {
           "expr": "rate(node_network_transmit_errs_total{device!~\"lo|monitoringvpn\"}[5m])",
           "interval": "",
           "intervalFactor": 4,
-          "legendFormat": "{{instance}} {{device}}",
+          "legendFormat": "{{instance}} out err",
           "refId": "D"
         }
       ],
@@ -647,7 +644,7 @@
       },
       "yaxes": [
         {
-          "format": "short",
+          "format": "pps",
           "label": null,
           "logBase": 1,
           "max": null,
@@ -781,7 +778,7 @@
       "description": "Network errors, drops etc. Should all be 0.",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -808,11 +805,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -852,7 +848,8 @@
           "fill": true,
           "line": true,
           "op": "gt",
-          "value": 10
+          "value": 10,
+          "visible": true
         }
       ],
       "timeFrom": null,
@@ -953,7 +950,7 @@
       "description": "Watch filesystems filling up. Shows only mounts over 10 % of available bytes used.",
       "fieldConfig": {
         "defaults": {
-          "custom": {},
+          "links": [],
           "unit": "percentunit"
         },
         "overrides": []
@@ -981,11 +978,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -1012,7 +1008,7 @@
           "line": true,
           "op": "gt",
           "value": 0.8,
-          "yaxis": "left"
+          "visible": true
         }
       ],
       "timeFrom": null,
@@ -1035,6 +1031,7 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:131",
           "format": "percentunit",
           "label": null,
           "logBase": 1,
@@ -1043,6 +1040,7 @@
           "show": true
         },
         {
+          "$$hashKey": "object:132",
           "format": "short",
           "label": null,
           "logBase": 1,
@@ -1065,7 +1063,7 @@
       "description": "Input Output Operations per second. Positive values mean read, negative write.",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -1092,11 +1090,10 @@
       "linewidth": 1,
       "nullPointMode": "null as zero",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -1170,7 +1167,7 @@
       "description": "Max average storage latency per node. Positive values mean read, negative write.",
       "fieldConfig": {
         "defaults": {
-          "custom": {}
+          "links": []
         },
         "overrides": []
       },
@@ -1197,11 +1194,10 @@
       "linewidth": 1,
       "nullPointMode": "null as zero",
       "options": {
-        "alertThreshold": true,
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "7.3.5",
+      "pluginVersion": "7.5.10",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -1268,14 +1264,14 @@
     }
   ],
   "refresh": "30s",
-  "schemaVersion": 20,
+  "schemaVersion": 27,
   "style": "dark",
   "tags": [],
   "templating": {
     "list": []
   },
   "time": {
-    "from": "now-1h",
+    "from": "now-3h",
     "to": "now"
   },
   "timepicker": {},
diff --git a/nixos/modules/packages.nix b/nixos/modules/packages.nix
index d6518dcf290c27b95e3428434623a63cfbdb8e19..c4390dc00f3948e04e3e90ef270261cc0dd1cdbb 100644
--- a/nixos/modules/packages.nix
+++ b/nixos/modules/packages.nix
@@ -1,8 +1,13 @@
 # A NixOS module which exposes custom packages to other modules.
 { pkgs, ...}:
-{
+let
+  ourpkgs = pkgs.callPackage ../../nixos/pkgs {};
+in {
   config = {
     # Expose `nixos/pkgs` as a new module argument `ourpkgs`.
-    _module.args.ourpkgs = pkgs.callPackage ../../nixos/pkgs {};
+    _module.args.ourpkgs = ourpkgs;
+    # Also expose it as a config setting, for usage by tests,
+    # since the `_module` config is not exposed in the result.
+    passthru.ourpkgs = ourpkgs;
   };
 }
diff --git a/nixos/modules/spending.nix b/nixos/modules/spending.nix
new file mode 100644
index 0000000000000000000000000000000000000000..238fbe8f939c4ddb0c78b9a34e106dbea8e39921
--- /dev/null
+++ b/nixos/modules/spending.nix
@@ -0,0 +1,139 @@
+# A NixOS module which can run a Ristretto-based issuer for PrivateStorage
+# ZKAPs.
+{ lib, pkgs, config, ourpkgs, ... }@args: let
+  cfg = config.services.private-storage-spending;
+in
+{
+  options = {
+    services.private-storage-spending = {
+      enable = lib.mkEnableOption "PrivateStorage Spending Service";
+      package = lib.mkOption {
+        default = ourpkgs.zkap-spending-service;
+        type = lib.types.package;
+        example = lib.literalExample "ourpkgs.zkap-spending-service";
+        description = ''
+          The package to use for the spending service.
+        '';
+      };
+      unixSocket = lib.mkOption {
+        default = "/run/zkap-spending-service/api.socket";
+        type = lib.types.path;
+        description = ''
+          The unix socket that the spending service API listens on.
+        '';
+      };
+    };
+    services.private-storage-spending.domain = lib.mkOption {
+      default = config.networking.fqdn;
+      type = lib.types.str;
+      example = lib.literalExample [ "spending.example.com" ];
+      description = ''
+        The domain name at which the spending service is reachable.
+      '';
+    };
+  };
+
+  config =
+    lib.mkIf cfg.enable {
+      systemd.sockets.zkap-spending-service = {
+        enable = true;
+        wantedBy = [ "sockets.target" ];
+        listenStreams = [ cfg.unixSocket ];
+      };
+      # Add a systemd service to run zkap-spending-service.
+      systemd.services.zkap-spending-service = {
+        enable = true;
+        description = "ZKAP Spending Service";
+        wantedBy = [ "multi-user.target" ];
+
+        serviceConfig.NonBlocking = true;
+
+        # It really shouldn't ever exit on its own!  If it does, it's a bug
+        # we'll have to fix.  Restart it and hope it doesn't happen too much
+        # before we can fix whatever the issue is.
+        serviceConfig.Restart = "always";
+        serviceConfig.Type = "simple";
+
+        # Use a unnamed user.
+        serviceConfig.DynamicUser = true;
+
+        serviceConfig = {
+          # Work around https://twistedmatrix.com/trac/ticket/10261
+          # Create a runtime directory so that the service has permission
+          # to change the mode on the socket.
+          RuntimeDirectory = "zkap-spending-service";
+
+          # This set of restrictions is mostly dervied from
+          # - running `systemd-analyze security zkap-spending-service.service
+          # - Looking at the restrictions from the nixos nginx config.
+          AmbientCapabilities = "";
+          CapabilityBoundingSet = "";
+          LockPersonality = true;
+          MemoryDenyWriteExecute = true;
+          NoNewPrivileges = true;
+          PrivateDevices = true;
+          PrivateMounts = true;
+          PrivateNetwork = true;
+          PrivateTmp = true;
+          PrivateUsers = true;
+          ProcSubset = "pid";
+          ProtectClock = true;
+          ProtectControlGroups = true;
+          ProtectHome = true;
+          ProtectHostname = true;
+          ProtectKernelLogs = true;
+          ProtectKernelModules = true;
+          ProtectKernelTunables = true;
+          ProtectProc = "invisible";
+          ProtectSystem = "strict";
+          RemoveIPC = true;
+          RestrictAddressFamilies = "AF_UNIX";
+          RestrictNamespaces = true;
+          RestrictRealtime = true;
+          RestrictSUIDSGID = true;
+          SystemCallArchitectures = "native";
+          # Lines starting with "~" are deny-list the others are allow-list
+          # Since the first line is allow, that bounds the set of allowed syscalls
+          # and the further lines restrict it.
+          SystemCallFilter = [
+            # From systemd.exec(5), @system-service is "A reasonable set of
+            # system calls used by common system [...]"
+            "@system-service"
+            # This is from the nginx config, except that `@ipc` is not removed,
+            # since twisted uses a self-pipe.
+            "~@cpu-emulation @debug @keyring @mount @obsolete @privileged @setuid"
+          ];
+          Umask = "0077";
+        };
+
+        script = let
+          httpArgs = "--http-endpoint systemd:domain=UNIX:index=0";
+        in
+          "exec ${cfg.package}/bin/${cfg.package.meta.mainProgram} run ${httpArgs}";
+      };
+
+      services.nginx = {
+        enable = true;
+
+        recommendedGzipSettings = true;
+        recommendedOptimisation = true;
+        recommendedProxySettings = true;
+        recommendedTlsSettings = true;
+
+        virtualHosts."${cfg.domain}" = {
+          locations."/v1/" = {
+            # Only forward requests beginning with /v1/ so
+            # we pass less scanning spam on to our backend
+            # Want a regex instead? try locations."~ /v\d+/"
+            proxyPass = "http://unix:${cfg.unixSocket}";
+          };
+          locations."/" = {
+            # Return a 404 error for any paths not specified above.
+            extraConfig = ''
+              return 404;
+            '';
+          };
+        };
+      };
+    };
+}
diff --git a/nixos/pkgs/default.nix b/nixos/pkgs/default.nix
index 3d534430377cb5fbbf0739d60a8a7ca9bb0419f6..efcff08333a8c28e110e95f01c6c284c2411b594 100644
--- a/nixos/pkgs/default.nix
+++ b/nixos/pkgs/default.nix
@@ -20,5 +20,6 @@ let
 in
 {
   zkapissuer = callPackage ./zkapissuer {};
+  zkap-spending-service = callPackage ./zkap-spending-service {};
   inherit (ourpkgs) privatestorage leasereport;
 }
diff --git a/nixos/pkgs/zkap-spending-service/default.nix b/nixos/pkgs/zkap-spending-service/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..4716109e7add7af74f032ee1668be1394cf05b17
--- /dev/null
+++ b/nixos/pkgs/zkap-spending-service/default.nix
@@ -0,0 +1,12 @@
+{ callPackage, fetchFromGitLab, lib }:
+let
+  repo-data = lib.importJSON ./repo.json;
+
+  repo = fetchFromGitLab (builtins.removeAttrs repo-data [ "branch" ]);
+in
+# We want to check the revision the service reports against the revsion
+# that we install. The upsream derivation doesn't currently know its own
+# version, but we do have it here. Thus, we add it as a meta attribute
+# to the derviation provided from upstream.
+lib.addMetaAttrs { inherit (repo-data) rev; }
+  (callPackage repo {})
diff --git a/nixos/pkgs/zkap-spending-service/repo.json b/nixos/pkgs/zkap-spending-service/repo.json
new file mode 100644
index 0000000000000000000000000000000000000000..39aeb8404c890e4781ee77f2a93d85d68acee5c3
--- /dev/null
+++ b/nixos/pkgs/zkap-spending-service/repo.json
@@ -0,0 +1,9 @@
+{
+  "owner": "privatestorage",
+  "repo": "zkap-spending-service",
+  "rev": "e0d63b79213d16f2de6629167ea8f1236ba22e14",
+  "branch": "main",
+  "domain": "whetstone.privatestorage.io",
+  "outputHash": "30abb0g9xxn4lp493kj5wmz8kj5q2iqvw40m8llqvb3zamx60gd8cy451ii7z15qbrbx9xmjdfw0k4gviij46fkx1s8nbich5c8qx57",
+  "outputHashAlgo": "sha512"
+}
diff --git a/nixos/system-tests.nix b/nixos/system-tests.nix
index 73b6665ab91e4d9a8a2200fb0eec7ff596f79b39..7b6d382ada53c1121a1bc3d0edbf82964d644ad2 100644
--- a/nixos/system-tests.nix
+++ b/nixos/system-tests.nix
@@ -3,5 +3,6 @@ let
   pkgs = import ../nixpkgs-2105.nix { };
 in {
   private-storage = pkgs.nixosTest ./tests/private-storage.nix;
+  spending = pkgs.nixosTest ./tests/spending.nix;
   tahoe = pkgs.nixosTest ./tests/tahoe.nix;
 }
diff --git a/nixos/tests/spending.nix b/nixos/tests/spending.nix
new file mode 100644
index 0000000000000000000000000000000000000000..c970157b9375e0d99e2be8d4f782992163a6c948
--- /dev/null
+++ b/nixos/tests/spending.nix
@@ -0,0 +1,32 @@
+{ pkgs, lib, ... }:
+{
+  name = "zkap-spending-service";
+  nodes = {
+    spending = { config, pkgs, ourpkgs, modulesPath, ... }: {
+      imports = [
+        ../modules/packages.nix
+        ../modules/spending.nix
+      ];
+
+      services.private-storage-spending.enable = true;
+      services.private-storage-spending.domain = "localhost";
+    };
+  };
+  testScript = { nodes }: let
+    revision = nodes.spending.config.passthru.ourpkgs.zkap-spending-service.meta.rev;
+    curl = "${pkgs.curl}/bin/curl -sSf";
+  in
+    ''
+      import json
+
+      start_all()
+
+      spending.wait_for_open_port(80)
+      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 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."
+    '';
+}
diff --git a/tools/default.nix b/tools/default.nix
index f9a0b1ff8d902f3072886939ad11e1e223ffbb7e..fb44c660e7d4a4a62cec6bb58a008a1bf00429dc 100644
--- a/tools/default.nix
+++ b/tools/default.nix
@@ -15,6 +15,7 @@ let
   };
   python-commands = [
     ./update-nixpkgs
+    ./update-gitlab-repo
   ];
 in
   # This derivation creates a package that wraps our tools to setup an environment
diff --git a/tools/update-gitlab-repo b/tools/update-gitlab-repo
new file mode 100755
index 0000000000000000000000000000000000000000..ddc82cb7bfd943ed3b4b80f79cf9e47b447c8b7d
--- /dev/null
+++ b/tools/update-gitlab-repo
@@ -0,0 +1,89 @@
+#!/usr/bin/env python
+
+"""
+Update a pinned gitlab 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://{domain}/api/v4/projects/{owner}%2F{repo}/repository/archive.tar.gz?sha={rev}"
+BRANCH_TEMPLATE = (
+    "https://{domain}/api/v4/projects/{owner}%2F{repo}/repository/branches/{branch}"
+)
+
+
+def get_gitlab_commit(config):
+    response = httpx.get(BRANCH_TEMPLATE.format(**config))
+    response.raise_for_status()
+    return response.json()["commit"]["id"]
+
+
+def get_gitlab_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_gitlab_commit(config)
+
+    archive_url = get_gitlab_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()