diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b91f7d5f175a32a49a5ed0788a87146d9b86c3f3..75a903ad3f8637ac5952c0fcb81bbddb72ca3314 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -8,7 +8,7 @@ default:
 docs:
   stage: "build"
   script:
-    - "nix-shell --run 'nix-build docs.nix'"
+    - "nix-build docs.nix"
     - "cp --recursive --no-preserve=mode result/docs/. docs/build/"
   artifacts:
     paths:
@@ -23,7 +23,7 @@ unit-tests:
 vulnerability-scan:
   stage: "test"
   script:
-    - "sed -i 's/undefined/\"unundefined\"/' morph/grid/local/secrets/users.nix"
+    - "sed -i 's/undefined/\"unundefined\"/' morph/grid/local/public-keys/users.nix"
     - "ci-tools/vulnerability-scan security-report.json"
     - "ci-tools/count-vulnerabilities <security-report.json"
   artifacts:
diff --git a/ci-tools/count-vulnerabilities b/ci-tools/count-vulnerabilities
index 9db1c5e7e3aa756dc5b151fbcc30bc4572dd1eba..b1d2b804d81c9c53c0f9a9b41e4e554978c0032d 100755
--- a/ci-tools/count-vulnerabilities
+++ b/ci-tools/count-vulnerabilities
@@ -1,4 +1,5 @@
-#!/usr/bin/env python3
+#! /usr/bin/env nix-shell
+#! nix-shell -i python3 -p python3
 
 from sys import stdin
 from json import load
diff --git a/docs.nix b/docs.nix
index 813a6cb432942fccd96b96ee07313ff84cf885c6..9bb64627870dfee01c6858d5699001cae96045c4 100644
--- a/docs.nix
+++ b/docs.nix
@@ -1,14 +1,2 @@
-{ pkgs ? import <nixpkgs> { } }:
-let
-  # NixOS 19.03 packaged graphviz has trouble rendering our architecture
-  # overview.  Latest from upstream does alright, though.  Use that.
-  make-graphviz = (import (pkgs.path + /pkgs/tools/graphics/graphviz/base.nix) {
-    rev = "b29d8e369011b832f72e0d250a05a0a15dcb5daa";
-    sha256 = "1w61filywn9cif2nryf6vd34mxxbvv25q34fd34am1rx70bk08ps";
-    version = "b29d8e369011b832f72e0d250a05a0a15dcb5daa";
-  });
-  graphviz = (pkgs.callPackage make-graphviz { }).overrideAttrs (old: {
-    patches = [];
-  });
-in
-  pkgs.callPackage ./privatestorageio.nix { inherit graphviz; }
+{ pkgs ? import ./stable2105.nix { } }:
+pkgs.callPackage ./privatestorageio.nix { }
diff --git a/docs/source/ops/generating-keys.rst b/docs/source/ops/generating-keys.rst
index 47a1f4e91a876ac1919252c099654886f0bd128a..c2f7028f2bc263c9e5bac40f78ca0adfb4861415 100644
--- a/docs/source/ops/generating-keys.rst
+++ b/docs/source/ops/generating-keys.rst
@@ -1,9 +1,10 @@
 Generating keys
 ===============
 
-There's an example ``secrets`` repo in ``morph/grid/local/secrets``.
+There are example ``public-keys`` and ``private-keys`` repos in ``morph/grid/local/``.
 ``<grid>/config.json`` has the paths for the key files for the respective grid.
-Create a symlink named ``secrets`` to your secret key repository for the deployment you are working on.
+Create a symlink ``private-keys`` to your secret key repositories for the deployment you are working on.
+Create a directory named ``public-keys`` containing the corresponding public keys for the deployment.
 
 
 Stripe
@@ -55,22 +56,6 @@ Move the three .pem files into the payment's server ``/var/lib/letsencrypt/live/
 Monitoring VPN
 ``````````````
 
-Create Wireguard VPN key pairs in ``secrets/monitoringvpn/`` or where you have them.
-
-``tools/create-vpn-keys.sh`` holds a script to rotate all VPN keys at once::
+Create all of the Wireguard VPN keys for a grid::
 
   ./tools/create-vpn-keys.sh morph/grid/testing/grid.nix
-
-Or do it manually::
-
-  cd secrets/monitoringvpn
-  for i in 1 11 12 13 ; do
-    wg genkey | tee 172.23.23.${i}.key | wg pubkey > 172.23.23.${i}.pub
-  done
-
-  ln -s 172.23.23.1.key server.key
-  ln -s 172.23.23.1.pub server.pub
-
-And a shared VPN key for "post-quantum resistance"::
-
-  wg genpsk > preshared.key
diff --git a/morph/README.rst b/morph/README.rst
index 12472518ad8e061764d6812694c306e87553c843..96d03eb3cf522af6f1b0065105a2d57ab5c78f6a 100644
--- a/morph/README.rst
+++ b/morph/README.rst
@@ -42,8 +42,8 @@ grid
 
 Specific grid definitions live in subdirectories beneath this directory.
 
-secrets
-~~~~~~~
+private-keys
+~~~~~~~~~~~~
 
 This must be created and populated before the grid can be built or deployed.
 
@@ -55,10 +55,44 @@ This path is **ignored** by git.
 The intended workflow is that the secrets will be maintained on secure storage and a symlink to the correct location created here.
 This keeps the secrets themselves out of the git working tree as an extra protection against unintentionally committing them.
 
-An exception is the ``secrets`` directory in the ``local`` morph grid:
+An exception is the ``private-keys`` directory in the ``local`` morph grid:
 That directory is fully populated, provided as an example, and mostly: not very secret.
 Do not deploy these keys to machines reachable via the internet.
 
+Strictly speaking,
+this path is configurable in the grid's ``config.json`` but all three grids currently use this name.
+
+public-keys
+~~~~~~~~~~~
+
+This must be created and populated before the grid can be built or deployed.
+
+This directory contains any public key material necessary for operation of the grid.
+This includes the public keys corresponding to any private keys held in ``private-keys``.
+
+As for ``private-keys``,
+this path can be configured in the grid's ``config.json``.
+
+Star-crossed Keys
+^^^^^^^^^^^^^^^^^
+
+Where the system uses keypairs,
+the public and private parts of those keypairs are stored in different locations
+(``public-keys`` and ``private-keys`` mentioned above).
+This somewhat complicates key management because any key rotation involves changing key material in two location instead of just one.
+
+This complication is balanced against a specific operational goal:
+that our build systems operate without copies of our private keys.
+Our system configurations do currently have build-time dependencies on public keys.
+
+Splitting public keys and private keys across two different storage locations provides a simple mechanism for providing build systems with the public keys but withholding the private keys.
+
+In the future we may:
+* be sufficiently confident in the security of our build systems to let them have our private keys; or
+* remove the dependency upon public keys from the build process.
+
+Either of these directions would let us re-unify public/private-key storage and remove this complication.
+
 config.json
 ~~~~~~~~~~~
 
diff --git a/morph/grid/local/README.rst b/morph/grid/local/README.rst
index 345547244635734278aa76cb5cd59946f2afd37f..d30d8766a4ef5a8db228ef38374330734e69cba7 100644
--- a/morph/grid/local/README.rst
+++ b/morph/grid/local/README.rst
@@ -33,18 +33,11 @@ Use the local development environment
 
     install -d ~/.ssh ; vagrant ssh-config >> ~/.ssh/config
 
-5. Edit the generated configuration: Add the ``publicIP`` addresses from ``grid.nix`` to ssh config **Host** match blocks (**not** HostName) so the ``Host`` lines all read like::
-
-    Host payments 192.168.67.21
-      HostName 127.0.0.1
-      User vagrant
-      [...]
-
   Latest Morph honors the ``SSH_CONFIG_FILE`` environment variable (`since 3f90aa88 (March 2020, v 1.5.0) <https://github.com/DBCDK/morph/commit/3f90aa885fac1c29fce9242452fa7c0c505744ef#diff-d155ad793bd62e6ea4c44ba985049ecb13a4f4f32f799791b2bce695a16c0101>`_), so in the future this should get a bit more convenient.
 
 6. Add your SSH key to ``users.nix`` so you'll be able to log in after deploying the new configuration::
 
-    $EDITOR secrets/users.nix
+    $EDITOR public-keys/users.nix
 
 7. Then, build and deploy our software to the Vagrant VMs::
 
@@ -56,4 +49,3 @@ Use the local development environment
     morph upload-secrets grid.nix
 
   You should now be able to log in with the users and keys you set in your ``users.nix`` file.
-
diff --git a/morph/grid/local/Vagrantfile b/morph/grid/local/Vagrantfile
index 7ad95ca872a72e5da6c11b3269e2a824cf8a55f9..a2890b8a63304d37002076b5804f2b322207160e 100644
--- a/morph/grid/local/Vagrantfile
+++ b/morph/grid/local/Vagrantfile
@@ -8,19 +8,19 @@ Vagrant.configure("2") do |config|
   # For a complete reference, please see the online documentation at
   # https://docs.vagrantup.com.
 
-  config.vm.define "payments" do |config|
+  config.vm.define "payments.localdev" do |config|
     config.vm.hostname = "payments"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
     config.vm.box_check_update = false
     config.vm.network "private_network", ip: "192.168.67.21"
     # Add self signed SSL key for zkap-issuer:
-    config.vm.provision "file", source: "secrets/payments-localdev-ssl", destination: "/tmp/payments-localdev-ssl"
+    config.vm.provision "file", source: "private-keys/payments-localdev-ssl", destination: "/tmp/payments-localdev-ssl"
     config.vm.provision "shell", inline: "sudo mkdir -p /var/lib/letsencrypt/live/payments.localdev/"
     config.vm.provision "shell", inline: "sudo mv /tmp/payments-localdev-ssl/* /var/lib/letsencrypt/live/payments.localdev/"
   end
 
-  config.vm.define "storage1" do |config|
+  config.vm.define "storage1.localdev" do |config|
     config.vm.hostname = "storage1"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
@@ -28,7 +28,7 @@ Vagrant.configure("2") do |config|
     config.vm.network "private_network", ip: "192.168.67.22"
   end
 
-  config.vm.define "storage2" do |config|
+  config.vm.define "storage2.localdev" do |config|
     config.vm.hostname = "storage2"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
@@ -36,7 +36,7 @@ Vagrant.configure("2") do |config|
     config.vm.network "private_network", ip: "192.168.67.23"
   end
 
-  config.vm.define "monitoring" do |config|
+  config.vm.define "monitoring.localdev" do |config|
     config.vm.hostname = "monitoring"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
@@ -54,4 +54,3 @@ Vagrant.configure("2") do |config|
   end
 
 end
-
diff --git a/morph/grid/local/config.json b/morph/grid/local/config.json
index 38f00367bf2fa36ad7663c89f7849146783b8515..9a929d2cf4613874379fdcc7a52f241c10f63f18 100644
--- a/morph/grid/local/config.json
+++ b/morph/grid/local/config.json
@@ -1,7 +1,7 @@
-{ "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "./secrets/stripe.secret"
-, "monitoringvpnKeyDir": "./secrets/monitoringvpn"
+{ "domain": "localdev"
+, "publicStoragePort": 8898
+, "publicKeyPath": "./public-keys"
+, "privateKeyPath": "./private-keys"
 , "monitoringvpnEndpoint": "192.168.67.24:51820"
 , "passValue": 1000000
 , "issuerDomains": ["payments.localdev"]
diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix
index fdc0cde55be4f1b644c212ce20f6c3e44af8e3df..bca902f20440e60e7d71162f273dd65d67317545 100644
--- a/morph/grid/local/grid.nix
+++ b/morph/grid/local/grid.nix
@@ -1,64 +1,76 @@
-# Load the helper function and call it with arguments tailored for the local
-# grid.  It will make the morph configuration for us.  We share this function
-# with the production grid and have one fewer possible point of divergence.
-import ../../lib/make-grid.nix {
-  name = "LocalDev";
-  config = ./config.json;
-  nodes = cfg:
-  let
-    sshUsers = import ./secrets/users.nix;
+let
+  pkgs = import <nixpkgs> { };
 
-    # Get absolute vpn key directory path, as a string:
-    monitoringvpnKeyDir = toString ./. + "/${cfg.monitoringvpnKeyDir}";
+  gridlib = import ../../lib;
+  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
+  config = rawConfig // {
+    sshUsers = import ./public-keys/users.nix;
 
-    # TBD: derive these automatically:
-    hostsMap = {
-      "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
-      "172.23.23.11" = [ "payments" "payments.monitoringvpn" ];
-      "172.23.23.12" = [ "storage1" "storage1.monitoringvpn" ];
-      "172.23.23.13" = [ "storage2" "storage2.monitoringvpn" ];
-    };
-    vpnClientIPs = [ "172.23.23.11" "172.23.23.12" "172.23.23.13" ];
-    nodeExporterTargets = [ "monitoring" "payments" "storage1" "storage2" ];
+    # Convert relative paths to absolute so library code can resolve names
+    # correctly.
+    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
+    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
+  };
+
+  payments = {
+    imports = [
+      gridlib.issuer
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.21"; }))
+      (gridlib.customize-issuer (config // {
+          monitoringvpnIPv4 = "172.23.23.11";
+      }))
+    ];
+  };
+
+  storage1 = {
+    imports = [
+      gridlib.storage
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.22"; }))
+      (gridlib.customize-storage (config // {
+        monitoringvpnIPv4 = "172.23.23.12";
+        stateVersion = "19.09";
+      }))
+    ];
+  };
 
-  in {
-    "payments" = import ../../lib/make-issuer.nix (cfg // rec {
-      publicIPv4 = "192.168.67.21";
-      monitoringvpnIPv4 = "172.23.23.11";
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.03";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+  storage2 = {
+    imports = [
+      gridlib.storage
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.23"; }))
+      (gridlib.customize-storage (config // {
+        monitoringvpnIPv4 = "172.23.23.13";
+        stateVersion = "19.09";
+      }))
+    ];
+  };
 
-    "storage1" = import ../../lib/make-testing.nix (cfg // rec {
-      publicIPv4 = "192.168.67.22";
-      monitoringvpnIPv4 = "172.23.23.12";
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.09";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+  monitoring = {
+    imports = [
+      gridlib.monitoring
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.24"; }))
+      (gridlib.customize-monitoring {
+        inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
+        inherit (config) domain publicKeyPath privateKeyPath;
+        monitoringvpnIPv4 = "172.23.23.1";
+        stateVersion = "19.09";
+      })
+    ];
+  };
 
-    "storage2" = import ../../lib/make-testing.nix (cfg // rec {
-      publicIPv4 = "192.168.67.23";
-      monitoringvpnIPv4 = "172.23.23.13";
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.09";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+  # TBD: derive these automatically:
+  hostsMap = {
+    "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
+    "172.23.23.11" = [ "payments" "payments.monitoringvpn" ];
+    "172.23.23.12" = [ "storage1" "storage1.monitoringvpn" ];
+    "172.23.23.13" = [ "storage2" "storage2.monitoringvpn" ];
+  };
+  vpnClientIPs = [ "172.23.23.11" "172.23.23.12" "172.23.23.13" ];
+  nodeExporterTargets = [ "monitoring" "payments" "storage1" "storage2" ];
+  paymentExporterTargets = [ "payments" ];
 
-    "monitoring" = import ../../lib/make-monitoring.nix (cfg // rec {
-      publicIPv4 = "192.168.67.24";
-      monitoringvpnIPv4 = "172.23.23.1";
-      inherit vpnClientIPs;
-      inherit hostsMap;
-      inherit nodeExporterTargets;
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.09";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+in {
+  network = {
+    description = "PrivateStorage.io LocalDev Grid";
   };
+  inherit payments monitoring storage1 storage2;
 }
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.11.key b/morph/grid/local/private-keys/monitoringvpn/172.23.23.11.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.11.key
rename to morph/grid/local/private-keys/monitoringvpn/172.23.23.11.key
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.12.key b/morph/grid/local/private-keys/monitoringvpn/172.23.23.12.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.12.key
rename to morph/grid/local/private-keys/monitoringvpn/172.23.23.12.key
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.13.key b/morph/grid/local/private-keys/monitoringvpn/172.23.23.13.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.13.key
rename to morph/grid/local/private-keys/monitoringvpn/172.23.23.13.key
diff --git a/morph/grid/local/secrets/monitoringvpn/preshared.key b/morph/grid/local/private-keys/monitoringvpn/preshared.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/preshared.key
rename to morph/grid/local/private-keys/monitoringvpn/preshared.key
diff --git a/morph/grid/local/secrets/monitoringvpn/server.key b/morph/grid/local/private-keys/monitoringvpn/server.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/server.key
rename to morph/grid/local/private-keys/monitoringvpn/server.key
diff --git a/morph/grid/local/secrets/payments-localdev-ssl/cert.pem b/morph/grid/local/private-keys/payments-localdev-ssl/cert.pem
similarity index 100%
rename from morph/grid/local/secrets/payments-localdev-ssl/cert.pem
rename to morph/grid/local/private-keys/payments-localdev-ssl/cert.pem
diff --git a/morph/grid/local/secrets/payments-localdev-ssl/chain.pem b/morph/grid/local/private-keys/payments-localdev-ssl/chain.pem
similarity index 100%
rename from morph/grid/local/secrets/payments-localdev-ssl/chain.pem
rename to morph/grid/local/private-keys/payments-localdev-ssl/chain.pem
diff --git a/morph/grid/local/secrets/payments-localdev-ssl/privkey.pem b/morph/grid/local/private-keys/payments-localdev-ssl/privkey.pem
similarity index 100%
rename from morph/grid/local/secrets/payments-localdev-ssl/privkey.pem
rename to morph/grid/local/private-keys/payments-localdev-ssl/privkey.pem
diff --git a/morph/grid/local/secrets/ristretto.signing-key b/morph/grid/local/private-keys/ristretto.signing-key
similarity index 100%
rename from morph/grid/local/secrets/ristretto.signing-key
rename to morph/grid/local/private-keys/ristretto.signing-key
diff --git a/morph/grid/local/secrets/stripe.secret b/morph/grid/local/private-keys/stripe.secret
similarity index 100%
rename from morph/grid/local/secrets/stripe.secret
rename to morph/grid/local/private-keys/stripe.secret
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.11.pub b/morph/grid/local/public-keys/monitoringvpn/172.23.23.11.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.11.pub
rename to morph/grid/local/public-keys/monitoringvpn/172.23.23.11.pub
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.12.pub b/morph/grid/local/public-keys/monitoringvpn/172.23.23.12.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.12.pub
rename to morph/grid/local/public-keys/monitoringvpn/172.23.23.12.pub
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.13.pub b/morph/grid/local/public-keys/monitoringvpn/172.23.23.13.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.13.pub
rename to morph/grid/local/public-keys/monitoringvpn/172.23.23.13.pub
diff --git a/morph/grid/local/secrets/monitoringvpn/server.pub b/morph/grid/local/public-keys/monitoringvpn/server.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/server.pub
rename to morph/grid/local/public-keys/monitoringvpn/server.pub
diff --git a/morph/grid/local/secrets/users.nix b/morph/grid/local/public-keys/users.nix
similarity index 83%
rename from morph/grid/local/secrets/users.nix
rename to morph/grid/local/public-keys/users.nix
index 93a8b660c78fa12b1e20c6d560f78efb1b5684c7..412077c0d5d6d98024036e369dfa552604f2dc57 100644
--- a/morph/grid/local/secrets/users.nix
+++ b/morph/grid/local/public-keys/users.nix
@@ -1,4 +1,4 @@
-# Add your public key. Example: 
+# Add your public key. Example:
 # let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHx7wJQNqKn8jOC4AxySRL2UxidNp7uIK9ad3pMb1ifF flo@fs-la";
 let key = undefined;
 in { "root" = key; "vagrant" = key; }
diff --git a/morph/grid/production/.gitignore b/morph/grid/production/.gitignore
index db2fc0de62d01d6d7eec83f8f3e8c3b13b20392a..e3b6111c86090b06c38b9e5afd1fcd16838ddf47 100644
--- a/morph/grid/production/.gitignore
+++ b/morph/grid/production/.gitignore
@@ -1 +1 @@
-secrets
+private-keys
diff --git a/morph/grid/production/config.json b/morph/grid/production/config.json
index ef7dc53649febcd7beb7901bb3608204df197059..092e4dff7b4c026c816afdd85b2a454089204141 100644
--- a/morph/grid/production/config.json
+++ b/morph/grid/production/config.json
@@ -1,7 +1,7 @@
-{ "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "./secrets/stripe.secret"
-, "monitoringvpnKeyDir": "./secrets/monitoringvpn"
+{ "domain": "private.storage"
+, "publicStoragePort": 8898
+, "privateKeyPath": "./private-keys"
+, "publicKeyPath": "./public-keys"
 , "monitoringvpnEndpoint": "monitoring.private.storage:51820"
 , "passValue": 1000000
 , "issuerDomains": [
diff --git a/morph/grid/production/grid.nix b/morph/grid/production/grid.nix
index fee0c9be6faed47d4a702b5b53c2419cbb677ba6..1aa605615dc26c9394f4f7143f5f50975107cffe 100644
--- a/morph/grid/production/grid.nix
+++ b/morph/grid/production/grid.nix
@@ -1,117 +1,114 @@
-# Load the helper function and call it with arguments tailored for the testing
-# grid.  It will make the morph configuration for us.  We share this function
-# with the testing grid and have one fewer possible point of divergence.
-import ../../lib/make-grid.nix {
-  name = "Production";
-  config = ./config.json;
-  nodes = cfg:
-    let
-      sshUsers = import ./secrets/users.nix;
+# See morph/grid/local/grid.nix for additional commentary.
+let
+  pkgs = import <nixpkgs> { };
 
-      # Get absolute vpn key directory path, as a string:
-      monitoringvpnKeyDir = toString ./. + "/${cfg.monitoringvpnKeyDir}";
+  gridlib = import ../../lib;
+  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
+  config = rawConfig // {
+    sshUsers = import ./public-keys/users.nix;
 
-      # TBD: derive these automatically:
-      hostsMap = {
-        "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
-        "172.23.23.11" = [   "payments"   "payments.monitoringvpn" ];
-        "172.23.23.21" = [ "storage001" "storage001.monitoringvpn" ];
-        "172.23.23.22" = [ "storage002" "storage002.monitoringvpn" ];
-        "172.23.23.23" = [ "storage003" "storage003.monitoringvpn" ];
-        "172.23.23.24" = [ "storage004" "storage004.monitoringvpn" ];
-        "172.23.23.25" = [ "storage005" "storage005.monitoringvpn" ];
-      };
-      vpnClientIPs = [
-        "172.23.23.11"
-        "172.23.23.21"
-        "172.23.23.22"
-        "172.23.23.23"
-        "172.23.23.24"
-        "172.23.23.25"
-      ];
-      nodeExporterTargets = [
-        "monitoring"
-        "payments"
-        "storage001"
-        "storage002"
-        "storage003"
-        "storage004"
-        "storage005"
-      ];
+    # Convert relative paths to absolute so library code can resolve names
+    # correctly.
+    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
+    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
+  };
 
-    in {
-    # Here are the hosts that are in this morph network.  This is sort of like
-    # a server manifest.  We try to keep as many of the specific details as
-    # possible out of *this* file so that this file only grows as server count
-    # grows.  If it grows too much, we can load servers by listing contents of
-    # a directory or reading from another JSON file or some such.  For now,
-    # I'm just manually maintaining these entries.
-    #
-    # The name on the left of the `=` is mostly irrelevant but it does provide
-    # a default hostname for the server if the configuration on the right side
-    # doesn't specify one.
-    #
-    # The names must be unique!
-    "payments.privatestorage.io" = import ../../lib/make-issuer.nix (cfg // {
-      publicIPv4 = "18.184.142.208";
-      monitoringvpnIPv4 = "172.23.23.11";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.03";
-    });
+  payments = {
+    imports = [
+      gridlib.issuer
+      gridlib.hardware-aws
+      (gridlib.customize-issuer (config // {
+        monitoringvpnIPv4 = "172.23.23.11";
+      }))
+    ];
+  };
 
-    "storage001" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage001-config.nix;
-        inherit sshUsers;
-        hardware = ./storage001-hardware.nix;
-        stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.21";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage002" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage002-config.nix;
-        inherit sshUsers;
-        hardware = ./storage002-hardware.nix;
-        stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.22";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage003" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage003-config.nix;
-        inherit sshUsers;
-        hardware = ./storage003-hardware.nix;
+  monitoring = {
+    imports = [
+      gridlib.monitoring
+      gridlib.hardware-aws
+      (gridlib.customize-monitoring {
+        inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
+        inherit (config) domain publicKeyPath privateKeyPath;
+        monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.23";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage004" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage004-config.nix;
-        inherit sshUsers;
-        hardware = ./storage004-hardware.nix;
-        stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.24";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage005" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage005-config.nix;
-        inherit sshUsers;
-        hardware = ./storage005-hardware.nix;
-        stateVersion = "19.03";
-        monitoringvpnIPv4 = "172.23.23.25";
-        inherit monitoringvpnKeyDir;
-    });
+      })
+    ];
+  };
+
+  defineStorageNode = name: { vpnIP, stateVersion }:
+  let
+    nodecfg = import "${./.}/${name}-config.nix";
+    hardware ="${./.}/${name}-hardware.nix";
+  in {
+    imports = [
+      # Get some of the very lowest-level system configuration for this
+      # node.  This isn't all *completely* hardware related.  Maybe some
+      # more factoring is in order, someday.
+      hardware
+
+      # Slightly awkwardly, enable some of our hardware / network / bootloader options.
+      ../../../nixos/modules/100tb.nix
+
+      # Get all of the configuration that is common across all storage nodes.
+      gridlib.storage
+
+      # Then customize the storage system a little bit based on this node's particulars.
+      (gridlib.customize-storage (config // nodecfg // {
+        monitoringvpnIPv4 = vpnIP;
+        inherit stateVersion;
+      }))
+    ];
+
+    # And supply configuration for those hardware / network / bootloader
+    # options.  See the 100tb module for handling of this value.  The module
+    # name is quoted because `1` makes `100tb` look an awful lot like a
+    # number.
+   "100tb".config = nodecfg;
+  };
+
+  # Define all of the storage nodes for this grid.
+  storageNodes = builtins.mapAttrs defineStorageNode {
+    storage001 = { vpnIP = "172.23.23.21"; stateVersion = "19.09"; };
+    storage002 = { vpnIP = "172.23.23.22"; stateVersion = "19.09"; };
+    storage003 = { vpnIP = "172.23.23.23"; stateVersion = "19.09"; };
+    storage004 = { vpnIP = "172.23.23.24"; stateVersion = "19.09"; };
+    storage005 = { vpnIP = "172.23.23.25"; stateVersion = "19.03"; };
+  };
+
+  # TBD: derive these automatically:
+  hostsMap = {
+    "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
+    "172.23.23.11" = [   "payments"   "payments.monitoringvpn" ];
+    "172.23.23.21" = [ "storage001" "storage001.monitoringvpn" ];
+    "172.23.23.22" = [ "storage002" "storage002.monitoringvpn" ];
+    "172.23.23.23" = [ "storage003" "storage003.monitoringvpn" ];
+    "172.23.23.24" = [ "storage004" "storage004.monitoringvpn" ];
+    "172.23.23.25" = [ "storage005" "storage005.monitoringvpn" ];
+  };
+  vpnClientIPs = [
+    "172.23.23.11"
+    "172.23.23.21"
+    "172.23.23.22"
+    "172.23.23.23"
+    "172.23.23.24"
+    "172.23.23.25"
+  ];
+  nodeExporterTargets = [
+    "monitoring"
+    "payments"
+    "storage001"
+    "storage002"
+    "storage003"
+    "storage004"
+    "storage005"
+  ];
+  paymentExporterTargets = [ "payments" ];
 
-    "monitoring" = import ../../lib/make-monitoring.nix (cfg // {
-      publicIPv4 = "monitoring.private.storage";
-      monitoringvpnIPv4 = "172.23.23.1";
-      inherit monitoringvpnKeyDir;
-      inherit vpnClientIPs;
-      inherit hostsMap;
-      inherit nodeExporterTargets;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.09";
-      inherit sshUsers;
-    });
+in {
+  network = {
+    description = "PrivateStorage.io Production Grid";
   };
-}
+  inherit payments;
+  inherit monitoring;
+} // storageNodes
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.1.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.1.pub
new file mode 100644
index 0000000000000000000000000000000000000000..79248b8afc2e5d58ce0e2829c34266d377e2ffa5
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.1.pub
@@ -0,0 +1 @@
+f4PF38t1ZRneFCV+12irDbMuG81WK6jiH0Ba+P+XtXM=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.11.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.11.pub
new file mode 100644
index 0000000000000000000000000000000000000000..c085058430258c7c5a4c3fe6a2a2e87ebce56543
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.11.pub
@@ -0,0 +1 @@
+yBdp154+SjyjTJM6ag1mbdnXORWrv/mJ01NJdkEe9VY=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.21.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.21.pub
new file mode 100644
index 0000000000000000000000000000000000000000..5c6351937d9d746d6c1e0ebca3439dc49a1f4574
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.21.pub
@@ -0,0 +1 @@
+G0//oetsCGa75x8rLsg98c9GT9a0ncf1yG9w2+5JV0M=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.22.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.22.pub
new file mode 100644
index 0000000000000000000000000000000000000000..1ec8fbe3f88c3d126b1c7a19a3c80ff55cedbe0c
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.22.pub
@@ -0,0 +1 @@
+Zq4OsMOTJ2NsVi00hB0x20mMqvoCrDUfleoI5rzIeEc=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.23.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.23.pub
new file mode 100644
index 0000000000000000000000000000000000000000..a5ce0ad526a0a0b949488304c05f0cc055695634
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.23.pub
@@ -0,0 +1 @@
+9ThSUgSNrykQEULj70QQyjlvtvGTmMPqsRMz8hc9xHA=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.24.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.24.pub
new file mode 100644
index 0000000000000000000000000000000000000000..c54c728a732d7ca083f9f5ac9e1cb7d82475101f
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.24.pub
@@ -0,0 +1 @@
+fPUnFOzBZRJDBdSR6iS5AaC40KKy/2REiM16hx+woxk=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.25.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.25.pub
new file mode 100644
index 0000000000000000000000000000000000000000..0ae6bb2adee18a318237aa020ab222be0b240aa9
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.25.pub
@@ -0,0 +1 @@
+qS4rT+zjWrbXDhtEF4oyGv8/5oCIE1ZU9FF+O6AL8V4=
diff --git a/morph/grid/production/public-keys/monitoringvpn/server.pub b/morph/grid/production/public-keys/monitoringvpn/server.pub
new file mode 120000
index 0000000000000000000000000000000000000000..0e74cbd09e33c4771cfecb7efea12650c8bd3b51
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/server.pub
@@ -0,0 +1 @@
+172.23.23.1.pub
\ No newline at end of file
diff --git a/morph/grid/production/public-keys/users.nix b/morph/grid/production/public-keys/users.nix
new file mode 100644
index 0000000000000000000000000000000000000000..8b586703740765b7a3d462e74ca3ef3cced68da7
--- /dev/null
+++ b/morph/grid/production/public-keys/users.nix
@@ -0,0 +1,2 @@
+let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGN4VQm3BIQKEFTw6aPrEwNuShf640N+Py2LOKznFCRT exarkun@bottom";
+in { "root" = key; "jcalderone" = key; }
diff --git a/morph/grid/production/storage001-hardware.nix b/morph/grid/production/storage001-hardware.nix
index 4cd9f59b76dd77b6e6e85709b3fbee771677b641..b2ca97c1db1b9721b93f2662d6e8d34189d5a0ab 100644
--- a/morph/grid/production/storage001-hardware.nix
+++ b/morph/grid/production/storage001-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/f72c1f46-6723-45bf-9ef7-92f31cc37589";
@@ -30,9 +31,12 @@
       fsType = "zfs";
     };
 
-  swapDevices =
-    [ { device = "/dev/disk/by-uuid/f986a811-4912-4e9a-8bc3-01cb6926c4c6"; }
-    ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
+
 
   nix.maxJobs = lib.mkDefault 24;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage002-hardware.nix b/morph/grid/production/storage002-hardware.nix
index 4fc3a4097e05ec8c38c86db6bfce92e2a1af6f35..2f354ad29930f048f7eb20b54a1504ed87db85a1 100644
--- a/morph/grid/production/storage002-hardware.nix
+++ b/morph/grid/production/storage002-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/0e92ada9-effb-42e2-a26a-9cdb529bcdc7";
@@ -30,9 +31,11 @@
       fsType = "ext4";
     };
 
-  swapDevices =
-    [ { device = "/dev/disk/by-uuid/f762b5e2-bbdd-4a02-bbd9-0bf6b11e0ab5"; }
-    ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 24;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage003-hardware.nix b/morph/grid/production/storage003-hardware.nix
index 9882f5372cecd52794e1500bdef30e367008496e..d8ffe5d59fb39ba4a9c6b1b73313f199a2ed980b 100644
--- a/morph/grid/production/storage003-hardware.nix
+++ b/morph/grid/production/storage003-hardware.nix
@@ -13,6 +13,7 @@
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
   boot.supportedFilesystems = [ "zfs" ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/240fc1f6-cd55-48a3-ac80-5b3550a32ef5";
@@ -31,7 +32,11 @@
       fsType = "zfs";
     };
 
-  swapDevices = [ ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 24;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage004-hardware.nix b/morph/grid/production/storage004-hardware.nix
index 07de74e20ef58ab474b02248bcb6eed6189e1079..1fe78a76e813605d8e181d5a858062f77114ba38 100644
--- a/morph/grid/production/storage004-hardware.nix
+++ b/morph/grid/production/storage004-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/d628122e-05d9-4212-b6a5-4b9516d85dbe";
@@ -25,7 +26,11 @@
       fsType = "zfs";
     };
 
-  swapDevices = [ ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 32;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage005-hardware.nix b/morph/grid/production/storage005-hardware.nix
index 9a5ad02725e30b00619978035772d60bec9fcb8a..e8f7b6391b4cb1c8d3e6059c1fd09512a0cc370b 100644
--- a/morph/grid/production/storage005-hardware.nix
+++ b/morph/grid/production/storage005-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/2653c6bb-396f-4911-b9ff-b68de8f9715d";
@@ -30,7 +31,11 @@
     fsType = "zfs";
   };
 
-  swapDevices = [ ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 32;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/testing/.gitignore b/morph/grid/testing/.gitignore
index db2fc0de62d01d6d7eec83f8f3e8c3b13b20392a..e3b6111c86090b06c38b9e5afd1fcd16838ddf47 100644
--- a/morph/grid/testing/.gitignore
+++ b/morph/grid/testing/.gitignore
@@ -1 +1 @@
-secrets
+private-keys
diff --git a/morph/grid/testing/config.json b/morph/grid/testing/config.json
index a44b465f7f293f9d70c369a076c30b6cf810924f..8b94959557364d8af8f1f4aa61c5647b46db9932 100644
--- a/morph/grid/testing/config.json
+++ b/morph/grid/testing/config.json
@@ -1,7 +1,7 @@
-{ "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "./secrets/stripe.secret"
-, "monitoringvpnKeyDir": "./secrets/monitoringvpn"
+{ "domain": "privatestorage-staging.com"
+, "publicStoragePort": 8898
+, "privateKeyPath": "./private-keys"
+, "publicKeyPath": "./public-keys"
 , "monitoringvpnEndpoint": "monitoring.privatestorage-staging.com:51820"
 , "passValue": 1000000
 , "issuerDomains": [
diff --git a/morph/grid/testing/grid.nix b/morph/grid/testing/grid.nix
index e31a28f2eb7817f393f4e8b6b71972b7fd2f79f1..996b1fba0bd2c12c22b00f549aa26c8b8472653d 100644
--- a/morph/grid/testing/grid.nix
+++ b/morph/grid/testing/grid.nix
@@ -1,54 +1,65 @@
-# Load the helper function and call it with arguments tailored for the testing
-# grid.  It will make the morph configuration for us.  We share this function
-# with the production grid and have one fewer possible point of divergence.
-import ../../lib/make-grid.nix {
-  name = "Testing";
-  config = ./config.json;
-  nodes = cfg:
-  let
-    sshUsers = import ./secrets/users.nix;
+# See morph/grid/local/grid.nix for additional commentary.
+let
+  pkgs = import <nixpkgs> { };
 
-    # Get absolute vpn key directory path, as a string:
-    monitoringvpnKeyDir = toString ./. + "/${cfg.monitoringvpnKeyDir}";
+  gridlib = import ../../lib;
+  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
+  config = rawConfig // {
+    sshUsers = import ./public-keys/users.nix;
 
-    # TBD: derive these automatically:
-    hostsMap = {
-      "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
-      "172.23.23.11" = [ "payments"   "payments.monitoringvpn"   ];
-      "172.23.23.12" = [ "storage001" "storage001.monitoringvpn" ];
-    };
-    vpnClientIPs = [ "172.23.23.11" "172.23.23.12" ];
-    nodeExporterTargets = [ "monitoring" "payments" "storage001" ];
+    # Convert relative paths to absolute so library code can resolve names
+    # correctly.
+    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
+    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
+  };
+
+  payments = {
+    imports = [
+      gridlib.issuer
+      gridlib.hardware-aws
+      (gridlib.customize-issuer (config // {
+        monitoringvpnIPv4 = "172.23.23.11";
+      }))
+    ];
+  };
 
-  in {
-    "payments" = import ../../lib/make-issuer.nix (cfg // {
-      publicIPv4 = "18.194.183.13";
-      monitoringvpnIPv4 = "172.23.23.11";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.03";
-    });
+  storage001 = {
+    imports = [
+      gridlib.storage
+      ./testing001-hardware.nix
+      (gridlib.customize-storage (config // {
+        monitoringvpnIPv4 = "172.23.23.12";
+        stateVersion = "19.03";
+      }))
+    ];
+  };
 
-    "storage001" = import ../../lib/make-testing.nix (cfg // {
-      publicIPv4 = "3.120.26.190";
-      monitoringvpnIPv4 = "172.23.23.12";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-      hardware = ./testing001-hardware.nix;
-      stateVersion = "19.03";
-    });
+  monitoring = {
+    imports = [
+      gridlib.monitoring
+      gridlib.hardware-aws
+      (gridlib.customize-monitoring {
+        inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
+        inherit (config) domain publicKeyPath privateKeyPath;
+        monitoringvpnIPv4 = "172.23.23.1";
+        stateVersion = "19.09";
+      })
+    ];
+  };
+
+  # TBD: derive these automatically:
+  hostsMap = {
+    "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
+    "172.23.23.11" = [ "payments"   "payments.monitoringvpn"   ];
+    "172.23.23.12" = [ "storage001" "storage001.monitoringvpn" ];
+  };
+  vpnClientIPs = [ "172.23.23.11" "172.23.23.12" ];
+  nodeExporterTargets = [ "monitoring" "payments" "storage001" ];
+  paymentExporterTargets = [ "payments" ];
 
-    "monitoring" = import ../../lib/make-monitoring.nix (cfg // {
-      publicIPv4 = "18.156.171.217";
-      monitoringvpnIPv4 = "172.23.23.1";
-      inherit monitoringvpnKeyDir;
-      inherit vpnClientIPs;
-      inherit hostsMap;
-      inherit nodeExporterTargets;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.09";
-      inherit sshUsers;
-    });
+in {
+  network = {
+    description = "PrivateStorage.io Testing Grid";
   };
+  inherit payments monitoring storage001;
 }
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.1.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.1.pub
new file mode 100644
index 0000000000000000000000000000000000000000..94e7f1592034419c8a561531811bd6e63241271c
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.1.pub
@@ -0,0 +1 @@
+iVS3L2DkH/pHAhiPpuduBMKlICPYmchHFfCg6n2ReUI=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.11.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.11.pub
new file mode 100644
index 0000000000000000000000000000000000000000..ed5b6822bd633df6b704fa0eda0e9250d4b198e2
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.11.pub
@@ -0,0 +1 @@
+sGUEH9+Mli1E1BFBMAHgPsnVlaD1EJKFaYOJ+dpyLy0=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.12.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.12.pub
new file mode 100644
index 0000000000000000000000000000000000000000..0c79d3a917db9f5caed071eabeae9d4974d660db
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.12.pub
@@ -0,0 +1 @@
+wvpkXigLG2zvmLhxsV2cmN/IgF+nLednV6uENvI6fh0=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.13.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.13.pub
new file mode 100644
index 0000000000000000000000000000000000000000..31fd40caf83b95fd8e4566af21b9d6e59a70e629
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.13.pub
@@ -0,0 +1 @@
+5t9t6DOcYMQJNtnsG5/Ek+OmSX1mZgbMAHSWlJQKuxc=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/server.pub b/morph/grid/testing/public-keys/monitoringvpn/server.pub
new file mode 120000
index 0000000000000000000000000000000000000000..0e74cbd09e33c4771cfecb7efea12650c8bd3b51
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/server.pub
@@ -0,0 +1 @@
+172.23.23.1.pub
\ No newline at end of file
diff --git a/morph/grid/testing/public-keys/users.nix b/morph/grid/testing/public-keys/users.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d6a965011065cfe39713adfb797c190eb8dd1ecd
--- /dev/null
+++ b/morph/grid/testing/public-keys/users.nix
@@ -0,0 +1,9 @@
+let
+  jcalderone = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN4GenAY/YLGuf1WoMXyyVa3S9i4JLQ0AG+pt7nvcLlQ exarkun@baryon";
+  flo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHx7wJQNqKn8jOC4AxySRL2UxidNp7uIK9ad3pMb1ifF flo@fs-la";
+in
+  {
+    "root" = jcalderone;
+    inherit jcalderone;
+    inherit flo;
+  }
diff --git a/morph/grid/testing/testing001-hardware.nix b/morph/grid/testing/testing001-hardware.nix
index 958a247862a7e4bb2581e7d1bb85cc0f85f3ea24..dd2f9733796875bb9d8a549538cba5743a8727a3 100644
--- a/morph/grid/testing/testing001-hardware.nix
+++ b/morph/grid/testing/testing001-hardware.nix
@@ -1,6 +1,13 @@
 {
   imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
   ec2.hvm = true;
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
+
 
   boot.supportedFilesystems = [ "zfs" ];
   networking.hostId = "10000000";
diff --git a/morph/lib/customize-issuer.nix b/morph/lib/customize-issuer.nix
new file mode 100644
index 0000000000000000000000000000000000000000..1c0d668fbd4ae59bab115c2116b7fa377395dcfc
--- /dev/null
+++ b/morph/lib/customize-issuer.nix
@@ -0,0 +1,94 @@
+# Define a function which returns a value which fills in all the holes left by
+# ``issuer.nix``.
+{
+  # A path on the deployment system of a directory containing all of the
+  # public keys for the system.  For example, this holds Wireguard public keys
+  # for the VPN configuration and SSH public keys to configure SSH
+  # authentication.
+  publicKeyPath
+
+  # A path on the deployment system of a directory containing all of the
+  # corresponding private keys for the system.
+, privateKeyPath
+
+  # A string giving the IP address and port number (":"-separated) of the VPN
+  # server.
+, monitoringvpnEndpoint
+
+  # A string giving the VPN IPv4 address for this system.
+, monitoringvpnIPv4
+
+  # A string giving the domain name associated with this grid.  This is meant
+  # to be combined with the hostname for this system to produce a
+  # fully-qualified domain name.  For example, an issuer might have "payments"
+  # as its hostname and belong to a grid with the domain
+  # "example-grid.invalid".  This ``domain`` parameter should have the value
+  # ``"example-grid.invalid"`` for the system figure out that
+  # ``payments.example-grid.invalid`` is the name of this system.
+, domain
+
+  # A set mapping usernames as strings to SSH public keys as strings.  For
+  # each element of the site, the indicated user is configured on the system
+  # with the indicated SSH key as an authorized key.
+, sshUsers
+
+  # A string giving an email address to use for Let's Encrypt registration and
+  # certificate issuance.
+, letsEncryptAdminEmail
+
+  # A list of strings giving the domain names that point at this issuer
+  # system.  These will all be included in Let's Encrypt certificate.
+, issuerDomains
+
+  # A list of strings giving CORS Origins will the issuer will be configured
+  # to allow.
+, allowedChargeOrigins
+, ...
+}:
+{ config, ... }: {
+  # The morph default deployment target the name of the node in the network
+  # attrset.  We don't always want to give the node its proper public address
+  # there (because it depends on which domain is associated with the grid
+  # being configured and using variable names complicates a lot of things).
+  # Instead, just tell morph how to reach the node here - by using its fully
+  # qualified domain name.
+  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
+
+  deployment.secrets = {
+    # A path on the deployment system to a file containing the Ristretto
+    # signing key.  This is used as the source of the Ristretto signing key
+    # morph secret.
+    "ristretto-signing-key".source = "${privateKeyPath}/ristretto.signing-key";
+
+    # A path on the deployment system to a file containing the Stripe secret
+    # key.  This is used as the source of the Stripe secret key morph secret.
+    "stripe-secret-key".source = "${privateKeyPath}/stripe.secret";
+
+    # ``.../monitoringvpn`` is a path on the deployment system of a directory
+    # containing a number of VPN-related secrets.  This is expected to contain
+    # a number of files named like ``<VPN IPv4 address>.key`` containing the
+    # VPN private key for the corresponding host.  It must also contain
+    # ``server.pub`` and ``preshared.key`` holding the VPN server's public key
+    # and the pre-shared key, respectively.  All of these things are used as
+    # the sources of various VPN-related morph secrets.
+    "monitoringvpn-secret-key".source = "${privateKeyPath}/monitoringvpn/${monitoringvpnIPv4}.key";
+    "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
+  };
+
+  networking.domain = domain;
+
+  services.private-storage.sshUsers = sshUsers;
+  services.private-storage.monitoring.vpn.client = {
+    enable = true;
+    ip = monitoringvpnIPv4;
+    endpoint = monitoringvpnEndpoint;
+    endpointPublicKeyFile = "${publicKeyPath}/monitoringvpn/server.pub";
+  };
+
+  services.private-storage-issuer = {
+    inherit letsEncryptAdminEmail allowedChargeOrigins;
+    domains = issuerDomains;
+  };
+
+  system.stateVersion = "19.03";
+}
diff --git a/morph/lib/customize-monitoring.nix b/morph/lib/customize-monitoring.nix
new file mode 100644
index 0000000000000000000000000000000000000000..05fe45107e44c583c495ee55aeb9e351ba3871f1
--- /dev/null
+++ b/morph/lib/customize-monitoring.nix
@@ -0,0 +1,63 @@
+# Define a function which returns a value which fills in all the holes left by
+# ``monitoring.nix``.
+{
+  # A set mapping VPN IP addresses as strings to lists of hostnames as
+  # strings.  The system's ``/etc/hosts`` will be populated with this
+  # information.  Apart from helping with normal forward resolution, this
+  # *also* gives us reverse resolution from the VPN IPs to hostnames which
+  # allows Grafana to show us hostnames instead of VPN IP addresses.
+  hostsMap
+
+  # See ``customize-issuer.nix``.
+, publicKeyPath
+, privateKeyPath
+, monitoringvpnIPv4
+, domain
+
+  # A list of VPN IP addresses as strings indicating which clients will be
+  # allowed onto the VPN.
+, vpnClientIPs
+
+  # A list of VPN clients (IP addresses or hostnames) as strings indicating
+  # which nodes to scrape "nodeExporter" metrics from.
+, nodeExporterTargets
+
+  # A list of VPN clients (IP addresses or hostnames) as strings indicating
+  # which nodes to scrape "nginxExporter" metrics from.
+, nginxExporterTargets ? []
+
+  # A list of VPN clients (IP addresses or hostnames) as strings indicating
+  # which nodes to scrape PaymentServer metrics from.
+, paymentExporterTargets ? []
+
+  # A string giving the NixOS state version for the system.
+, stateVersion
+, ...
+}:
+{ config, ... }: {
+  # See customize-issuer.nix for an explanatoin of targetHost value.
+  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
+
+  deployment.secrets = {
+    "monitoringvpn-private-key".source = "${privateKeyPath}/monitoringvpn/server.key";
+    "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
+  };
+
+  networking.domain = domain;
+  networking.hosts = hostsMap;
+
+  services.private-storage.monitoring.vpn.server = {
+    enable = true;
+    ip = monitoringvpnIPv4;
+    inherit vpnClientIPs;
+    pubKeysPath = "${publicKeyPath}/monitoringvpn";
+  };
+
+  services.private-storage.monitoring.prometheus = {
+    inherit nodeExporterTargets;
+    inherit nginxExporterTargets;
+    inherit paymentExporterTargets;
+  };
+
+  system.stateVersion = stateVersion;
+}
diff --git a/morph/lib/customize-storage.nix b/morph/lib/customize-storage.nix
new file mode 100644
index 0000000000000000000000000000000000000000..68655874efd9ba39b52dacfdddaedb54863ed769
--- /dev/null
+++ b/morph/lib/customize-storage.nix
@@ -0,0 +1,47 @@
+# Define a function which returns a value which fills in all the holes left by
+# ``storage.nix``.
+{
+  # See ``customize-issuer.nix``
+  privateKeyPath
+, publicKeyPath
+, monitoringvpnEndpoint
+, monitoringvpnIPv4
+, sshUsers
+, domain
+
+  # An integer giving the value of a single pass in byte×months.
+, passValue
+
+  # An integer giving the port number to include in Tahoe storage service
+  # advertisements and on which to listen for storage connections.
+, publicStoragePort
+
+  # A string giving the NixOS state version for the system.
+, stateVersion
+, ...
+}:
+{ config, ... }: {
+  # See customize-issuer.nix for an explanatoin of targetHost value.
+  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
+
+  deployment.secrets = {
+    "ristretto-signing-key".source = "${privateKeyPath}/ristretto.signing-key";
+    "monitoringvpn-secret-key".source = "${privateKeyPath}/monitoringvpn/${monitoringvpnIPv4}.key";
+    "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
+  };
+
+  networking.domain = domain;
+
+  services.private-storage = {
+    inherit sshUsers passValue publicStoragePort;
+  };
+
+  services.private-storage.monitoring.vpn.client = {
+    enable = true;
+    ip = monitoringvpnIPv4;
+    endpoint = monitoringvpnEndpoint;
+    endpointPublicKeyFile = "${publicKeyPath}/monitoringvpn/server.pub";
+  };
+
+  system.stateVersion = stateVersion;
+}
diff --git a/morph/lib/default.nix b/morph/lib/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..bdd92f4bfe52eba2e19df3ac73a087a4af4a53dc
--- /dev/null
+++ b/morph/lib/default.nix
@@ -0,0 +1,16 @@
+# Gather up the grid library functionality and present it in a (somewhat)
+# coherent public interface.  Application code should prefer these names over
+# directly importing the source files in this directory.
+{
+  hardware-aws = import ./issuer-aws.nix;
+  hardware-virtual = import ./hardware-virtual.nix;
+
+  issuer = import ./issuer.nix;
+  customize-issuer = import ./customize-issuer.nix;
+
+  storage = import ./storage.nix;
+  customize-storage = import ./customize-storage.nix;
+
+  monitoring = import ./monitoring.nix;
+  customize-monitoring = import ./customize-monitoring.nix;
+}
diff --git a/morph/grid/local/virtual-hardware.nix b/morph/lib/hardware-virtual.nix
similarity index 95%
rename from morph/grid/local/virtual-hardware.nix
rename to morph/lib/hardware-virtual.nix
index d5e9067bd5f3b3ca2ea1bb46746253fa39b25cf6..cf1582792bff77c491210ee5e91f99bfbffbf9f3 100644
--- a/morph/grid/local/virtual-hardware.nix
+++ b/morph/lib/hardware-virtual.nix
@@ -11,6 +11,7 @@
 
   boot.initrd.availableKernelModules = [ "ata_piix" "sd_mod" "sr_mod" ];
   boot.initrd.kernelModules = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
   boot.kernelModules = [ ];
   boot.extraModulePackages = [ ];
 
@@ -33,4 +34,3 @@
   # We want to push packages with morph without having to sign them
   nix.trustedUsers = [ "@wheel" "root" "vagrant" ];
 }
-
diff --git a/morph/lib/issuer-aws.nix b/morph/lib/issuer-aws.nix
index b4d4757ad5597b69363ef12e4297aec80913f00e..3febd796b3eceb5a2da73e4874d52d50767da947 100644
--- a/morph/lib/issuer-aws.nix
+++ b/morph/lib/issuer-aws.nix
@@ -1,4 +1,15 @@
-{
+{ lib, ... }: {
   imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
+
+  # amazon-image.nix isn't quite aware of nvme-attached storage so give it a
+  # little help configuring grub.
+  boot.loader.grub.device = lib.mkForce "/dev/nvme0n1";
+
   ec2.hvm = true;
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 }
diff --git a/morph/lib/issuer.nix b/morph/lib/issuer.nix
new file mode 100644
index 0000000000000000000000000000000000000000..417ef7965ea0120322995059fcca7a5a9afe2543
--- /dev/null
+++ b/morph/lib/issuer.nix
@@ -0,0 +1,56 @@
+# This is all of the static NixOS system configuration necessary to specify an
+# "issuer"-type system.  The configuration has various holes in it which must
+# be filled somehow.  These holes correspond to configuration which is not
+# statically known.  This value is suitable for use as a module to be imported
+# into a more complete system configuration.  It is expected that the holes
+# will be filled by a sibling module created by ``customize-issuer.nix``.
+rec {
+  deployment = {
+    secrets = {
+      "ristretto-signing-key" = {
+        destination = "/run/keys/ristretto.signing-key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
+      };
+      "stripe-secret-key" = {
+        destination = "/run/keys/stripe.secret-key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
+      };
+
+      "monitoringvpn-secret-key" = {
+        destination = "/run/keys/monitoringvpn/client.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+      "monitoringvpn-preshared-key" = {
+        destination = "/run/keys/monitoringvpn/preshared.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+    };
+  };
+
+  imports = [
+    ../../nixos/modules/issuer.nix
+    ../../nixos/modules/monitoring/vpn/client.nix
+    ../../nixos/modules/monitoring/exporters/node.nix
+  ];
+
+  services.private-storage-issuer = {
+    enable = true;
+    tls = true;
+    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
+    stripeSecretKeyPath = deployment.secrets.stripe-secret-key.destination;
+    database = "SQLite3";
+    databasePath = "/var/db/vouchers.sqlite3";
+  };
+}
diff --git a/morph/lib/make-grid.nix b/morph/lib/make-grid.nix
deleted file mode 100644
index de10df1e9a62ee0ac7fde98070743ee4a9cf484b..0000000000000000000000000000000000000000
--- a/morph/lib/make-grid.nix
+++ /dev/null
@@ -1,19 +0,0 @@
-# Define a function for making a morph configuration for a storage grid.  It
-# takes two arguments.  A string like "Production" giving the name of the grid
-# and a function that takes the grid configuration as an argument and returns
-# a set of nodes specifying the addresses and NixOS configurations for each
-# server in the morph network.
-{ name, config, nodes }:
-let
-  pkgs = import <nixpkgs> { };
-  # Load our JSON configuration for later use.
-  cfg = pkgs.lib.trivial.importJSON config;
-in
-{
-  network =  {
-    # Make all of the hosts in this network use the nixpkgs we pinned above.
-    inherit pkgs;
-    # This is just for human consumption as far as I can tell.
-    description = "PrivateStorage.io ${name} Grid";
-  };
-} // (nodes cfg)
diff --git a/morph/lib/make-issuer.nix b/morph/lib/make-issuer.nix
deleted file mode 100644
index bbdf0cebbf770738e9ccb997daec75e58df021b5..0000000000000000000000000000000000000000
--- a/morph/lib/make-issuer.nix
+++ /dev/null
@@ -1,91 +0,0 @@
-{ hardware
-, ristrettoSigningKeyPath
-, stripeSecretKeyPath
-, issuerDomains
-, letsEncryptAdminEmail
-, allowedChargeOrigins
-, sshUsers
-, stateVersion
-, publicIPv4
-, monitoringvpnKeyDir ? null
-, monitoringvpnIPv4 ? null
-, monitoringvpnEndpoint ? null
-, ...
-}: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              monitoringvpnEndpoint != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-secret-key" = {
-      source = monitoringvpnKeyDir + "/${monitoringvpnIPv4}.key";
-      destination = "/run/keys/monitoringvpn/client.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-  deployment = {
-    targetHost = publicIPv4;
-
-    secrets = {
-      "ristretto-signing-key" = {
-        source = ristrettoSigningKeyPath;
-        destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
-      };
-      "stripe-secret-key" = {
-        source = stripeSecretKeyPath;
-        destination = "/run/keys/stripe.secret-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
-      };
-    } // vpnSecrets;
-  };
-
-  imports = [
-    hardware
-    ../../nixos/modules/issuer.nix
-    ../../nixos/modules/monitoring/vpn/client.nix
-    ../../nixos/modules/monitoring/exporters/node.nix
-  ];
-
-  services.private-storage.sshUsers = sshUsers;
-  services.private-storage-issuer = {
-    enable = true;
-    tls = true;
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    stripeSecretKeyPath = deployment.secrets.stripe-secret-key.destination;
-    database = "SQLite3";
-    databasePath = "/var/db/vouchers.sqlite3";
-    inherit letsEncryptAdminEmail;
-    domains = issuerDomains;
-    inherit allowedChargeOrigins;
-  };
-
-  system.stateVersion = stateVersion;
-
-  services.private-storage.monitoring.vpn.client = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    endpoint = monitoringvpnEndpoint;
-    endpointPublicKeyFile = monitoringvpnKeyDir + "/server.pub";
-  };
-}
diff --git a/morph/lib/make-monitoring.nix b/morph/lib/make-monitoring.nix
deleted file mode 100644
index 592a859657e624e8fdf5632f8144c5acc6919e8c..0000000000000000000000000000000000000000
--- a/morph/lib/make-monitoring.nix
+++ /dev/null
@@ -1,77 +0,0 @@
-{ publicIPv4
-, hardware
-, publicStoragePort
-, ristrettoSigningKeyPath
-, passValue
-, sshUsers
-, stateVersion
-, monitoringvpnIPv4 ? null
-, monitoringvpnKeyDir ? null
-, vpnClientIPs ? null
-, nodeExporterTargets ? []
-, nginxExporterTargets ? []
-, hostsMap ? {}
-, ... }: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              vpnClientIPs != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-private-key" = {
-      source = monitoringvpnKeyDir + "/server.key";
-      destination = "/run/keys/monitoringvpn/server.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-
-  deployment = {
-    targetHost = publicIPv4;
-    secrets = vpnSecrets;
-  };
-
-  imports = [
-    hardware
-    ../../nixos/modules/monitoring/vpn/server.nix
-    ../../nixos/modules/monitoring/server/grafana.nix
-    ../../nixos/modules/monitoring/server/prometheus.nix
-    ../../nixos/modules/monitoring/exporters/node.nix
-    # Loki 0.3.0 from Nixpkgs 19.09 is too old and does not work:
-    # ../../nixos/modules/monitoring/server/loki.nix
-  ];
-
-  services.private-storage.monitoring.vpn.server = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    inherit vpnClientIPs;
-    pubKeysPath = monitoringvpnKeyDir;
-  };
-
-  services.private-storage.monitoring.grafana = {
-    domain = "monitoring.private.storage";
-    prometheusUrl = "http://localhost:9090/";
-    lokiUrl = "http://localhost:3100/";
-  };
-
-  services.private-storage.monitoring.prometheus = {
-    inherit nodeExporterTargets;
-    inherit nginxExporterTargets;
-  };
-
-  system.stateVersion = stateVersion;
-
-  networking.hosts = hostsMap;
-}
diff --git a/morph/lib/make-storage.nix b/morph/lib/make-storage.nix
deleted file mode 100644
index 6619336d758f69a677e9178592357480aed3f0c8..0000000000000000000000000000000000000000
--- a/morph/lib/make-storage.nix
+++ /dev/null
@@ -1,109 +0,0 @@
-# Define the function that defines the node.
-{ cfg                        # Get the configuration that's specific to this node.
-, hardware                   # The path to the hardware configuration for this node.
-, publicStoragePort          # The storage port number on which to accept connections.
-, ristrettoSigningKeyPath    # The *local* path to the Ristretto signing key file.
-, passValue                  # Bytes component of size×time value of passes.
-, sshUsers                   # Users for which to configure SSH access to this node.
-, stateVersion               # The value for system.stateVersion on this node.
-                             # This value determines the NixOS release with
-                             # which your system is to be compatible, in order
-                             # to avoid breaking some software such as
-                             # database servers. You should change this only
-                             # after NixOS release notes say you should.
-, monitoringvpnKeyDir ? null # The directory that holds the VPN keys.
-, monitoringvpnIPv4 ? null   # This node's IP in the monitoring VPN.
-, monitoringvpnEndpoint ? null # The VPN server and port.
-, ...
-}: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              monitoringvpnEndpoint != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-secret-key" = {
-      source = monitoringvpnKeyDir + "/${monitoringvpnIPv4}.key";
-      destination = "/run/keys/monitoringvpn/client.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-  deployment = {
-    targetHost = cfg.publicIPv4;
-
-    secrets = {
-      "ristretto-signing-key" = {
-        source = ristrettoSigningKeyPath;
-        destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        # Service name here matches the name defined by our tahoe-lafs nixos
-        # module.  It would be nice to not have to hard-code it here.  Can we
-        # extract it from the tahoe-lafs nixos module somehow?
-        action = ["sudo" "systemctl" "restart" "tahoe.storage.service"];
-      };
-    } // vpnSecrets;
-  };
-
-  # Any extra NixOS modules to load on this server.
-  imports = [
-    # Include the results of the hardware scan.
-    hardware
-    # Configure it as a system operated by 100TB.
-    ../../nixos/modules/100tb.nix
-    # Bring in our module for configuring the Tahoe-LAFS service and other
-    # Private Storage-specific things.
-    ../../nixos/modules/private-storage.nix
-    # Connect to the monitoringvpn.
-    ../../nixos/modules/monitoring/vpn/client.nix
-    # Expose base system metrics over the monitoringvpn.
-    ../../nixos/modules/monitoring/exporters/node.nix
-  ];
-
-  # Pass the configuration specific to this host to the 100TB module to be
-  # expanded into a complete system configuration.  See the 100tb module for
-  # handling of this value.
-  #
-  # The module name is quoted because `1` makes `100tb` look an awful lot like
-  # it should be a number.
-  "100tb".config = cfg;
-
-  # Turn on the Private Storage (Tahoe-LAFS) service.
-  services.private-storage = {
-    # Yep.  Turn it on.
-    enable = true;
-    # Get the public IPv4 address from the node configuration.
-    inherit (cfg) publicIPv4;
-    # And the port to operate on is specified via parameter.
-    inherit publicStoragePort;
-    # Give it the Ristretto signing key, too, to support authorization.
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    # Assign the configured pass value.
-    inherit passValue;
-    # It gets the users, too.
-    inherit sshUsers;
-  };
-
-  system.stateVersion = stateVersion;
-
-  services.private-storage.monitoring.vpn.client = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    endpoint = monitoringvpnEndpoint;
-    endpointPublicKeyFile = monitoringvpnKeyDir + "/server.pub";
-  };
-}
diff --git a/morph/lib/make-testing.nix b/morph/lib/make-testing.nix
deleted file mode 100644
index 3f6e767db5ee734a8ca2314b216d4fa602c01907..0000000000000000000000000000000000000000
--- a/morph/lib/make-testing.nix
+++ /dev/null
@@ -1,80 +0,0 @@
-{ publicIPv4
-, hardware
-, publicStoragePort
-, ristrettoSigningKeyPath
-, passValue
-, sshUsers
-, stateVersion
-, monitoringvpnKeyDir ? null
-, monitoringvpnIPv4 ? null
-, monitoringvpnEndpoint ? null
-, ... }: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              monitoringvpnEndpoint != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-secret-key" = {
-      source = monitoringvpnKeyDir + "/${monitoringvpnIPv4}.key";
-      destination = "/run/keys/monitoringvpn/client.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-
-  deployment = {
-    targetHost = publicIPv4;
-
-    secrets = {
-      "ristretto-signing-key" = {
-        source = ristrettoSigningKeyPath;
-        destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        # Service name here matches the name defined by our tahoe-lafs nixos
-        # module.  It would be nice to not have to hard-code it here.  Can we
-        # extract it from the tahoe-lafs nixos module somehow?
-        action = ["sudo" "systemctl" "restart" "tahoe.storage.service"];
-      };
-    } // vpnSecrets;
-  };
-
-  imports = [
-    hardware
-    ../../nixos/modules/private-storage.nix
-    ../../nixos/modules/monitoring/vpn/client.nix
-    ../../nixos/modules/monitoring/exporters/node.nix
-  ];
-
-  services.private-storage =
-  { enable = true;
-    inherit publicIPv4;
-    inherit publicStoragePort;
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    inherit passValue;
-    inherit sshUsers;
-  };
-
-  system.stateVersion = stateVersion;
-
-  services.private-storage.monitoring.vpn.client = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    endpoint = monitoringvpnEndpoint;
-    endpointPublicKeyFile = monitoringvpnKeyDir + "/server.pub";
-  };
-}
diff --git a/morph/lib/monitoring.nix b/morph/lib/monitoring.nix
new file mode 100644
index 0000000000000000000000000000000000000000..b48820f0941694869fdda06e724ba1ae714b5993
--- /dev/null
+++ b/morph/lib/monitoring.nix
@@ -0,0 +1,37 @@
+# Similar to ``issuer.nix`` but for a "monitoring"-type system.  Holes are
+# filled by ``customize-monitoring.nix``.
+rec {
+  deployment = {
+    secrets = {
+      "monitoringvpn-private-key" = {
+        destination = "/run/keys/monitoringvpn/server.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+      "monitoringvpn-preshared-key" = {
+        destination = "/run/keys/monitoringvpn/preshared.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+    };
+  };
+
+  imports = [
+    ../../nixos/modules/monitoring/vpn/server.nix
+    ../../nixos/modules/monitoring/server/grafana.nix
+    ../../nixos/modules/monitoring/server/prometheus.nix
+    ../../nixos/modules/monitoring/exporters/node.nix
+    # Loki 0.3.0 from Nixpkgs 19.09 is too old and does not work:
+    # ../../nixos/modules/monitoring/server/loki.nix
+  ];
+
+  services.private-storage.monitoring.grafana = {
+    domain = "monitoring.private.storage";
+    prometheusUrl = "http://localhost:9090/";
+    lokiUrl = "http://localhost:3100/";
+  };
+}
diff --git a/morph/lib/storage.nix b/morph/lib/storage.nix
new file mode 100644
index 0000000000000000000000000000000000000000..1cac51b43aa38fb90a535fd34ba53363fc0cdbaa
--- /dev/null
+++ b/morph/lib/storage.nix
@@ -0,0 +1,51 @@
+# Similar to ``issuer.nix`` but for a "storage"-type system.  Holes are filled
+# by ``customize-storage.nix``.
+rec {
+  deployment = {
+    secrets = {
+      "ristretto-signing-key" = {
+        destination = "/run/keys/ristretto.signing-key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        # Service name here matches the name defined by our tahoe-lafs nixos
+        # module.  It would be nice to not have to hard-code it here.  Can we
+        # extract it from the tahoe-lafs nixos module somehow?
+        action = ["sudo" "systemctl" "restart" "tahoe.storage.service"];
+      };
+      "monitoringvpn-secret-key" = {
+        destination = "/run/keys/monitoringvpn/client.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+      "monitoringvpn-preshared-key" = {
+        destination = "/run/keys/monitoringvpn/preshared.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+    };
+  };
+
+  # Any extra NixOS modules to load on this server.
+  imports = [
+    # Bring in our module for configuring the Tahoe-LAFS service and other
+    # Private Storage-specific things.
+    ../../nixos/modules/private-storage.nix
+    # Connect to the monitoringvpn.
+    ../../nixos/modules/monitoring/vpn/client.nix
+    # Expose base system metrics over the monitoringvpn.
+    ../../nixos/modules/monitoring/exporters/node.nix
+  ];
+
+  # Turn on the Private Storage (Tahoe-LAFS) service.
+  services.private-storage = {
+    # Yep.  Turn it on.
+    enable = true;
+    # Give it the Ristretto signing key to support authorization.
+    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
+  };
+}
diff --git a/morph/grid/local/vagrant-guest.nix b/morph/lib/vagrant-guest.nix
similarity index 100%
rename from morph/grid/local/vagrant-guest.nix
rename to morph/lib/vagrant-guest.nix
diff --git a/nixos/modules/monitoring/server/grafana-config/services-overview.json b/nixos/modules/monitoring/server/grafana-config/services-overview.json
new file mode 100644
index 0000000000000000000000000000000000000000..8ee7b130ce917183edfa26a7a53c1a8df1353303
--- /dev/null
+++ b/nixos/modules/monitoring/server/grafana-config/services-overview.json
@@ -0,0 +1,664 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "description": "RED: Requests-Errors-Duration for our services",
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "id": 2,
+  "links": [],
+  "panels": [
+    {
+      "collapsed": false,
+      "datasource": null,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 18,
+      "panels": [],
+      "title": "Payments v1/stripe/charge",
+      "type": "row"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "HTTPS responses per second",
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 1
+      },
+      "id": 4,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "rate(http_responses_total{path=\"v1/stripe/charge\"}[5m])",
+          "instant": false,
+          "legendFormat": "{{status}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/stripe/charge RPS",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "",
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 1
+      },
+      "id": 15,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": true,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"4XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Client error (4XX) rate",
+          "refId": "A"
+        },
+        {
+          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"5XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Server error (5XX) rate",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/stripe/charge error rate",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "percentunit",
+          "label": null,
+          "logBase": 1,
+          "max": "100",
+          "min": "0",
+          "show": true
+        },
+        {
+          "format": "percent",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "cacheTimeout": null,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "Requests taking longer than 1 s, between 1 sec and 10 msec, and 10 msec and below",
+      "fill": 2,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 1
+      },
+      "id": 12,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pluginVersion": "6.4.3",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 0.01s",
+          "refId": "A"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"1.0\"} - ignoring(le) http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 1s",
+          "refId": "D"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"+Inf\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"1.0\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "> 1s",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/stripe/charge durations",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "datasource": null,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 8
+      },
+      "id": 11,
+      "panels": [],
+      "title": "Payments v1/redeem",
+      "type": "row"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "HTTPS responses per second",
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 9
+      },
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "rate(http_responses_total{path=\"v1/redeem\"}[5m])",
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "{{status}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/redeem RPS",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 9
+      },
+      "id": 16,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": true,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "sum(http_responses_total{path=\"v1/redeem\", status=\"4XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Client error (4XX) rate",
+          "refId": "A"
+        },
+        {
+          "expr": "sum(http_responses_total{path=\"v1/redeem\", status=\"5XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Server error (5XX) rate",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/redeem error rate",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "percentunit",
+          "label": null,
+          "logBase": 1,
+          "max": "100",
+          "min": "0",
+          "show": true
+        },
+        {
+          "format": "percent",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "cacheTimeout": null,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "Requests taking longer than 1 s, between 1 sec and 10 msec, and 10 msec and below",
+      "fill": 2,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 9
+      },
+      "id": 13,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pluginVersion": "6.4.3",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 0.01s",
+          "refId": "A"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"1.0\"} - ignoring(le) http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 1s",
+          "refId": "D"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"+Inf\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"1.0\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "> 1s",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/redeem durations",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    }
+  ],
+  "refresh": "",
+  "schemaVersion": 20,
+  "style": "dark",
+  "tags": [],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-3h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ]
+  },
+  "timezone": "",
+  "title": "Services overview",
+  "uid": "JX3RVEk7k",
+  "version": 6
+}
diff --git a/nixos/modules/monitoring/server/prometheus.nix b/nixos/modules/monitoring/server/prometheus.nix
index 36c2ba6402559771dff8771b1369842e21f7ff7f..c92261ccad2ebdd8dd34dda9027e66962b345be9 100644
--- a/nixos/modules/monitoring/server/prometheus.nix
+++ b/nixos/modules/monitoring/server/prometheus.nix
@@ -26,6 +26,11 @@ in {
       example = lib.literalExample "[ node1 node2 ]";
       description = "List of nodes (hostnames or IPs) to scrape.";
     };
+    paymentExporterTargets = lib.mkOption {
+      type = with lib.types; listOf str;
+      example = lib.literalExample "[ node1 node2 ]";
+      description = "List of nodes (hostnames or IPs) to scrape.";
+    };
   };
 
   config = rec {
@@ -49,6 +54,15 @@ in {
           }];
           relabel_configs = [ dropPortNumber ];
         }
+        {
+          job_name = "payment-exporters";
+          scheme = "https";
+          tls_config.insecure_skip_verify = true;
+          static_configs = [{
+            targets = cfg.paymentExporterTargets;
+          }];
+          relabel_configs = [ dropPortNumber ];
+        }
       ];
     };
   };
diff --git a/nixos/modules/monitoring/vpn/client.nix b/nixos/modules/monitoring/vpn/client.nix
index dbd50b82ef5b09495e332e6fbb7ac5676f5ac322..ed1933e34d715fba0933f32d606e989b4d1ed4ec 100644
--- a/nixos/modules/monitoring/vpn/client.nix
+++ b/nixos/modules/monitoring/vpn/client.nix
@@ -48,7 +48,7 @@ in {
     };
     endpointPublicKeyFile = lib.mkOption {
       type = lib.types.path;
-      example = lib.literalExample ../PrivateStorageSecrets/monitoringvpn/server.pub;
+      example = lib.literalExample ./monitoringvpn/server.pub;
       description = ''
         File with base64 public key generated by <command>cat private.key | wg pubkey > pubkey.pub</command>.
       '';
@@ -71,4 +71,3 @@ in {
     };
   };
 }
-
diff --git a/nixos/modules/monitoring/vpn/server.nix b/nixos/modules/monitoring/vpn/server.nix
index 2374ddc8657fb299fb83155cbabe328cd54c1aaf..3c41e0209bb7fe18f1a81a44ab509c8442372bbf 100644
--- a/nixos/modules/monitoring/vpn/server.nix
+++ b/nixos/modules/monitoring/vpn/server.nix
@@ -51,7 +51,7 @@ in {
     };
     pubKeysPath = lib.mkOption {
       type = lib.types.path;
-      example = lib.literalExample ../PrivateStorageSecrets/monitoringvpn;
+      example = lib.literalExample ./monitoringvpn;
       description = ''
         The path to the directory that holds the public keys.
       '';
@@ -69,4 +69,3 @@ in {
     };
   };
 }
-
diff --git a/nixos/modules/private-storage.nix b/nixos/modules/private-storage.nix
index 52720e618973c57b41aade87585c7ab758abff22..38e224709e783b9590de73d728e4d6ca134e5adb 100644
--- a/nixos/modules/private-storage.nix
+++ b/nixos/modules/private-storage.nix
@@ -18,6 +18,12 @@ let
   # NOTE: This is promised by the service privacy policy.  It *may not* be
   # raised without following the process for updating the privacy policy.
   max-incident-age = "29d";
+
+  fqdn = "${
+    assert config.networking.hostName != null; config.networking.hostName
+    }.${
+    assert config.networking.domain != null; config.networking.domain
+    }";
 in
 {
   imports = [
@@ -38,12 +44,13 @@ in
         The package to use for the Tahoe-LAFS daemon.
       '';
     };
-    services.private-storage.publicIPv4 = lib.mkOption
-    { default = "127.0.0.1";
+    services.private-storage.publicAddress = lib.mkOption
+    { default = "${fqdn}";
       type = lib.types.str;
-      example = lib.literalExample "192.0.2.0";
+      example = lib.literalExample "storage.example.invalid";
       description = ''
-        An IPv4 address to advertise for this storage service.
+        A publicly-visible address to use in Tahoe-LAFS advertisements for
+        this storage service.
       '';
     };
     services.private-storage.introducerFURL = lib.mkOption
@@ -63,7 +70,7 @@ in
       '';
     };
     services.private-storage.issuerRootURL = lib.mkOption
-    { default = "https://issuer.privatestorage.io/";
+    { default = "https://issuer.${config.networking.domain}/";
       type = lib.types.str;
       example = lib.literalExample "https://example.invalid/";
       description = ''
@@ -122,7 +129,7 @@ in
           # First, in the syntax which it uses to listen.
           "tub.port" = "tcp:${toString cfg.publicStoragePort}";
           # Second, in the syntax it advertises to in the fURL.
-          "tub.location" = "tcp:${cfg.publicIPv4}:${toString cfg.publicStoragePort}";
+          "tub.location" = "tcp:${cfg.publicAddress}:${toString cfg.publicStoragePort}";
         };
         storage =
         { enabled = true;
diff --git a/nixos/modules/tests/private-storage.nix b/nixos/modules/tests/private-storage.nix
index cbf4c5937ca6780ce9e931d6ceec91c29643fbc3..353abc891fafd1cc988e47a1befa530a012470dc 100644
--- a/nixos/modules/tests/private-storage.nix
+++ b/nixos/modules/tests/private-storage.nix
@@ -115,7 +115,7 @@ in {
         ];
         services.private-storage = {
           enable = true;
-          publicIPv4 = "storage";
+          publicAddress = "storage";
           introducerFURL = introducerFURL;
           issuerRootURL = issuerURL;
           inherit ristrettoSigningKeyPath;
diff --git a/nixpkgs.json b/nixpkgs.json
index 33b343ef3498ae226218f59be257e808e9a88c7e..e8a900d44fc2a08a09c4e1f6447da3a4a6a52fd1 100644
--- a/nixpkgs.json
+++ b/nixpkgs.json
@@ -1,4 +1,4 @@
 { "name": "nixpkgs"
-, "url": "https://github.com/PrivateStorageio/nixpkgs/archive/8c7a61c658e32eaccf666e5fe818a996c36a988f.tar.gz"
-, "sha256": "1ln0a8c20qykm57wl901lixny1fcfmzgbavd7pbjk6jbnfij59bl"
+, "url": "https://github.com/PrivateStorageio/nixpkgs/archive/788cc5806d46b89013ddd59db589b748bc20435e.tar.gz"
+, "sha256": "1mjznn4i4524gl5aiapjpy2jzpac1fzp7jvnkamrh9090ndalhar"
 }
diff --git a/shell.nix b/shell.nix
index 2c1c5123da656d34fafe0883b50ef49c578c6c8b..df272a12e2241c704a4c08c6d60d47bb138bcae5 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,10 +1,10 @@
 let
   nixpkgs = import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs.json))) { };
-  stable2105 = import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs-2105.json))) { };
+  stable2105 = import ./stable2105.nix { };
 in
 { pkgs ? nixpkgs }:
 pkgs.mkShell {
-  NIX_PATH = "nixpkgs=${nixpkgs.path}";
+  NIX_PATH = "nixpkgs=${pkgs.path}";
   buildInputs = [
     pkgs.morph
     stable2105.vagrant
diff --git a/stable2105.nix b/stable2105.nix
new file mode 100644
index 0000000000000000000000000000000000000000..536d913b89ba6a57d8d683381ea1c8f40e026b4f
--- /dev/null
+++ b/stable2105.nix
@@ -0,0 +1 @@
+import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs-2105.json)))
diff --git a/tools/create-vpn-keys.sh b/tools/create-vpn-keys.sh
index e092a8ced698bd3a3bb2d4acc3ca07a3a8e6032d..c81225ec340db38d551e9d0c6c13d7bd44e21a4d 100755
--- a/tools/create-vpn-keys.sh
+++ b/tools/create-vpn-keys.sh
@@ -4,7 +4,7 @@
 # Parameters:
 #   file: path to grid.nix of morph deployment
 #
-# Output: Key files for all monitoring VPN hosts in secrets/monitoringvpn
+# Output: Key files for all monitoring VPN hosts in {private,public}-keys/monitoringvpn
 #         relative to the grid.nix
 #
 # The server key will also be symlinked to server.{key,pub}.
@@ -19,7 +19,8 @@ if [[ $# -ne 1 ]]; then
 fi
 
 SRC=$(dirname $0)
-VPN_SECRETS=$(dirname $1)/secrets/monitoringvpn
+VPN_SECRETS=$(dirname $1)/private-keys/monitoringvpn
+VPN_PUBLIC=$(dirname $1)/public-keys/monitoringvpn
 
 CONFIG=$(nix-instantiate --strict --json --eval "${SRC}"/get-vpn-config.nix --arg pathToGrid "${1}")
 
@@ -27,14 +28,15 @@ MONITORING_IPS=$(echo $CONFIG | jp --unquoted "join(' ', clientIPs)")
 VPNSERVER_IP=$(echo $CONFIG | jp --unquoted "serverIP")
 
 mkdir -p "${VPN_SECRETS}"
+mkdir -p "${VPN_PUBLIC}"
 
 for i in $MONITORING_IPS $VPNSERVER_IP; do
-  wg genkey | tee "${VPN_SECRETS}"/${i}.key | wg pubkey > "${VPN_SECRETS}"/${i}.pub
+  wg genkey | tee "${VPN_SECRETS}"/${i}.key | wg pubkey > "${VPN_PUBLIC}"/${i}.pub
 done
 
 wg genpsk > "${VPN_SECRETS}"/preshared.key
 
 ln -fs $VPNSERVER_IP.key "${VPN_SECRETS}"/server.key
-ln -fs $VPNSERVER_IP.pub "${VPN_SECRETS}"/server.pub
+ln -fs $VPNSERVER_IP.pub "${VPN_PUBLIC}"/server.pub
 
 # EOF