diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 76dce30e2e5137a8a9199f2d739f96db92988406..d6b06fae42f6e738725238fac59617aeb161dfd4 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -39,7 +39,7 @@ morph-build-localdev:
     - |
       # The local grid configuration is *almost* complete enough to build.  It
       # just needs this tweak.
-      sed -i 's/undefined/\"unundefined\"/' morph/grid/${GRID}/public-keys/users.nix
+      echo '{}' > morph/grid/${GRID}/public-keys/users.nix
 
 morph-build-testing:
   <<: *MORPH_BUILD
@@ -74,22 +74,47 @@ system-tests:
 .update-grid: &UPDATE_GRID
   stage: "deploy"
   script: |
-    env --ignore-environment - NIX_PATH=$NIX_PATH GITLAB_USER_LOGIN=$GITLAB_USER_LOGIN CI_JOB_NAME=$CI_JOB_NAME CI_PIPELINE_SOURCE=$CI_PIPELINE_SOURCE CI_COMMIT_BRANCH=$CI_COMMIT_BRANCH ./ci-tools/update-grid-servers "${PRIVATESTORAGEIO_SSH_DEPLOY_KEY_PATH}" "${CI_ENVIRONMENT_NAME}"
-
-# Update the staging deployment - only on a merge to the staging branch.
+    env --ignore-environment - \
+      NIX_PATH="$NIX_PATH" \
+      GITLAB_USER_LOGIN="$GITLAB_USER_LOGIN" \
+      CI_JOB_NAME="$CI_JOB_NAME" \
+      CI_PIPELINE_SOURCE="$CI_PIPELINE_SOURCE" \
+      CI_COMMIT_BRANCH="$CI_COMMIT_BRANCH" \
+      ./ci-tools/update-grid-servers "${PRIVATESTORAGEIO_SSH_DEPLOY_KEY_PATH}" "${CI_ENVIRONMENT_NAME}"
+
+# Update the staging deployment - only on a commit to the develop branch.
 update-staging:
   <<: *UPDATE_GRID
-  only:
-    - "staging"
+  # https://docs.gitlab.com/ee/ci/yaml/#rules
+  rules:
+    # https://docs.gitlab.com/ee/ci/yaml/index.html#rulesif
+    # https://docs.gitlab.com/ee/ci/jobs/job_control.html#cicd-variable-expressions
+    # https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
+    - if: '$CI_COMMIT_BRANCH == "develop"'
   environment:
+    # You can find some status information about environments in GitLab at
+    # https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/environments.
     name: "staging"
-    url: "https://privatestorage-staging.com/"
-
-# Update the production deployment - only on a merge to the production branch.
+    # The URL controls where the "View Deployment" button for this environment
+    # will take you.  The main website isn't controlled by this codebase so we
+    # don't point there.  The monitoring system *is* controlled by this
+    # codebase and it also tells us lots of stuff about other things
+    # controlled by this codebase so that seems like a good place to land.
+    # Not that I make it a habit to visit the deployment using the GitLab
+    # button...  Still, discoverability or something.
+    url: "https://monitoring.privatestorage-staging.com/"
+
+# Update the production deployment - only on a commit to the production branch.
 deploy-to-production:
   <<: *UPDATE_GRID
-  only:
-    - "production"
+  # https://docs.gitlab.com/ee/ci/yaml/#rules
+  rules:
+    # https://docs.gitlab.com/ee/ci/yaml/index.html#rulesif
+    # https://docs.gitlab.com/ee/ci/jobs/job_control.html#cicd-variable-expressions
+    # https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
+    - if: '$CI_COMMIT_BRANCH == "production"'
+
   environment:
+    # See notes in `update-staging`.
     name: "production"
-    url: "https://privatestorage.io/"
+    url: "https://monitoring.private.storage/"
diff --git a/DEPLOYMENT-NOTES.rst b/DEPLOYMENT-NOTES.rst
new file mode 100644
index 0000000000000000000000000000000000000000..5de83386dbb939cc3cfe2a8b68c198f218934933
--- /dev/null
+++ b/DEPLOYMENT-NOTES.rst
@@ -0,0 +1,14 @@
+Deployment notes
+================
+
+- 2021-09-07 `Manage access to payment metrics <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/merge_requests/146>`_ requires moving and chown'ing the PaymentServer database on the ``payments`` host::
+
+   mkdir /var/lib/zkapissuer
+
+   mv /var/db/vouchers.sqlite3 /var/lib/zkapissuer/vouchers.sqlite3
+
+   chown -R zkapissuer:zkapissuer /var/lib/zkapissuer
+
+   chmod 750 /var/lib/zkapissuer
+   chmod 640 /var/lib/zkapissuer/vouchers.sqlite3
+
diff --git a/README.rst b/README.rst
index d3d9f088db4f8b976f3f55852715762280bb93c0..3a2b2d8ecfcdba6ffaa4f3f2e275cb85feb709de 100644
--- a/README.rst
+++ b/README.rst
@@ -1,7 +1,7 @@
 Project Hosting Moved
 =====================
 
-This project can now be found at https://whetstone.privatestorage.io/opensource/PrivateStorageio
+This project can now be found at https://whetstone.privatestorage.io/privatestorage/PrivateStorageio
 
 PrivateStorageio
 ================
@@ -13,8 +13,8 @@ Documentation
 
 There is documentation for:
 
-* Operators/Admins: ``docs/ops/README.rst``
-* Developers: ``docs/dev/README.rst``
+* Operators/Admins: `<docs/source/ops/README.rst>`_
+* Developers: `<docs/source/dev/README.rst>`_
 
 The documentation can be built using this command::
 
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 36b62a34aae4ecea544e5f0527c85809106057a8..66aa921e2ba799e1b1b4d8e7a778ab07ee07a73b 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -86,7 +86,6 @@ html_theme_options = {
     'logo': 'logo-ps.svg',
     'description': "&nbsp;", # ugly hack to get some white space below the logo
     'fixed_sidebar': True,
-    'extra_nav_links': {"Fork me on GitHub": "https://github.com/PrivateStorageio/PrivateStorageio"},
 }
 
 # Add any paths that contain custom static files (such as style sheets) here,
diff --git a/docs/source/dev/README.rst b/docs/source/dev/README.rst
index 904e8b3be07bdcc1473a3c1fe22afe8ffb0e15a2..14d2de31f932a0aa50545643e30c679c36696e19 100644
--- a/docs/source/dev/README.rst
+++ b/docs/source/dev/README.rst
@@ -28,7 +28,7 @@ The system tests boot QEMU VMs which prevents them from running on CI at this ti
 The build requires > 10 GB of disk space,
 and the VMs might be timing out on slow or busy machines.
 If you run into timeouts,
-try `raising the number of retries <https://github.com/PrivateStorageio/PrivateStorageio/blob/e8233d2/nixos/modules/tests/run-introducer.py#L55-L62>`_.
+try `raising the number of retries <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/blob/e8233d2/nixos/modules/tests/run-introducer.py#L55-62>`_.
 
 It is also possible go through the testing script interactively - useful for debugging::
 
@@ -36,9 +36,27 @@ It is also possible go through the testing script interactively - useful for deb
 
 This will give you a result symlink in the current directory.
 Inside that is bin/nixos-test-driver which gives you a kind of REPL for interacting with the VMs.
-The kind of `Perl in this testScript <https://github.com/PrivateStorageio/PrivateStorageio/blob/78881a3/nixos/modules/tests/private-storage.nix#L180>`_ is what you can enter into this REPL.
+The kind of `Python in this testScript <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/blob/78881a3/nixos/modules/tests/private-storage.nix#L180>`_ is what you can enter into this REPL.
 Consult the `official documentation on NixOS Tests <https://nixos.org/manual/nixos/stable/index.html#sec-nixos-tests>`_ for more information.
 
+Updatings Pins
+--------------
+
+Nixpkgs
+```````
+
+To update the version of NixOS we deploy with, run:
+
+.. code: shell
+
+   nix-shell --run 'update-nixpkgs'
+
+That will update ``nixpkgs-2015.json`` to the latest release on the nixos-21.05 channel.
+
+To update the channel, the script will need to be updated,
+along with the filenames that have the channel in them.
+
+
 Architecture overview
 ---------------------
 
@@ -48,8 +66,5 @@ Architecture overview
 .. include::
       ../../../morph/grid/local/README.rst
 
-
-
-
 .. _Nix: https://nixos.org/nix
 
diff --git a/morph/grid/local/.gitignore b/morph/grid/local/.gitignore
index 8000dd9db47c0b9dd34046ec17880dcbb27e5eb9..00e940f3fb4c5e579dbdf2964110b9a187beb98a 100644
--- a/morph/grid/local/.gitignore
+++ b/morph/grid/local/.gitignore
@@ -1 +1,2 @@
-.vagrant
+/.vagrant
+/public-keys/users.nix
diff --git a/morph/grid/local/README.rst b/morph/grid/local/README.rst
index d30d8766a4ef5a8db228ef38374330734e69cba7..48f395cb82fc272481a61f0d1ab425ffbd20cd02 100644
--- a/morph/grid/local/README.rst
+++ b/morph/grid/local/README.rst
@@ -35,7 +35,7 @@ Use the local development environment
 
   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::
+6. Create a ``public-keys/users.nix`` file with your SSH key (see ``public-keys/users.nix.example`` for the format) so you'll be able to log in after deploying the new configuration::
 
     $EDITOR public-keys/users.nix
 
diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix
index 51f41832ded8fe18290c47b5b3ad85fb58c2a511..46cb9c8ec1dc5278823c9e3ffc405289e7510469 100644
--- a/morph/grid/local/grid.nix
+++ b/morph/grid/local/grid.nix
@@ -2,20 +2,65 @@ let
   pkgs = import <nixpkgs> { };
 
   gridlib = import ../../lib;
-  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
-  config = rawConfig // {
-    sshUsers = import ./public-keys/users.nix;
+  grid-config = pkgs.lib.trivial.importJSON ./config.json;
 
+  ssh-users = let
+    ssh-users-file = ./public-keys/users.nix;
+  in
+    if builtins.pathExists ssh-users-file then
+      import ssh-users-file
+    else
+      # Use builtins.toString so that nix does not add the file
+      # to the nix store before including it in the string.
+      throw ''
+        ssh-keys for local grid are not configured.
+        Refusing to build a possibly inaccessible configuration.
+        Please create ${builtins.toString ssh-users-file} before building.
+        See ${builtins.toString ./README.rst} for more information.
+      '';
+
+  # Module with per-grid configuration
+  grid-module = {config, ...}: {
+    imports = [
+      gridlib.base
+      # Allow us to remotely trigger updates to this system.
+      ../../../nixos/modules/deployment.nix
+      # Give it a good SSH configuration.
+      ../../../nixos/modules/ssh.nix
+    ];
+    services.private-storage.sshUsers = ssh-users;
+
+    # Include the ssh-users config in a form that can be read by nix,
+    # so the self-update deployment system can access it.
+    # nixos/modules/update-deployment imports the nix file into
+    # the checkout of this repository it creates.
+    environment.etc."nixos/ssh-users.json" = {
+      # Output the loaded value, rather than just copying the file, in case the
+      # file has external references.
+      mode = "0666";
+      text = builtins.toJSON ssh-users;
+    };
+    environment.etc."nixos/ssh-users.nix" = {
+      # This is the file that is imported by update-deployment.
+      # We don't directly read the JSON so that the script doesn't
+      # depend on the format we use.
+      mode = "0666";
+      text = ''
+        # Include the ssh-users config 
+        builtins.fromJSON (builtins.readFile ./ssh-users.json)
+      '';
+    };
+
+    networking.domain = grid-config.domain;
     # Convert relative paths to absolute so library code can resolve names
     # correctly.
-    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
-    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
-  };
-
-  # Configure deployment management authorization for all systems in the grid.
-  deployment = {
+    grid = {
+      publicKeyPath = toString ./. + "/${grid-config.publicKeyPath}";
+      privateKeyPath = toString ./. + "/${grid-config.privateKeyPath}";
+    };
+    # Configure deployment management authorization for all systems in the grid.
     services.private-storage.deployment = {
-      authorizedKey = builtins.readFile "${config.publicKeyPath}/deploy_key.pub";
+      authorizedKey = builtins.readFile "${config.grid.publicKeyPath}/deploy_key.pub";
       gridName = "local";
     };
   };
@@ -24,10 +69,10 @@ let
     imports = [
       gridlib.issuer
       (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.21"; }))
-      (gridlib.customize-issuer (config // {
+      (gridlib.customize-issuer (grid-config // {
           monitoringvpnIPv4 = "172.23.23.11";
       }))
-      deployment
+      grid-module
     ];
   };
 
@@ -35,11 +80,11 @@ let
     imports = [
       gridlib.storage
       (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.22"; }))
-      (gridlib.customize-storage (config // {
+      (gridlib.customize-storage (grid-config // {
         monitoringvpnIPv4 = "172.23.23.12";
         stateVersion = "19.09";
       }))
-      deployment
+      grid-module
     ];
   };
 
@@ -47,11 +92,11 @@ let
     imports = [
       gridlib.storage
       (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.23"; }))
-      (gridlib.customize-storage (config // {
+      (gridlib.customize-storage (grid-config // {
         monitoringvpnIPv4 = "172.23.23.13";
         stateVersion = "19.09";
       }))
-      deployment
+      grid-module
     ];
   };
 
@@ -61,12 +106,12 @@ let
       (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.24"; }))
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
-        inherit (config) domain publicKeyPath privateKeyPath sshUsers letsEncryptAdminEmail;
-        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
+        inherit (grid-config) letsEncryptAdminEmail;
+        googleOAuthClientID = grid-config.monitoringGoogleOAuthClientID;
         monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
       })
-      deployment
+      grid-module
     ];
   };
 
diff --git a/morph/grid/local/public-keys/users.nix b/morph/grid/local/public-keys/users.nix.example
similarity index 62%
rename from morph/grid/local/public-keys/users.nix
rename to morph/grid/local/public-keys/users.nix.example
index 412077c0d5d6d98024036e369dfa552604f2dc57..10a60be1f7b8760e81f7fdb6ecd1d177913e05af 100644
--- a/morph/grid/local/public-keys/users.nix
+++ b/morph/grid/local/public-keys/users.nix.example
@@ -1,4 +1,6 @@
 # Add your public key. Example:
 # let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHx7wJQNqKn8jOC4AxySRL2UxidNp7uIK9ad3pMb1ifF flo@fs-la";
+# You can use the following to get key from the local machine.
+# let key = builtins.readFile ~/.ssh/id_ed25519.pub;
 let key = undefined;
 in { "root" = key; "vagrant" = key; }
diff --git a/morph/grid/production/grid.nix b/morph/grid/production/grid.nix
index 06eefdd28da57ad65ea99543ba8421bc934ef752..6009be84fb2a7ed7ca63e2e73b4f08f1f45ecb0d 100644
--- a/morph/grid/production/grid.nix
+++ b/morph/grid/production/grid.nix
@@ -3,20 +3,28 @@ let
   pkgs = import <nixpkgs> { };
 
   gridlib = import ../../lib;
-  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
-  config = rawConfig // {
-    sshUsers = import ./public-keys/users.nix;
+  grid-config = pkgs.lib.trivial.importJSON ./config.json;
 
+  # Module with per-grid configuration
+  grid-module = {config, ...}: {
+    imports = [
+      gridlib.base
+      # Allow us to remotely trigger updates to this system.
+      ../../../nixos/modules/deployment.nix
+      # Give it a good SSH configuration.
+      ../../../nixos/modules/ssh.nix
+    ];
+    services.private-storage.sshUsers = import ./public-keys/users.nix;
+    networking.domain = grid-config.domain;
     # Convert relative paths to absolute so library code can resolve names
     # correctly.
-    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
-    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
-  };
-
-  # Configure deployment management authorization for all systems in the grid.
-  deployment = {
+    grid = {
+      publicKeyPath = toString ./. + "/${grid-config.publicKeyPath}";
+      privateKeyPath = toString ./. + "/${grid-config.privateKeyPath}";
+    };
+    # Configure deployment management authorization for all systems in the grid.
     services.private-storage.deployment = {
-      authorizedKey = builtins.readFile "${config.publicKeyPath}/deploy_key.pub";
+      authorizedKey = builtins.readFile "${config.grid.publicKeyPath}/deploy_key.pub";
       gridName = "production";
     };
   };
@@ -25,10 +33,10 @@ let
     imports = [
       gridlib.issuer
       gridlib.hardware-aws
-      (gridlib.customize-issuer (config // {
+      (gridlib.customize-issuer (grid-config // {
         monitoringvpnIPv4 = "172.23.23.11";
       }))
-      deployment
+      grid-module
     ];
   };
 
@@ -38,12 +46,12 @@ let
       gridlib.hardware-aws
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
-        inherit (config) domain publicKeyPath privateKeyPath sshUsers letsEncryptAdminEmail;
-        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
+        inherit (grid-config) letsEncryptAdminEmail;
+        googleOAuthClientID = grid-config.monitoringGoogleOAuthClientID;
         monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
       })
-      deployment
+      grid-module
     ];
   };
 
@@ -65,13 +73,13 @@ let
       gridlib.storage
 
       # Then customize the storage system a little bit based on this node's particulars.
-      (gridlib.customize-storage (config // nodecfg // {
+      (gridlib.customize-storage (grid-config // nodecfg // {
         monitoringvpnIPv4 = vpnIP;
         inherit stateVersion;
       }))
 
       # Also configure deployment management authorization
-      deployment
+      grid-module
     ];
 
     # And supply configuration for those hardware / network / bootloader
diff --git a/morph/grid/testing/grid.nix b/morph/grid/testing/grid.nix
index 7b06c99e1f7a1b65b535f924a0a24aebe6753586..18983f0b32d28f13981b56475d7691a8cb434808 100644
--- a/morph/grid/testing/grid.nix
+++ b/morph/grid/testing/grid.nix
@@ -3,20 +3,28 @@ let
   pkgs = import <nixpkgs> { };
 
   gridlib = import ../../lib;
-  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
-  config = rawConfig // {
-    sshUsers = import ./public-keys/users.nix;
+  grid-config = pkgs.lib.trivial.importJSON ./config.json;
 
+  # Module with per-grid configuration
+  grid-module = {config, ...}: {
+    imports = [
+      gridlib.base
+      # Allow us to remotely trigger updates to this system.
+      ../../../nixos/modules/deployment.nix
+      # Give it a good SSH configuration.
+      ../../../nixos/modules/ssh.nix
+    ];
+    services.private-storage.sshUsers = import ./public-keys/users.nix;
+    networking.domain = grid-config.domain;
     # Convert relative paths to absolute so library code can resolve names
     # correctly.
-    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
-    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
-  };
-
-  # Configure deployment management authorization for all systems in the grid.
-  deployment = {
+    grid = {
+      publicKeyPath = toString ./. + "/${grid-config.publicKeyPath}";
+      privateKeyPath = toString ./. + "/${grid-config.privateKeyPath}";
+    };
+    # Configure deployment management authorization for all systems in the grid.
     services.private-storage.deployment = {
-      authorizedKey = builtins.readFile "${config.publicKeyPath}/deploy_key.pub";
+      authorizedKey = builtins.readFile "${config.grid.publicKeyPath}/deploy_key.pub";
       gridName = "testing";
     };
   };
@@ -25,10 +33,10 @@ let
     imports = [
       gridlib.issuer
       gridlib.hardware-aws
-      (gridlib.customize-issuer (config // {
+      (gridlib.customize-issuer (grid-config // {
         monitoringvpnIPv4 = "172.23.23.11";
       }))
-      deployment
+      grid-module
     ];
   };
 
@@ -37,11 +45,11 @@ let
       gridlib.storage
       gridlib.hardware-aws
       ./testing001-hardware.nix
-      (gridlib.customize-storage (config // {
+      (gridlib.customize-storage (grid-config // {
         monitoringvpnIPv4 = "172.23.23.12";
         stateVersion = "19.03";
       }))
-      deployment
+      grid-module
     ];
   };
 
@@ -51,12 +59,12 @@ let
       gridlib.hardware-aws
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
-        inherit (config) domain publicKeyPath privateKeyPath sshUsers letsEncryptAdminEmail;
-        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
+        inherit (grid-config) letsEncryptAdminEmail;
+        googleOAuthClientID = grid-config.monitoringGoogleOAuthClientID;
         monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
       })
-      deployment
+      grid-module
     ];
   };
 
diff --git a/morph/lib/base.nix b/morph/lib/base.nix
new file mode 100644
index 0000000000000000000000000000000000000000..271766d9cff5253f6d9a72e475dec3398b2cd6b3
--- /dev/null
+++ b/morph/lib/base.nix
@@ -0,0 +1,36 @@
+# This module contains settings and configuration that apply to all nodes in a grid.
+{ lib, config, ...}:
+{
+  options.grid = {
+    publicKeyPath = lib.mkOption {
+      type = lib.types.path;
+      description = ''
+      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.
+      '';
+    };
+    privateKeyPath = lib.mkOption {
+      type = lib.types.path;
+      description = ''
+      A path on the deployment system of a directory containing all of the
+      corresponding private keys for the system.
+      '';
+    };
+  };
+
+  imports = [
+    ../../nixos/modules/packages.nix
+  ];
+
+  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}";
+  };
+}
diff --git a/morph/lib/bootstrap-configuration.nix b/morph/lib/bootstrap-configuration.nix
index e26e345780d0d4da2d65e7b79af9c5e445a35e98..531f867572f3bd46963fc850384f6280f11531a1 100644
--- a/morph/lib/bootstrap-configuration.nix
+++ b/morph/lib/bootstrap-configuration.nix
@@ -67,7 +67,7 @@ let
   # Stop!  I hope you're done when you get here.  If you have to modify
   # anything below this point the expression should probably be refactored and
   # another variable added controlling whatever new thing you need to control.
-  # Open an issue: https://github.com/PrivateStorageio/PrivateStorageio/issues/new
+  # Open an issue: https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/issues/new?issue
 in
 # Define a function that ignores all its arguments.  We don't need any of them
 # for now.
diff --git a/morph/lib/customize-issuer.nix b/morph/lib/customize-issuer.nix
index 1c0d668fbd4ae59bab115c2116b7fa377395dcfc..0686556cdf6abe79f0ac9e16586c9c219f3cddb1 100644
--- a/morph/lib/customize-issuer.nix
+++ b/morph/lib/customize-issuer.nix
@@ -1,37 +1,13 @@
 # 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
+  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
@@ -45,25 +21,11 @@
 , 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}";
-
+{ config, ... }:
+let
+  inherit (config.grid) publicKeyPath privateKeyPath;
+in {
   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
@@ -75,9 +37,6 @@
     "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;
diff --git a/morph/lib/customize-monitoring.nix b/morph/lib/customize-monitoring.nix
index 391aa5602575100c8650d8e4fb6892e38fc95ebf..19a800f1fa806c09f132f2bb2769869a30c65ec2 100644
--- a/morph/lib/customize-monitoring.nix
+++ b/morph/lib/customize-monitoring.nix
@@ -9,11 +9,7 @@
   hostsMap
 
   # See ``customize-issuer.nix``.
-, publicKeyPath
-, privateKeyPath
 , monitoringvpnIPv4
-, domain
-, sshUsers
 , letsEncryptAdminEmail
 
   # A list of VPN IP addresses as strings indicating which clients will be
@@ -40,10 +36,10 @@
 , stateVersion
 , ...
 }:
-{ config, ... }: {
-  # See customize-issuer.nix for an explanatoin of targetHost value.
-  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
-
+{ config, ... }:
+let
+  inherit (config.grid) publicKeyPath privateKeyPath;
+in {
   deployment.secrets = let
     # When Grafana SSO is disabled there is not necessarily any client secret
     # available.  Avoid telling morph that there is one in this case (so it
@@ -82,11 +78,8 @@
     in
       grafanaSSO // monitoringvpn;
 
-  networking.domain = domain;
   networking.hosts = hostsMap;
 
-  services.private-storage.sshUsers = sshUsers;
-
   services.private-storage.monitoring.vpn.server = {
     enable = true;
     ip = monitoringvpnIPv4;
diff --git a/morph/lib/customize-storage.nix b/morph/lib/customize-storage.nix
index 68655874efd9ba39b52dacfdddaedb54863ed769..6a288213c3f117309b697e44304be9a7d5620bcb 100644
--- a/morph/lib/customize-storage.nix
+++ b/morph/lib/customize-storage.nix
@@ -2,12 +2,8 @@
 # ``storage.nix``.
 {
   # See ``customize-issuer.nix``
-  privateKeyPath
-, publicKeyPath
-, monitoringvpnEndpoint
+  monitoringvpnEndpoint
 , monitoringvpnIPv4
-, sshUsers
-, domain
 
   # An integer giving the value of a single pass in byte×months.
 , passValue
@@ -20,20 +16,17 @@
 , stateVersion
 , ...
 }:
-{ config, ... }: {
-  # See customize-issuer.nix for an explanatoin of targetHost value.
-  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
-
+{ config, ... }:
+let
+  inherit (config.grid) publicKeyPath privateKeyPath;
+in {
   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;
+    inherit passValue publicStoragePort;
   };
 
   services.private-storage.monitoring.vpn.client = {
diff --git a/morph/lib/default.nix b/morph/lib/default.nix
index bdd92f4bfe52eba2e19df3ac73a087a4af4a53dc..bf25e5a58d04d148296bffef48acc4e4e125684b 100644
--- a/morph/lib/default.nix
+++ b/morph/lib/default.nix
@@ -2,6 +2,8 @@
 # coherent public interface.  Application code should prefer these names over
 # directly importing the source files in this directory.
 {
+  base = import ./base.nix;
+
   hardware-aws = import ./issuer-aws.nix;
   hardware-virtual = import ./hardware-virtual.nix;
 
@@ -13,4 +15,6 @@
 
   monitoring = import ./monitoring.nix;
   customize-monitoring = import ./customize-monitoring.nix;
+
+  modules = builtins.toString ../../nixos/modules;
 }
diff --git a/morph/lib/issuer.nix b/morph/lib/issuer.nix
index 51046b436e297cdc5034134e3503556e8030588c..d60af799888c97ec8f97a061d40b54d3f2db82a7 100644
--- a/morph/lib/issuer.nix
+++ b/morph/lib/issuer.nix
@@ -1,23 +1,28 @@
-# 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 {
+# This, along with `customize-issuer.nix, contains all of the NixOS system
+# configuration necessary to specify an "issuer"-type system.  Originally, this
+# file has all the static configuration, and `customize-issuer.nix` was a function
+# that filled in the holes. We are in the process of merging the modules, using settings
+# instead of function arguments.
+# See https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/issues/80
+{ config, ...}:
+let
+  inherit (config.grid) publicKeyPath privateKeyPath;
+in {
   deployment = {
     secrets = {
       "ristretto-signing-key" = {
         destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
+        source = "${privateKeyPath}/ristretto.signing-key";
+        owner.user = "zkapissuer";
+        owner.group = "zkapissuer";
         permissions = "0400";
         action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
       };
       "stripe-secret-key" = {
         destination = "/run/keys/stripe.secret-key";
-        owner.user = "root";
-        owner.group = "root";
+        source = "${privateKeyPath}/stripe.secret";
+        owner.user = "zkapissuer";
+        owner.group = "zkapissuer";
         permissions = "0400";
         action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
       };
@@ -40,9 +45,6 @@ rec {
   };
 
   imports = [
-    # Allow us to remotely trigger updates to this system.
-    ../../nixos/modules/deployment.nix
-
     ../../nixos/modules/issuer.nix
     ../../nixos/modules/monitoring/vpn/client.nix
     ../../nixos/modules/monitoring/exporters/node.nix
@@ -51,9 +53,9 @@ rec {
   services.private-storage-issuer = {
     enable = true;
     tls = true;
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    stripeSecretKeyPath = deployment.secrets.stripe-secret-key.destination;
+    ristrettoSigningKeyPath = config.deployment.secrets.ristretto-signing-key.destination;
+    stripeSecretKeyPath = config.deployment.secrets.stripe-secret-key.destination;
     database = "SQLite3";
-    databasePath = "/var/db/vouchers.sqlite3";
+    databasePath = "/var/lib/zkapissuer/vouchers.sqlite3";
   };
 }
diff --git a/morph/lib/monitoring.nix b/morph/lib/monitoring.nix
index f8810be2f7e878eeb979e82d2746895d6157212e..bf92d1041f2bf9b9fb1ff4580a25ff7b596a9bbb 100644
--- a/morph/lib/monitoring.nix
+++ b/morph/lib/monitoring.nix
@@ -1,6 +1,6 @@
 # Similar to ``issuer.nix`` but for a "monitoring"-type system.  Holes are
 # filled by ``customize-monitoring.nix``.
-rec {
+{
   deployment = {
     secrets = {
       "monitoringvpn-private-key" = {
@@ -21,11 +21,6 @@ rec {
   };
 
   imports = [
-    # Give it a good SSH configuration.
-    ../../nixos/modules/ssh.nix
-    # Allow us to remotely trigger updates to this system.
-    ../../nixos/modules/deployment.nix
-
     ../../nixos/modules/monitoring/vpn/server.nix
     ../../nixos/modules/monitoring/server/grafana.nix
     ../../nixos/modules/monitoring/server/prometheus.nix
diff --git a/morph/lib/storage.nix b/morph/lib/storage.nix
index ebad3d17e17e0098f6e098d61d7c614fde91b31e..86e142286351237099337d38d03a9b54255b8246 100644
--- a/morph/lib/storage.nix
+++ b/morph/lib/storage.nix
@@ -1,10 +1,14 @@
 # Similar to ``issuer.nix`` but for a "storage"-type system.  Holes are filled
 # by ``customize-storage.nix``.
-rec {
+{ config, ...} :
+let
+  inherit (config.grid) publicKeyPath privateKeyPath;
+in {
   deployment = {
     secrets = {
       "ristretto-signing-key" = {
         destination = "/run/keys/ristretto.signing-key";
+        source = "${privateKeyPath}/ristretto.signing-key";
         owner.user = "root";
         owner.group = "root";
         permissions = "0400";
@@ -32,8 +36,6 @@ rec {
 
   # Any extra NixOS modules to load on this server.
   imports = [
-    # Allow us to remotely trigger updates to this system.
-    ../../nixos/modules/deployment.nix
     # Bring in our module for configuring the Tahoe-LAFS service and other
     # Private Storage-specific things.
     ../../nixos/modules/private-storage.nix
@@ -48,6 +50,6 @@ rec {
     # Yep.  Turn it on.
     enable = true;
     # Give it the Ristretto signing key to support authorization.
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
+    ristrettoSigningKeyPath = config.deployment.secrets.ristretto-signing-key.destination;
   };
 }
diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix
index ce1f928b2738066811425a3c7e3e3c85c03ac272..85c39c7271e9273b5e299980ebf7a46849bc9457 100644
--- a/nixos/modules/issuer.nix
+++ b/nixos/modules/issuer.nix
@@ -1,19 +1,12 @@
 # A NixOS module which can run a Ristretto-based issuer for PrivateStorage
 # ZKAPs.
-{ lib, pkgs, config, ... }: let
+{ lib, pkgs, ourpkgs, config, ... }: let
   cfg = config.services.private-storage-issuer;
-  # Our own nixpkgs fork:
-  ourpkgs = import ../../nixpkgs-ps.nix {};
 in {
-  imports = [
-    # Give it a good SSH configuration.
-    ../../nixos/modules/ssh.nix
-  ];
-
   options = {
     services.private-storage-issuer.enable = lib.mkEnableOption "PrivateStorage ZKAP Issuer Service";
     services.private-storage-issuer.package = lib.mkOption {
-      default = ourpkgs.zkapissuer.components.exes."PaymentServer-exe";
+      default = ourpkgs.zkapissuer;
       type = lib.types.package;
       example = lib.literalExample "pkgs.zkapissuer.components.exes.\"PaymentServer-exe\"";
       description = ''
@@ -115,11 +108,13 @@ in {
 
   config =
     let
-      certroot = "/var/lib/letsencrypt/live";
       # We'll refer to this collection of domains by the first domain in the
       # list.
       domain = builtins.head cfg.domains;
-      certServiceName = "cert-${domain}";
+      certServiceName = "acme-${domain}";
+      # Payment server internal http port (arbitrary, non-priviledged):
+      internalHttpPort = "1061";
+
     in lib.mkIf cfg.enable {
     # Add a systemd service to run PaymentServer.
     systemd.services.zkapissuer = {
@@ -127,25 +122,32 @@ in {
       description = "ZKAP Issuer";
       wantedBy = [ "multi-user.target" ];
 
-      # Make sure we have a certificate the first time, if we are running over
-      # TLS and require a certificate.
-      requires = lib.optional cfg.tls "${certServiceName}.service";
-
-      after = [
-        # Make sure there is a network so we can bind to all of the
-        # interfaces.
-        "network.target"
-      ] ++
-        # Make sure we run after the certificate is issued, if we are running
-        # over TLS and require a certificate.
-        lib.optional cfg.tls "${certServiceName}.service";
-
       # It really shouldn't ever exit on its own!  If it does, it's a bug
       # we'll have to fix.  Restart it and hope it doesn't happen too much
       # before we can fix whatever the issue is.
       serviceConfig.Restart = "always";
       serviceConfig.Type = "simple";
 
+      # Run w/o privileges
+      serviceConfig = {
+        DynamicUser = false;
+        User = "zkapissuer";
+        Group = "zkapissuer";
+      };
+
+      # Make systemd create a User/Group owned directory for PaymentServer
+      # state. According to the docs at
+      # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectory=
+      # "The specified directory names must be relative" ... this
+      # makes systemd create /var/lib/zkapissuer/ for us:
+      serviceConfig.StateDirectory = "zkapissuer";
+      serviceConfig.StateDirectoryMode = "0750";
+
+      # Bail if there is still an old (root-owned) DB file on this system.
+      # If you hit this, and this /var/db/ file is indeed current, move it to
+      # /var/lib/zkapissuer/vouchers.sqlite3 and chown it to zkapissuer:zkapissuer.
+      unitConfig.AssertPathExists = "!/var/db/vouchers.sqlite3";
+
       script =
         let
           # Compute the right command line arguments to pass to it.  The
@@ -158,16 +160,7 @@ in {
             if cfg.database == "Memory"
               then "--database Memory"
               else "--database SQLite3 --database-path ${cfg.databasePath}";
-          httpsArgs =
-            if cfg.tls
-            then
-              "--https-port 443 " +
-              "--https-certificate-path ${certroot}/${domain}/cert.pem " +
-              "--https-certificate-chain-path ${certroot}/${domain}/chain.pem " +
-              "--https-key-path ${certroot}/${domain}/privkey.pem"
-            else
-              # Only for automated testing.
-              "--http-port 80";
+          httpArgs = "--http-port ${internalHttpPort}";
 
           prefixOption = s: "--cors-origin=" + s;
           originStrings = map prefixOption cfg.allowedChargeOrigins;
@@ -179,33 +172,21 @@ in {
             "--stripe-endpoint-scheme ${cfg.stripeEndpointScheme} " +
             "--stripe-endpoint-port ${toString cfg.stripeEndpointPort}";
         in
-          "${cfg.package}/bin/PaymentServer-exe ${originArgs} ${issuerArgs} ${databaseArgs} ${httpsArgs} ${stripeArgs}";
+          "${cfg.package.exePath} ${originArgs} ${issuerArgs} ${databaseArgs} ${httpArgs} ${stripeArgs}";
     };
 
-    # Certificate renewal.  A short-lived service meant to be repeatedly
-    # activated to request a new certificate be issued, if the current one is
-    # close to expiring.
-    systemd.services.${certServiceName} = {
-      enable = cfg.tls;
-      description = "Certificate ${domain}";
-      # Activate this unit periodically so that certbot can determine if the
-      # certificate expiration time is close enough to warrant a renewal
-      # request.
-      startAt = "weekly";
-
-      serviceConfig = {
-        ExecStart =
-        let
-          configArgs = "--config-dir /var/lib/letsencrypt --work-dir /var/run/letsencrypt --logs-dir /var/run/log/letsencrypt";
-        in
-          pkgs.writeScript "cert-${domain}-start.sh" ''
-          #!${pkgs.runtimeShell} -e
-          # Register if necessary.
-          ${pkgs.certbot}/bin/certbot register ${configArgs} --non-interactive --agree-tos -m ${cfg.letsEncryptAdminEmail} || true
-          # Obtain the certificate.
-          ${pkgs.certbot}/bin/certbot certonly ${configArgs} --non-interactive --standalone --expand --domains ${builtins.concatStringsSep "," cfg.domains}
-          '';
-      };
+    # PaymentServer runs as this user and group by default
+    # Mind the comments in nixpkgs/nixos/modules/misc/ids.nix: "When adding a uid,
+    # make sure it doesn't match an existing gid. And don't use uids above 399!"
+    ids.uids.zkapissuer = 397;
+    ids.gids.zkapissuer = 397;
+    users.extraGroups.zkapissuer.gid = config.ids.gids.zkapissuer;
+    users.extraUsers.zkapissuer = {
+      uid = config.ids.uids.zkapissuer;
+      isNormalUser = false;
+      group = "zkapissuer";
+      # Let PaymentServer read from keys, if necessary.
+      extraGroups = [ "keys" ];
     };
 
     # Open 80 and 443 for the certbot HTTP server and the PaymentServer HTTPS server.
@@ -213,5 +194,38 @@ in {
       80
       443
     ];
+
+    # NGINX reverse proxy
+    security.acme.email = cfg.letsEncryptAdminEmail;
+    security.acme.acceptTerms = true;
+    services.nginx = {
+      enable = true;
+
+      recommendedGzipSettings = true;
+      recommendedOptimisation = true;
+      recommendedProxySettings = true;
+      recommendedTlsSettings = true;
+
+      virtualHosts."${domain}" = {
+        serverAliases = builtins.tail cfg.domains;
+        enableACME = cfg.tls;
+        forceSSL = cfg.tls;
+        locations."/v1/" = {
+          # Only forward requests beginning with /v1/ so
+          # we pass less scanning spam on to our backend
+          # Want a regex instead? try locations."~ /v\d+/"
+          proxyPass = "http://127.0.0.1:${internalHttpPort}";
+        };
+        locations."/metrics" = {
+          # Only allow our monitoringvpn subnet
+          extraConfig = ''
+            allow 172.23.23.0/24;
+            deny all;
+          '';
+          proxyPass = "http://127.0.0.1:${internalHttpPort}";
+        };
+      };
+    };
+
   };
 }
diff --git a/nixos/modules/monitoring/server/grafana-config/services-overview.json b/nixos/modules/monitoring/server/grafana-config/payments.json
similarity index 57%
rename from nixos/modules/monitoring/server/grafana-config/services-overview.json
rename to nixos/modules/monitoring/server/grafana-config/payments.json
index 1606d2e59593fea116323dfaea25448bc4fbc9b6..6bb121e4f0ad377145956fb2d75bb0679524bd8a 100644
--- a/nixos/modules/monitoring/server/grafana-config/services-overview.json
+++ b/nixos/modules/monitoring/server/grafana-config/payments.json
@@ -12,11 +12,10 @@
       }
     ]
   },
-  "description": "RED: Requests-Errors-Duration for our services",
+  "description": "PaymentServer and related metrics",
   "editable": true,
   "gnetId": null,
   "graphTooltip": 0,
-  "id": 2,
   "links": [],
   "panels": [
     {
@@ -28,9 +27,251 @@
         "x": 0,
         "y": 0
       },
+      "id": 24,
+      "panels": [],
+      "title": "Payments",
+      "type": "row"
+    },
+    {
+      "aliasColors": {
+        "Attempts": "yellow",
+        "Successes": "green"
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "Our calls to the Stripe API: Attempted and successful credit card charges.",
+      "fieldConfig": {
+        "defaults": {},
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 0,
+        "y": 1
+      },
+      "hiddenSeries": false,
+      "id": 22,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "7.5.7",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "exemplar": true,
+          "expr": "payment_processors_stripe_charge_attempts",
+          "hide": false,
+          "interval": "",
+          "legendFormat": "Attempts",
+          "refId": "B"
+        },
+        {
+          "exemplar": true,
+          "expr": "payment_processors_stripe_charge_successes",
+          "interval": "",
+          "legendFormat": "Successes",
+          "refId": "C"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "Stripe",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:350",
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "$$hashKey": "object:351",
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {
+        "Redeemed vouchers": "yellow"
+      },
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "",
+      "fieldConfig": {
+        "defaults": {},
+        "overrides": []
+      },
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 12,
+        "x": 12,
+        "y": 1
+      },
+      "hiddenSeries": false,
+      "id": 20,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "alertThreshold": true
+      },
+      "percentage": false,
+      "pluginVersion": "7.5.7",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [
+        {
+          "$$hashKey": "object:223",
+          "alias": "Redeemed vouchers",
+          "yaxis": 1
+        },
+        {
+          "$$hashKey": "object:230",
+          "alias": "Issued signatures",
+          "yaxis": 2
+        }
+      ],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "exemplar": true,
+          "expr": "payment_redemption_signatures_issued",
+          "interval": "",
+          "legendFormat": "Issued signatures",
+          "refId": "A"
+        },
+        {
+          "exemplar": true,
+          "expr": "payment_redemption_voucher_redeemed",
+          "format": "time_series",
+          "hide": false,
+          "interval": "",
+          "legendFormat": "Redeemed vouchers",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "Redemption",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "$$hashKey": "object:285",
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": "0",
+          "show": true
+        },
+        {
+          "$$hashKey": "object:286",
+          "decimals": null,
+          "format": "short",
+          "label": "",
+          "logBase": 1,
+          "max": null,
+          "min": "0",
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "datasource": null,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 8
+      },
       "id": 18,
       "panels": [],
-      "title": "Payments v1/stripe/charge",
+      "title": "HTTP v1/stripe/charge",
       "type": "row"
     },
     {
@@ -40,14 +281,21 @@
       "dashes": false,
       "datasource": null,
       "description": "HTTPS responses per second",
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
       "fill": 1,
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
         "w": 8,
         "x": 0,
-        "y": 1
+        "y": 9
       },
+      "hiddenSeries": false,
       "id": 4,
       "legend": {
         "avg": false,
@@ -62,9 +310,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
+      "pluginVersion": "7.5.7",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -74,8 +323,10 @@
       "steppedLine": false,
       "targets": [
         {
-          "expr": "rate(http_responses_total{path=\"v1/stripe/charge\"}[5m])",
+          "exemplar": true,
+          "expr": "rate(http_responses_total{path=\"v1/stripe/charge\", instance=\"payments\"}[5m])",
           "instant": false,
+          "interval": "",
           "legendFormat": "{{status}}",
           "refId": "A"
         }
@@ -84,7 +335,7 @@
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "v1/stripe/charge RPS",
+      "title": "Requests per second",
       "tooltip": {
         "shared": true,
         "sort": 0,
@@ -100,14 +351,16 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:452",
           "format": "short",
           "label": null,
           "logBase": 1,
           "max": null,
-          "min": null,
+          "min": "0",
           "show": true
         },
         {
+          "$$hashKey": "object:453",
           "format": "short",
           "label": null,
           "logBase": 1,
@@ -128,14 +381,21 @@
       "dashes": false,
       "datasource": null,
       "description": "",
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
       "fill": 1,
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
         "w": 8,
         "x": 8,
-        "y": 1
+        "y": 9
       },
+      "hiddenSeries": false,
       "id": 15,
       "legend": {
         "avg": false,
@@ -150,9 +410,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "dataLinks": []
+        "alertThreshold": true
       },
-      "percentage": true,
+      "percentage": false,
+      "pluginVersion": "7.5.7",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -162,12 +423,16 @@
       "steppedLine": false,
       "targets": [
         {
-          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"4XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "exemplar": true,
+          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"4XX\"}) / sum(http_responses_total{path=\"v1/stripe/charge\"})",
+          "interval": "",
           "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\"})",
+          "exemplar": true,
+          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"5XX\"}) / sum(http_responses_total{path=\"v1/stripe/charge\"})",
+          "interval": "",
           "legendFormat": "Server error (5XX) rate",
           "refId": "B"
         }
@@ -176,7 +441,7 @@
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "v1/stripe/charge error rate",
+      "title": "Error rate",
       "tooltip": {
         "shared": true,
         "sort": 0,
@@ -192,14 +457,16 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:576",
           "format": "percentunit",
           "label": null,
           "logBase": 1,
-          "max": "100",
+          "max": "1",
           "min": "0",
           "show": true
         },
         {
+          "$$hashKey": "object:577",
           "format": "percent",
           "label": null,
           "logBase": 1,
@@ -214,21 +481,33 @@
       }
     },
     {
-      "aliasColors": {},
+      "aliasColors": {
+        "=< 0.1s": "blue",
+        "=< 1s": "green",
+        "=< 5s": "yellow",
+        "> 5s": "orange"
+      },
       "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",
+      "description": "Request durations, stacked",
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
       "fill": 2,
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
         "w": 8,
         "x": 16,
-        "y": 1
+        "y": 9
       },
+      "hiddenSeries": false,
       "id": 12,
       "legend": {
         "avg": false,
@@ -244,51 +523,68 @@
       "links": [],
       "nullPointMode": "null",
       "options": {
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "6.4.3",
+      "pluginVersion": "7.5.7",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
       "seriesOverrides": [],
       "spaceLength": 10,
-      "stack": false,
+      "stack": true,
       "steppedLine": false,
       "targets": [
         {
-          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"0.01\"}",
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"0.1\"}",
           "format": "time_series",
           "hide": false,
           "instant": false,
+          "interval": "",
           "intervalFactor": 1,
-          "legendFormat": "=< 0.01s",
+          "legendFormat": "=< 0.1s",
           "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\"}",
+          "exemplar": true,
+          "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.1\"}",
           "format": "time_series",
           "hide": false,
           "instant": false,
+          "interval": "",
           "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\"}",
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"5.0\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"1.0\"}",
           "format": "time_series",
           "hide": false,
           "instant": false,
+          "interval": "",
           "intervalFactor": 1,
-          "legendFormat": "> 1s",
+          "legendFormat": "=< 5s",
           "refId": "B"
+        },
+        {
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"+Inf\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"5.0\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "interval": "",
+          "intervalFactor": 1,
+          "legendFormat": "> 5s",
+          "refId": "C"
         }
       ],
       "thresholds": [],
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "v1/stripe/charge durations",
+      "title": "Durations",
       "tooltip": {
         "shared": true,
         "sort": 0,
@@ -304,14 +600,16 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:625",
           "format": "short",
           "label": null,
           "logBase": 1,
           "max": null,
-          "min": null,
+          "min": "0",
           "show": true
         },
         {
+          "$$hashKey": "object:626",
           "format": "short",
           "label": null,
           "logBase": 1,
@@ -332,11 +630,11 @@
         "h": 1,
         "w": 24,
         "x": 0,
-        "y": 8
+        "y": 16
       },
       "id": 11,
       "panels": [],
-      "title": "Payments v1/redeem",
+      "title": "HTTP v1/redeem",
       "type": "row"
     },
     {
@@ -346,14 +644,21 @@
       "dashes": false,
       "datasource": null,
       "description": "HTTPS responses per second",
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
       "fill": 1,
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
         "w": 8,
         "x": 0,
-        "y": 9
+        "y": 17
       },
+      "hiddenSeries": false,
       "id": 2,
       "legend": {
         "avg": false,
@@ -368,9 +673,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
+      "pluginVersion": "7.5.7",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -391,7 +697,7 @@
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "v1/redeem RPS",
+      "title": "Requests per second",
       "tooltip": {
         "shared": true,
         "sort": 0,
@@ -407,14 +713,16 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:751",
           "format": "short",
           "label": null,
           "logBase": 1,
           "max": null,
-          "min": null,
+          "min": "0",
           "show": true
         },
         {
+          "$$hashKey": "object:752",
           "format": "short",
           "label": null,
           "logBase": 1,
@@ -434,14 +742,21 @@
       "dashLength": 10,
       "dashes": false,
       "datasource": null,
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
       "fill": 1,
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
         "w": 8,
         "x": 8,
-        "y": 9
+        "y": 17
       },
+      "hiddenSeries": false,
       "id": 16,
       "legend": {
         "avg": false,
@@ -456,9 +771,10 @@
       "linewidth": 1,
       "nullPointMode": "null",
       "options": {
-        "dataLinks": []
+        "alertThreshold": true
       },
-      "percentage": true,
+      "percentage": false,
+      "pluginVersion": "7.5.7",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
@@ -482,7 +798,7 @@
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "v1/redeem error rate",
+      "title": "Error rate",
       "tooltip": {
         "shared": true,
         "sort": 0,
@@ -498,14 +814,16 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:804",
           "format": "percentunit",
           "label": null,
           "logBase": 1,
-          "max": "100",
+          "max": "1",
           "min": "0",
           "show": true
         },
         {
+          "$$hashKey": "object:805",
           "format": "percent",
           "label": null,
           "logBase": 1,
@@ -520,21 +838,33 @@
       }
     },
     {
-      "aliasColors": {},
+      "aliasColors": {
+        "=< 0.1s": "blue",
+        "=< 1s": "green",
+        "=< 5s": "yellow",
+        "> 5s": "orange"
+      },
       "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",
+      "description": "Request durations, stacked.",
+      "fieldConfig": {
+        "defaults": {
+          "links": []
+        },
+        "overrides": []
+      },
       "fill": 2,
       "fillGradient": 0,
       "gridPos": {
         "h": 7,
         "w": 8,
         "x": 16,
-        "y": 9
+        "y": 17
       },
+      "hiddenSeries": false,
       "id": 13,
       "legend": {
         "avg": false,
@@ -550,51 +880,68 @@
       "links": [],
       "nullPointMode": "null",
       "options": {
-        "dataLinks": []
+        "alertThreshold": true
       },
       "percentage": false,
-      "pluginVersion": "6.4.3",
+      "pluginVersion": "7.5.7",
       "pointradius": 2,
       "points": false,
       "renderer": "flot",
       "seriesOverrides": [],
       "spaceLength": 10,
-      "stack": false,
+      "stack": true,
       "steppedLine": false,
       "targets": [
         {
-          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.01\"}",
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.1\"}",
           "format": "time_series",
           "hide": false,
           "instant": false,
+          "interval": "",
           "intervalFactor": 1,
-          "legendFormat": "=< 0.01s",
+          "legendFormat": "=< 0.1s",
           "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\"}",
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"1.0\"} - ignoring(le) http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.1\"}",
           "format": "time_series",
           "hide": false,
           "instant": false,
+          "interval": "",
           "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\"}",
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"5.0\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"1.0\"}",
           "format": "time_series",
           "hide": false,
           "instant": false,
+          "interval": "",
           "intervalFactor": 1,
-          "legendFormat": "> 1s",
+          "legendFormat": "=< 5s",
           "refId": "B"
+        },
+        {
+          "exemplar": true,
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"+Inf\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"5.0\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "interval": "",
+          "intervalFactor": 1,
+          "legendFormat": "> 5s",
+          "refId": "C"
         }
       ],
       "thresholds": [],
       "timeFrom": null,
       "timeRegions": [],
       "timeShift": null,
-      "title": "v1/redeem durations",
+      "title": "Durations",
       "tooltip": {
         "shared": true,
         "sort": 0,
@@ -610,14 +957,16 @@
       },
       "yaxes": [
         {
+          "$$hashKey": "object:853",
           "format": "short",
           "label": null,
           "logBase": 1,
           "max": null,
-          "min": null,
+          "min": "0",
           "show": true
         },
         {
+          "$$hashKey": "object:854",
           "format": "short",
           "label": null,
           "logBase": 1,
@@ -632,8 +981,8 @@
       }
     }
   ],
-  "refresh": "",
-  "schemaVersion": 20,
+  "refresh": "5m",
+  "schemaVersion": 27,
   "style": "dark",
   "tags": [],
   "templating": {
@@ -658,7 +1007,7 @@
     ]
   },
   "timezone": "",
-  "title": "Services overview",
-  "uid": "ServicesOverview",
-  "version": 6
+  "title": "Payments",
+  "uid": "Payments",
+  "version": 1
 }
diff --git a/nixos/modules/monitoring/server/grafana-config/resources-overview.json b/nixos/modules/monitoring/server/grafana-config/resources-overview.json
index 8cf342514143d84de1263a3d6debaf8e40b4c922..cb5bc91da7c3adbb1c9377473b053d31d53550f0 100644
--- a/nixos/modules/monitoring/server/grafana-config/resources-overview.json
+++ b/nixos/modules/monitoring/server/grafana-config/resources-overview.json
@@ -1279,7 +1279,7 @@
     "to": "now"
   },
   "timepicker": {},
-  "timezone": "utc",
+  "timezone": "",
   "title": "Resources overview",
   "uid": "ResourcesOverview",
   "version": 1
diff --git a/nixos/modules/monitoring/server/grafana.nix b/nixos/modules/monitoring/server/grafana.nix
index 2fd9e7f7c83217afc4943e644f6d3161e56c49f9..c23150238241db561bae52aa50e4878b6961f9e6 100644
--- a/nixos/modules/monitoring/server/grafana.nix
+++ b/nixos/modules/monitoring/server/grafana.nix
@@ -86,6 +86,8 @@ in {
       extraOptions = {
         # Defend against DNS rebinding attacks.
         SERVER_ENFORCE_DOMAIN = "true";
+        # Same time zone for all users by default
+        DATE_FORMATS_DEFAULT_TIMEZONE = "UTC";
       };
 
       auth = {
diff --git a/nixos/modules/packages.nix b/nixos/modules/packages.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d6518dcf290c27b95e3428434623a63cfbdb8e19
--- /dev/null
+++ b/nixos/modules/packages.nix
@@ -0,0 +1,8 @@
+# A NixOS module which exposes custom packages to other modules.
+{ pkgs, ...}:
+{
+  config = {
+    # Expose `nixos/pkgs` as a new module argument `ourpkgs`.
+    _module.args.ourpkgs = pkgs.callPackage ../../nixos/pkgs {};
+  };
+}
diff --git a/nixos/modules/private-storage.nix b/nixos/modules/private-storage.nix
index fa5fea837c544e66ae8811a2e3c468a67a18759e..c119a3d3417f7d4b7ec07c5652b65122dc5fce12 100644
--- a/nixos/modules/private-storage.nix
+++ b/nixos/modules/private-storage.nix
@@ -1,6 +1,6 @@
 # A NixOS module which can instantiate a Tahoe-LAFS storage server in the
 # preferred configuration for the Private Storage grid.
-{ pkgs, lib, config, ... }:
+{ pkgs, ourpkgs, lib, config, ... }:
 let
   # Grab the configuration for this module for convenient access below.
   cfg = config.services.private-storage;
@@ -8,9 +8,6 @@ let
   # TODO: This path copied from tahoe.nix.
   tahoe-base = "/var/db/tahoe-lafs";
 
-  # Our own nixpkgs fork:
-  ourpkgs = import ../../nixpkgs-ps.nix {};
-
   # The full path to the directory where the storage server will write
   # incident reports.
   incidents-dir = "${tahoe-base}/${storage-node-name}/logs/incidents";
@@ -30,8 +27,6 @@ let
 in
 {
   imports = [
-    # Give it a good SSH configuration.
-    ./ssh.nix
     # Load our tahoe-lafs module.  It is configurable in the way I want it to
     # be configurable.
     ./tahoe.nix
diff --git a/nixos/modules/tests/tahoe.nix b/nixos/modules/tests/tahoe.nix
deleted file mode 100644
index df7acdf3cde3e8101a1119dbce127b17a68ef589..0000000000000000000000000000000000000000
--- a/nixos/modules/tests/tahoe.nix
+++ /dev/null
@@ -1,72 +0,0 @@
-{ ... }: {
-  nodes = {
-    storage = { config, pkgs, ... }: {
-      imports = [
-        ../tahoe.nix
-      ];
-
-      services.tahoe.nodes.storage = {
-        package = pkgs.privatestorage;
-        sections = {
-          node = {
-            nickname = "storage";
-            "web.port" = "tcp:4000:interface=127.0.0.1";
-            "tub.port" = "tcp:4001";
-            "tub.location" = "tcp:127.0.0.1:4001";
-          };
-          storage = {
-            enabled = true;
-          };
-        };
-      };
-    };
-  };
-  testScript = ''
-  startAll;
-
-  # After the service starts, destroy the "created" marker to force it to
-  # re-create its internal state.
-  $storage->waitForOpenPort(4001);
-  $storage->succeed("systemctl stop tahoe.storage");
-  $storage->succeed("rm /var/db/tahoe-lafs/storage.created");
-  $storage->succeed("systemctl start tahoe.storage");
-
-  # After it starts up again, verify it has consistent internal state and a
-  # backup of the prior state.
-  $storage->waitForOpenPort(4001);
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.created ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.1 ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.1/private/node.privkey ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.1/private/node.pem ]");
-  $storage->succeed("[ ! -e /var/db/tahoe-lafs/storage.2 ]");
-
-  # Stop it again, once again destroy the "created" marker, and this time also
-  # jam some partial state in the way that will need cleanup.
-  $storage->succeed("systemctl stop tahoe.storage");
-  $storage->succeed("rm /var/db/tahoe-lafs/storage.created");
-  $storage->succeed("mkdir -p /var/db/tahoe-lafs/storage.atomic/partial");
-  eval {
-    $storage->succeed("systemctl start tahoe.storage");
-    1;
-  } or do {
-    my ($x, $y) = $storage->execute("journalctl -u tahoe.storage");
-    $storage->log($y);
-    die $@;
-  };
-
-  # After it starts up again, verify it has consistent internal state and
-  # backups of the prior two states.  It also has no copy of the inconsistent
-  # state because it could never have been used.
-  $storage->waitForOpenPort(4001);
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.created ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.1 ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.2 ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.2/private/node.privkey ]");
-  $storage->succeed("[ -e /var/db/tahoe-lafs/storage.2/private/node.pem ]");
-  $storage->succeed("[ ! -e /var/db/tahoe-lafs/storage.atomic ]");
-  $storage->succeed("[ ! -e /var/db/tahoe-lafs/storage/partial ]");
-  $storage->succeed("[ ! -e /var/db/tahoe-lafs/storage.3 ]");
-  '';
-}
diff --git a/nixos/modules/update-deployment b/nixos/modules/update-deployment
index d8d32ff64eb52123be448ed598d00ab2bc1850da..1c8960588f418e57eeaadb7ad29db4285369cbdd 100755
--- a/nixos/modules/update-deployment
+++ b/nixos/modules/update-deployment
@@ -13,14 +13,10 @@ shift
 # configuration that controls what value is actually passed when an update is
 # triggered.
 case "${GRIDNAME}" in
-    "local")
+    "local"|"testing")
 	BRANCH="develop"
 	;;
 
-    "testing")
-	BRANCH="staging"
-	;;
-
     "production")
 	BRANCH="production"
 	;;
@@ -50,10 +46,10 @@ fi
 # Get us to a pristine checkout of the right branch.
 git -C "${CHECKOUT}" reset --hard "origin/${BRANCH}"
 
-# If we happen to be on the local grid then fix the undefined key.
+# If we happen to be on the local grid then add the required user.nix file
+# containing ssh-keys.
 if [ "${GRIDNAME}" = "local" ]; then
-    KEY="$(cat /etc/ssh/authorized_keys.d/vagrant)"
-    sed -i "s_undefined_\"${KEY}\"_" "${CHECKOUT}"/morph/grid/${GRIDNAME}/public-keys/users.nix
+    echo "import /etc/nixos/ssh-users.nix" > "${CHECKOUT}"/morph/grid/"${GRIDNAME}"/public-keys/users.nix
 fi
 
 # Compute a log message explaining what we're doing.
diff --git a/nixos/pkgs/default.nix b/nixos/pkgs/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..3d534430377cb5fbbf0739d60a8a7ca9bb0419f6
--- /dev/null
+++ b/nixos/pkgs/default.nix
@@ -0,0 +1,24 @@
+# Expose all our locally defined packages as attributes.
+# In `gridlib.base`, we expose this as a new `ourpkgs` module argument.
+# To access this directly, you can call this as::
+#
+#    pkgs.callPackage ./nixos/pkgs
+{buildPlatform, hostPlatform, callPackage}:
+let
+  # Our own nixpkgs fork:
+  ourpkgs = import ../../nixpkgs-ps.nix {
+    # Ensure that the fork is configured for the same system
+    # as we were called with.
+    localSystem = buildPlatform;
+    crossSystem = hostPlatform;
+    # Ensure that configuration of the system where this runs
+    # doesn't leak into what we build.
+    # See https://github.com/NixOS/nixpkgs/issues/62513
+    config = {};
+    overlays = [];
+  };
+in
+{
+  zkapissuer = callPackage ./zkapissuer {};
+  inherit (ourpkgs) privatestorage leasereport;
+}
diff --git a/nixos/pkgs/zkapissuer/default.nix b/nixos/pkgs/zkapissuer/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..b4f90d3582cd686fbdf62a6267cb1070c05e9c57
--- /dev/null
+++ b/nixos/pkgs/zkapissuer/default.nix
@@ -0,0 +1,6 @@
+{ callPackage }:
+let
+  repo = callPackage ./repo.nix { };
+  PaymentServer = (import "${repo}/nix").PaymentServer;
+in
+  PaymentServer.components.exes."PaymentServer-exe"
diff --git a/nixos/pkgs/zkapissuer/repo.nix b/nixos/pkgs/zkapissuer/repo.nix
new file mode 100644
index 0000000000000000000000000000000000000000..6646a2e32eb8e5a747e4491ce43f706fee65724c
--- /dev/null
+++ b/nixos/pkgs/zkapissuer/repo.nix
@@ -0,0 +1,7 @@
+{ fetchFromGitHub }:
+fetchFromGitHub {
+  owner = "PrivateStorageio";
+  repo = "PaymentServer";
+  rev = "ff30e85c231a3b5ad76426bbf8801f8f76884367";
+  sha256 = "1spz19f5z96shmfpazj0rv6877xvchf3gl49a4xahjbbsz39x34x";
+}
diff --git a/nixos/system-tests.nix b/nixos/system-tests.nix
index 5f51d01dd57267b75b3742c76c03c1393676d426..73b6665ab91e4d9a8a2200fb0eec7ff596f79b39 100644
--- a/nixos/system-tests.nix
+++ b/nixos/system-tests.nix
@@ -1,7 +1,7 @@
 # The overall system test suite for PrivateStorageio NixOS configuration.
 let
-  pkgs = import ../nixpkgs-ps.nix { };
+  pkgs = import ../nixpkgs-2105.nix { };
 in {
-  private-storage = pkgs.nixosTest ./modules/tests/private-storage.nix;
-  tahoe = pkgs.nixosTest ./modules/tests/tahoe.nix;
+  private-storage = pkgs.nixosTest ./tests/private-storage.nix;
+  tahoe = pkgs.nixosTest ./tests/tahoe.nix;
 }
diff --git a/nixos/modules/tests/exercise-storage.py b/nixos/tests/exercise-storage.py
similarity index 100%
rename from nixos/modules/tests/exercise-storage.py
rename to nixos/tests/exercise-storage.py
diff --git a/nixos/modules/tests/get-passes.py b/nixos/tests/get-passes.py
similarity index 100%
rename from nixos/modules/tests/get-passes.py
rename to nixos/tests/get-passes.py
diff --git a/nixos/modules/tests/node.pem b/nixos/tests/node.pem
similarity index 100%
rename from nixos/modules/tests/node.pem
rename to nixos/tests/node.pem
diff --git a/nixos/modules/tests/private-storage.nix b/nixos/tests/private-storage.nix
similarity index 51%
rename from nixos/modules/tests/private-storage.nix
rename to nixos/tests/private-storage.nix
index 353abc891fafd1cc988e47a1befa530a012470dc..6fb85a6713b4668ef4bdfa239480485bfbb52a18 100644
--- a/nixos/modules/tests/private-storage.nix
+++ b/nixos/tests/private-storage.nix
@@ -14,7 +14,7 @@ let
     "ssh" "-oStrictHostKeyChecking=no" "-i" "/tmp/ssh_key" "${username}@${hostname}" ":"
   ];
 
-  # Separate helper programs so we can write as little perl inside a string
+  # Separate helper programs so we can write as little python inside a string
   # inside a nix expression as possible.
   run-introducer = ./run-introducer.py;
   run-client = ./run-client.py;
@@ -72,33 +72,30 @@ let
     networking.dhcpcd.enable = false;
   };
 
-  # Return a Perl program fragment to run a shell command on one of the nodes.
+  # Return a python program fragment to run a shell command on one of the nodes.
   # The first argument is the name of the node.  The second is a list of the
   # argv to run.
   #
-  # The program's output is piped to systemd-cat and the Perl fragment
+  # The program's output is piped to systemd-cat and the python fragment
   # evaluates to success if the command exits with a success status.
   runOnNode = node: argv:
     let
       command = builtins.concatStringsSep " " argv;
     in
-      "
-      \$${node}->succeed('set -eo pipefail; ${command} | systemd-cat');
-      # succeed() is not success but 1 is.
-      1;
-      ";
+      "${node}.succeed('set -eo pipefail; ${command} | systemd-cat')";
 in {
   # https://nixos.org/nixos/manual/index.html#sec-nixos-tests
   # https://nixos.mayflower.consulting/blog/2019/07/11/leveraging-nixos-tests-in-your-project/
   nodes = rec {
     # Get a machine where we can run a Tahoe-LAFS client node.
     client =
-      { config, pkgs, ... }:
-      { environment.systemPackages = [
+      { config, pkgs, ourpkgs, ... }:
+      { imports = [ ../modules/packages.nix ];
+        environment.systemPackages = [
           pkgs.daemonize
           # A Tahoe-LAFS configuration capable of using the right storage
           # plugin.
-          pkgs.privatestorage
+          ourpkgs.privatestorage
           # Support for the tests we'll run.
           (pkgs.python3.withPackages (ps: [ ps.requests ps.hyperlink ]))
         ];
@@ -111,7 +108,9 @@ in {
     storage =
       { config, pkgs, ... }:
       { imports =
-        [ ../private-storage.nix
+        [ ../modules/packages.nix
+          ../modules/private-storage.nix
+          ../modules/ssh.nix
         ];
         services.private-storage = {
           enable = true;
@@ -128,7 +127,9 @@ in {
     issuer =
     { config, pkgs, ... }:
     { imports =
-      [ ../issuer.nix
+      [ ../modules/packages.nix
+        ../modules/issuer.nix
+        ../modules/ssh.nix
       ];
       services.private-storage.sshUsers = sshUsers;
 
@@ -174,138 +175,135 @@ in {
       };
   };
 
-  # Test the machines with a Perl program (sobbing).
-  testScript =
-    ''
-      # Boot the VMs.  We used to do them all in parallel but the boot
-      # sequence got flaky at some point for some reason I don't
-      # understand. :/ It might be related to this:
-      #
-      # https://discourse.nixos.org/t/nixos-ppc64le-vm-does-not-have-dev-vda-device/11548/9
-      #
-      # See <nixpkgs/nixos/modules/virtualisation/qemu-vm.nix> for the Nix
-      # that constructs the QEMU command that gets run.
-      #
-      # Boot them one at a time for now.
-      $issuer->connect();
-      $introducer->connect();
-      $storage->connect();
-      $client->connect();
-      $api_stripe_com->connect();
-
-      # The issuer and the storage server should accept SSH connections.  This
-      # doesn't prove it is so but if it fails it's a pretty good indication
-      # it isn't so.
-      $storage->waitForOpenPort(22);
-      ${runOnNode "issuer" (ssh "probeuser" "storage")}
-      ${runOnNode "issuer" (ssh "root" "storage")}
-      $issuer->waitForOpenPort(22);
-      ${runOnNode "storage" (ssh "probeuser" "issuer")}
-      ${runOnNode "storage" (ssh "root" "issuer")}
-
-      # Set up a Tahoe-LAFS introducer.
-      $introducer->copyFileFromHost(
-          '${pemFile}',
-          '/tmp/node.pem'
-      );
-
-      eval {
+  # Test the machines with a Python program.
+  testScript = ''
+    # Boot the VMs.  We used to do them all in parallel but the boot
+    # sequence got flaky at some point for some reason I don't
+    # understand. :/ It might be related to this:
+    #
+    # https://discourse.nixos.org/t/nixos-ppc64le-vm-does-not-have-dev-vda-device/11548/9
+    #
+    # See <nixpkgs/nixos/modules/virtualisation/qemu-vm.nix> for the Nix
+    # that constructs the QEMU command that gets run.
+    #
+    # Boot them one at a time for now.
+    issuer.connect()
+    introducer.connect()
+    storage.connect()
+    client.connect()
+    api_stripe_com.connect()
+
+    # The issuer and the storage server should accept SSH connections.  This
+    # doesn't prove it is so but if it fails it's a pretty good indication
+    # it isn't so.
+    storage.wait_for_open_port(22)
+    ${runOnNode "issuer" (ssh "probeuser" "storage")}
+    ${runOnNode "issuer" (ssh "root" "storage")}
+    issuer.wait_for_open_port(22)
+    ${runOnNode "storage" (ssh "probeuser" "issuer")}
+    ${runOnNode "storage" (ssh "root" "issuer")}
+
+    # Set up a Tahoe-LAFS introducer.
+    introducer.copy_from_host('${pemFile}', '/tmp/node.pem')
+
+    try:
       ${runOnNode "introducer" [ run-introducer "/tmp/node.pem" (toString introducerPort) introducerFURL ]}
-      } or do {
-        my ($code, $log) = $introducer->execute('cat /tmp/stdout /tmp/stderr');
-        $introducer->log($log);
-        die $@;
-      };
-
-      #
-      # Get a Tahoe-LAFS storage server up.
-      #
-      my ($code, $version) = $storage->execute('tahoe --version');
-      $storage->log($version);
-
-      # The systemd unit should reach the running state.
-      $storage->waitForUnit('tahoe.storage.service');
-
-      # Some while after that the Tahoe-LAFS node should listen on the web API
-      # port. The port number here has to agree with the port number set in
-      # the private-storage.nix module.
-      $storage->waitForOpenPort(3456);
-
-      # Once the web API is listening it should be possible to scrape some
-      # status from the node if it is really working.
-      $storage->succeed('tahoe -d /var/db/tahoe-lafs/storage status');
-
-      # It should have Eliot logging turned on as well.
-      $storage->succeed('[ -e /var/db/tahoe-lafs/storage/logs/eliot.json ]');
-
-      #
-      # Storage appears to be working so try to get a client to speak with it.
-      #
-      ${runOnNode "client" [ run-client "/tmp/client" introducerFURL issuerURL ]}
-      $client->waitForOpenPort(3456);
-
-      # Make sure the fake Stripe API server is ready for requests.
-      eval {
-        $api_stripe_com->waitForUnit("api.stripe.com");
-        1;
-      } or do {
-        my ($code, $log) = $api_stripe_com->execute('journalctl -u api.stripe.com');
-        $api_stripe_com->log($log);
-        die $@;
-      };
-
-      # Get some ZKAPs from the issuer.
-      eval {
-        ${runOnNode "client" [
-          get-passes
-          "http://127.0.0.1:3456"
-          "/tmp/client/private/api_auth_token"
-          issuerURL
-          voucher
-        ]}
-      } or do {
-        my ($code, $log) = $client->execute('cat /tmp/stdout /tmp/stderr');
-        $client->log($log);
-
-        # Dump the fake Stripe API server logs, too, since the error may arise
-        # from a PaymentServer/Stripe interaction.
-        my ($code, $log) = $api_stripe_com->execute('journalctl -u api.stripe.com');
-        $api_stripe_com->log($log);
-        die $@;
-      };
-
-      # The client should be prepped now.  Make it try to use some storage.
-      eval {
-        ${runOnNode "client" [ exercise-storage "/tmp/client" ]}
-      } or do {
-        my ($code, $log) = $client->execute('cat /tmp/stdout /tmp/stderr');
-        $client->log($log);
-        die $@;
-      };
-
-      # It should be possible to restart the storage service without the
-      # storage node fURL changing.
-      eval {
-        my $furlfile = '/var/db/tahoe-lafs/storage/private/storage-plugin.privatestorageio-zkapauthz-v1.furl';
-        my $before = $storage->execute('cat ' . $furlfile);
-        ${runOnNode "storage" [ "systemctl" "restart" "tahoe.storage" ]}
-        my $after = $storage->execute('cat ' . $furlfile);
-        if ($before != $after) {
-          die 'fURL changes after storage node restart';
-        }
-        1;
-      } or do {
-        my ($code, $log) = $storage->execute('cat /tmp/stdout /tmp/stderr');
-        $storage->log($log);
-        die $@;
-      };
-
-      # The client should actually still work, too.
-      eval {
-        ${runOnNode "client" [ exercise-storage "/tmp/client" ]}
-      } or do {
-        my ($code, $log) = $client->execute('cat /tmp/stdout /tmp/stderr');
-        $client->log($log);
-        die $@;
-      };
-      ''; }
+    except:
+      code, log = introducer.execute('cat /tmp/stdout /tmp/stderr')
+      introducer.log(log)
+      raise
+
+    #
+    # Get a Tahoe-LAFS storage server up.
+    #
+    code, version = storage.execute('tahoe --version')
+    storage.log(version)
+
+    # The systemd unit should reach the running state.
+    storage.wait_for_unit('tahoe.storage.service')
+
+    # Some while after that the Tahoe-LAFS node should listen on the web API
+    # port. The port number here has to agree with the port number set in
+    # the private-storage.nix module.
+    storage.wait_for_open_port(3456)
+
+    # Once the web API is listening it should be possible to scrape some
+    # status from the node if it is really working.
+    storage.succeed('tahoe -d /var/db/tahoe-lafs/storage status')
+
+    # It should have Eliot logging turned on as well.
+    storage.succeed('[ -e /var/db/tahoe-lafs/storage/logs/eliot.json ]')
+
+    #
+    # Storage appears to be working so try to get a client to speak with it.
+    #
+    ${runOnNode "client" [ run-client "/tmp/client" introducerFURL issuerURL ]}
+    client.wait_for_open_port(3456)
+
+    # Make sure the fake Stripe API server is ready for requests.
+    try:
+      api_stripe_com.wait_for_unit("api.stripe.com")
+    except:
+      code, log = api_stripe_com.execute('journalctl -u api.stripe.com')
+      api_stripe_com.log(log)
+      raise
+
+    # Get some ZKAPs from the issuer.
+    try:
+      ${runOnNode "client" [
+        get-passes
+        "http://127.0.0.1:3456"
+        "/tmp/client/private/api_auth_token"
+        issuerURL
+        voucher
+      ]}
+    except:
+      code, log = client.execute('cat /tmp/stdout /tmp/stderr');
+      client.log(log)
+
+      # Dump the fake Stripe API server logs, too, since the error may arise
+      # from a PaymentServer/Stripe interaction.
+      code, log = api_stripe_com.execute('journalctl -u api.stripe.com')
+      api_stripe_com.log(log)
+      raise
+
+    # The client should be prepped now.  Make it try to use some storage.
+    try:
+      ${runOnNode "client" [ exercise-storage "/tmp/client" ]}
+    except:
+      code, log = client.execute('cat /tmp/stdout /tmp/stderr')
+      client.log(log)
+      raise
+
+    # It should be possible to restart the storage service without the
+    # storage node fURL changing.
+    try:
+      furlfile = '/var/db/tahoe-lafs/storage/private/storage-plugin.privatestorageio-zkapauthz-v1.furl'
+      before = storage.execute('cat ' + furlfile)
+      ${runOnNode "storage" [ "systemctl" "restart" "tahoe.storage" ]}
+      after = storage.execute('cat ' + furlfile)
+      if (before != after):
+        raise Exception('fURL changes after storage node restart')
+    except:
+      code, log = storage.execute('cat /tmp/stdout /tmp/stderr')
+      storage.log(log)
+      raise
+
+    # The client should actually still work, too.
+    try:
+      ${runOnNode "client" [ exercise-storage "/tmp/client" ]}
+    except:
+      code, log = client.execute('cat /tmp/stdout /tmp/stderr')
+      client.log(log)
+      raise
+
+    # The issuer metrics should be accessible from the monitoring network.
+    issuer.execute('ifconfig lo:fauxvpn 172.23.23.2/24')
+    issuer.wait_until_succeeds("nc -z 172.23.23.2 80")
+    issuer.succeed('curl --silent --insecure --fail --output /dev/null http://172.23.23.2/metrics')
+    # The issuer metrics should NOT be accessible from any other network.
+    issuer.fail('curl --silent --insecure --fail --output /dev/null http://localhost/metrics')
+    client.fail('curl --silent --insecure --fail --output /dev/null http://issuer/metrics')
+    issuer.execute('ifconfig lo:fauxvpn down')
+  '';
+}
diff --git a/nixos/modules/tests/probeuser_ed25519 b/nixos/tests/probeuser_ed25519
similarity index 100%
rename from nixos/modules/tests/probeuser_ed25519
rename to nixos/tests/probeuser_ed25519
diff --git a/nixos/modules/tests/probeuser_ed25519.pub b/nixos/tests/probeuser_ed25519.pub
similarity index 100%
rename from nixos/modules/tests/probeuser_ed25519.pub
rename to nixos/tests/probeuser_ed25519.pub
diff --git a/nixos/modules/tests/run-client.py b/nixos/tests/run-client.py
similarity index 83%
rename from nixos/modules/tests/run-client.py
rename to nixos/tests/run-client.py
index bcd01e1b04a9b41cb7aa75f29fd3247d995d2527..e6cde321bdeb8a2b2493c984cce116a0287b16d1 100755
--- a/nixos/modules/tests/run-client.py
+++ b/nixos/tests/run-client.py
@@ -33,6 +33,10 @@ def main():
     config.add_section(u"storageclient.plugins.privatestorageio-zkapauthz-v1")
     config.set(u"storageclient.plugins.privatestorageio-zkapauthz-v1", u"redeemer", u"ristretto")
     config.set(u"storageclient.plugins.privatestorageio-zkapauthz-v1", u"ristretto-issuer-root-url", issuerURL)
+    # This has to agree with the PaymentServer configuration at the configured
+    # issuer location.  Presently PaymentServer has 50000 hard-coded as the
+    # correct value.
+    config.set(u"storageclient.plugins.privatestorageio-zkapauthz-v1", u"default-token-count", u"50000")
 
     with open("/tmp/client/tahoe.cfg", "wt") as cfg:
         config.write(cfg)
diff --git a/nixos/modules/tests/run-introducer.py b/nixos/tests/run-introducer.py
similarity index 100%
rename from nixos/modules/tests/run-introducer.py
rename to nixos/tests/run-introducer.py
diff --git a/nixos/modules/tests/stripe-api-double.py b/nixos/tests/stripe-api-double.py
similarity index 100%
rename from nixos/modules/tests/stripe-api-double.py
rename to nixos/tests/stripe-api-double.py
diff --git a/nixos/tests/tahoe.nix b/nixos/tests/tahoe.nix
new file mode 100644
index 0000000000000000000000000000000000000000..e39fd6d3fcb776e8e5215bb1264e08e2b7306c1f
--- /dev/null
+++ b/nixos/tests/tahoe.nix
@@ -0,0 +1,72 @@
+{ ... }:
+  {
+  nodes = {
+    storage = { config, pkgs, ourpkgs, ... }: {
+      imports = [
+        ../modules/packages.nix
+        ../modules/tahoe.nix
+      ];
+
+      services.tahoe.nodes.storage = {
+        package = ourpkgs.privatestorage;
+        sections = {
+          node = {
+            nickname = "storage";
+            "web.port" = "tcp:4000:interface=127.0.0.1";
+            "tub.port" = "tcp:4001";
+            "tub.location" = "tcp:127.0.0.1:4001";
+          };
+          storage = {
+            enabled = true;
+          };
+        };
+      };
+    };
+  };
+  testScript = ''
+  start_all()
+
+  # After the service starts, destroy the "created" marker to force it to
+  # re-create its internal state.
+  storage.wait_for_open_port(4001)
+  storage.succeed("systemctl stop tahoe.storage")
+  storage.succeed("rm /var/db/tahoe-lafs/storage.created")
+  storage.succeed("systemctl start tahoe.storage")
+
+  # After it starts up again, verify it has consistent internal state and a
+  # backup of the prior state.
+  storage.wait_for_open_port(4001)
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.created ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.1 ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.1/private/node.privkey ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.1/private/node.pem ]")
+  storage.succeed("[ ! -e /var/db/tahoe-lafs/storage.2 ]")
+
+  # Stop it again, once again destroy the "created" marker, and this time also
+  # jam some partial state in the way that will need cleanup.
+  storage.succeed("systemctl stop tahoe.storage")
+  storage.succeed("rm /var/db/tahoe-lafs/storage.created")
+  storage.succeed("mkdir -p /var/db/tahoe-lafs/storage.atomic/partial")
+  try:
+    storage.succeed("systemctl start tahoe.storage")
+  except:
+    x, y = storage.execute("journalctl -u tahoe.storage")
+    storage.log(y)
+    raise
+
+  # After it starts up again, verify it has consistent internal state and
+  # backups of the prior two states.  It also has no copy of the inconsistent
+  # state because it could never have been used.
+  storage.wait_for_open_port(4001)
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.created ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.1 ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.2 ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.2/private/node.privkey ]")
+  storage.succeed("[ -e /var/db/tahoe-lafs/storage.2/private/node.pem ]")
+  storage.succeed("[ ! -e /var/db/tahoe-lafs/storage.atomic ]")
+  storage.succeed("[ ! -e /var/db/tahoe-lafs/storage/partial ]")
+  storage.succeed("[ ! -e /var/db/tahoe-lafs/storage.3 ]")
+  '';
+}
diff --git a/nixpkgs-2105.json b/nixpkgs-2105.json
index 76950db1870cb62d68e655f5ca4be90f3fcbf6be..f79aa88bc0bb97b26c4668ac1d2c4efcdb25b9fb 100644
--- a/nixpkgs-2105.json
+++ b/nixpkgs-2105.json
@@ -1,4 +1,5 @@
-{ "name": "release2105"
-, "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.804.5de44c15758/nixexprs.tar.xz"
-, "sha256": "002zvc16hyrbs0icx1qj255c9dqjpdxx4bhhfjndlj3kwn40by0m"
+{
+  "name": "release2105",
+  "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.3065.b3083bc6933/nixexprs.tar.xz",
+  "sha256": "186vni8rij8bhd6n5n9h55jf2x78v9zdy2gn9v4cpjhajp4pvzm0"
 }
diff --git a/shell.nix b/shell.nix
index f3d2750edd68e4861e6d0700e0259c1ce86f817a..a5741377eec5ebd4b8862a0ea47e15edfdac2731 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,12 +1,28 @@
 let
   release2105 = import ./nixpkgs-2105.nix { };
 in
-{ pkgs ? release2105 }:
+{ pkgs ? release2105, lib ? pkgs.lib, python ? pkgs.python3 }:
+let
+  tools = pkgs.callPackage ./tools {};
+in
 pkgs.mkShell {
-  NIX_PATH = "nixpkgs=${pkgs.path}";
+  # When a path (such as `pkgs.path`) is interpolated into a string then nix
+  # first adds that path to the store, and then interpolates the store path
+  # into the string.  We use `builtins.toString` to convert the path to a
+  # string without copying it to the store before interpolating. Either the
+  # path is already in the store (e.g. when `pkgs` is `release2105`) so we
+  # avoid making a second copy with a longer name, or the user passed in local
+  # path (e.g. a checkout of nixpkgs) and we point at it directly, rather than
+  # a snapshot of it.
+  # See https://github.com/NixOS/nix/issues/200 and https://github.com/NixOS/nix/issues/1728
+  shellHook = ''
+    export NIX_PATH="nixpkgs=${builtins.toString pkgs.path}";
+  '';
+  # Run the shellHook from tools
+  inputsFrom = [tools];
   buildInputs = [
+    tools
     pkgs.morph
-    pkgs.vagrant
     pkgs.jp
   ];
 }
diff --git a/tools/default.nix b/tools/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..f9a0b1ff8d902f3072886939ad11e1e223ffbb7e
--- /dev/null
+++ b/tools/default.nix
@@ -0,0 +1,49 @@
+{ pkgs, lib, makeWrapper, ... }:
+let
+  python = pkgs.python3;
+  # This is a python envionment that has the dependencies
+  # for the development python scripts we use, and the
+  # helper library.
+  python-env = python.buildEnv.override {
+    extraLibs = [ python.pkgs.httpx ];
+    # Add `.pth` file pointing at the directory containg our helper library.
+    # This will get added to `sys.path` by `site.py`.
+    # See https://docs.python.org/3/library/site.html
+    postBuild = ''
+      echo ${lib.escapeShellArg ./pylib} > $out/${lib.escapeShellArg python.sitePackages}/tools.pth
+    '';
+  };
+  python-commands = [
+    ./update-nixpkgs
+  ];
+in
+  # This derivation creates a package that wraps our tools to setup an environment
+  # with there dependencies available.
+pkgs.runCommand "ps_tools" {
+  nativeBuildInputs = [ makeWrapper ];
+  shellHook = ''
+    # Only display the help if we are running an interactive shell.
+    if [[ $- == *i* ]]; then
+      cat <<MOTD
+    Tools (pass --help for details):
+    ${lib.concatStringsSep "\n" (map (path:
+        "- ${baseNameOf path}"
+    ) python-commands)}
+    MOTD
+    fi
+  '';
+  } ''
+    mkdir -p $out/bin
+    ${lib.concatStringsSep "\n" (map (path:
+      let
+        baseName = baseNameOf path;
+        # We use toString so that we wrap the in-tree scripts, rather than copying
+        # them to the nix-store. This means that we don't need to run nix-shell again
+        # to pick up changes.
+        sourcePath = toString path;
+      in
+      # makeWrapper <executable> <wrapperfile> <args>
+      # See https://nixos.org/manual/nixpkgs/stable/#fun-makeWrapper
+      "makeWrapper ${python-env}/bin/python $out/bin/${baseName} --add-flags ${sourcePath}"
+      ) python-commands)}
+  ''
diff --git a/tools/pylib/README.rst b/tools/pylib/README.rst
new file mode 100644
index 0000000000000000000000000000000000000000..083f6620d93ac30303b805cb4098e93804323aef
--- /dev/null
+++ b/tools/pylib/README.rst
@@ -0,0 +1,2 @@
+This directory contains a python package of helper functions used by the scripts in ``tools/``.
+To get this on the python path, run ``nix-shell`` in the root of the repository.
diff --git a/tools/pylib/ps_tools/__init__.py b/tools/pylib/ps_tools/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..278ef0f1a765a39f5e8a46adf99621b6b238dc5e
--- /dev/null
+++ b/tools/pylib/ps_tools/__init__.py
@@ -0,0 +1,35 @@
+"""
+Helpers for development and CI scripts.
+"""
+from __future__ import annotations
+
+import subprocess
+
+
+def get_url_hash(hash_type, name, url) -> dict[str, str]:
+    """
+    Get the nix hash of the given URL.
+
+    :returns: Dictionary of arguments suitable to pass to :nix:`pkgs.fetchzip`
+        or a function derived from it (such as :nix:`pkgs.fetchFromGitLab`)
+        to specify the hash.
+    """
+    output = subprocess.run(
+        [
+            "nix-prefetch-url",
+            "--type",
+            hash_type,
+            "--unpack",
+            "--name",
+            name,
+            url,
+        ],
+        capture_output=True,
+        check=True,
+        encoding="utf-8",
+    )
+
+    return {
+        "outputHashAlgo": hash_type,
+        "outputHash": output.stdout.strip(),
+    }
diff --git a/tools/update-nixpkgs b/tools/update-nixpkgs
new file mode 100755
index 0000000000000000000000000000000000000000..09c823b0a419b5937d4953337b94a26c4b502e32
--- /dev/null
+++ b/tools/update-nixpkgs
@@ -0,0 +1,66 @@
+#!/usr/bin/env python
+
+import argparse
+import json
+from pathlib import Path
+
+import httpx
+from ps_tools import get_url_hash
+
+# We pass this to builtins.fetchTarball which only supports sha256
+HASH_TYPE = "sha256"
+
+DEFAULT_CHANNEL = "nixos-21.05"
+CHANNEL_URL_TEMPLATE = "https://channels.nixos.org/{channel}/nixexprs.tar.xz"
+
+
+def get_nixos_channel_url(*, channel):
+    """
+    Get the URL for the current release of the given nixos channel.
+
+    `https://channels.nixos.org/<channel>` redirects to the path on
+    `https://releases.nixos.org` that corresponds to the current release
+    of that channel. This captures that redirect, so we can pin against
+    the release.
+    """
+    response = httpx.head(
+        CHANNEL_URL_TEMPLATE.format(channel=channel), allow_redirects=False
+    )
+    response.raise_for_status()
+    assert response.is_redirect
+    return str(response.next_request.url)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Update a pinned nixos repository.")
+    parser.add_argument(
+        "repo_file",
+        metavar="repo-file",
+        nargs="?",
+        default=Path(__file__).parent.with_name("nixpkgs-2105.json"),
+        type=Path,
+        help="JSON file with pinned configuration.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+    )
+    parser.set_defaults(channel=DEFAULT_CHANNEL)
+    args = parser.parse_args()
+
+    repo_file = args.repo_file
+    config = json.loads(repo_file.read_text())
+
+    config["url"] = get_nixos_channel_url(channel=args.channel)
+    hash_data = get_url_hash(HASH_TYPE, name=config["name"], url=config["url"])
+    config["sha256"] = hash_data["outputHash"]
+
+    output = json.dumps(config, indent=2)
+    if args.dry_run:
+        print(output)
+    else:
+        repo_file.write_text(output)
+
+
+if __name__ == "__main__":
+    main()