diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b91f7d5f175a32a49a5ed0788a87146d9b86c3f3..76dce30e2e5137a8a9199f2d739f96db92988406 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -8,7 +8,7 @@ default:
 docs:
   stage: "build"
   script:
-    - "nix-shell --run 'nix-build docs.nix'"
+    - "nix-build docs.nix"
     - "cp --recursive --no-preserve=mode result/docs/. docs/build/"
   artifacts:
     paths:
@@ -20,10 +20,42 @@ unit-tests:
   script:
     - "nix-shell --run 'nix-build nixos/unit-tests.nix' && cat result"
 
+.morph-build: &MORPH_BUILD
+  stage: "test"
+  timeout: "3 hours"
+
+  script:
+    - |
+      # GRID is set in one of the "instantiations" of this job template.
+      nix-shell --run "morph build --show-trace morph/grid/${GRID}/grid.nix"
+
+
+morph-build-localdev:
+  <<: *MORPH_BUILD
+  variables:
+    GRID: "local"
+
+  before_script:
+    - |
+      # 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
+
+morph-build-testing:
+  <<: *MORPH_BUILD
+  variables:
+    GRID: "testing"
+
+
+morph-build-production:
+  <<: *MORPH_BUILD
+  variables:
+    GRID: "production"
+
+
 vulnerability-scan:
   stage: "test"
   script:
-    - "sed -i 's/undefined/\"unundefined\"/' morph/grid/local/secrets/users.nix"
     - "ci-tools/vulnerability-scan security-report.json"
     - "ci-tools/count-vulnerabilities <security-report.json"
   artifacts:
@@ -38,24 +70,26 @@ system-tests:
   script:
     - "nix-shell --run 'nix-build nixos/system-tests.nix'"
 
-deploy-to-staging:
+# A template for a job that can update one of the grids.
+.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.
+update-staging:
+  <<: *UPDATE_GRID
   only:
     - "staging"
   environment:
     name: "staging"
     url: "https://privatestorage-staging.com/"
-  script:
-    - echo "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
-    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the $CI_ENVIRONMENT_NAME environment."
 
+# Update the production deployment - only on a merge to the production branch.
 deploy-to-production:
-  stage: "deploy"
+  <<: *UPDATE_GRID
   only:
     - "production"
   environment:
     name: "production"
     url: "https://privatestorage.io/"
-  script:
-    - echo "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE "
-    - echo "and would like to deploy the $CI_COMMIT_BRANCH branch to the $CI_ENVIRONMENT_NAME environment."
diff --git a/ci-tools/count-vulnerabilities b/ci-tools/count-vulnerabilities
index 9db1c5e7e3aa756dc5b151fbcc30bc4572dd1eba..b1d2b804d81c9c53c0f9a9b41e4e554978c0032d 100755
--- a/ci-tools/count-vulnerabilities
+++ b/ci-tools/count-vulnerabilities
@@ -1,4 +1,5 @@
-#!/usr/bin/env python3
+#! /usr/bin/env nix-shell
+#! nix-shell -i python3 -p python3
 
 from sys import stdin
 from json import load
diff --git a/ci-tools/known_hosts.production b/ci-tools/known_hosts.production
new file mode 100644
index 0000000000000000000000000000000000000000..88e5696c47a6a5583e0eb20e3c77f82bc46bda8e
--- /dev/null
+++ b/ci-tools/known_hosts.production
@@ -0,0 +1,7 @@
+monitoring.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGvKv2y+IAL4+oDnX7Cm5G9QuADBHUj9OxzLX0okf6hF
+payments.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJBlyFTJyN+VDlzGWANKqBlXeexlX/xTpp6gb5sUlA9U
+storage001.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILQojjGVvmjZfDcrlec8ZmpbzMEeHd4+t4DJq1R/NUXw
+storage002.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKDWqK7FBzT4L1eoIU/iaEZNZxq3Jr613PmK2nbAXFs2
+storage003.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHobJpQVv9GaTv8Xh9CGlL7BL5yKLxCiD3ZDdVTyt0Ep
+storage004.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJy96VEPp617ewxdkt+8ZgWcYkLxlVG/C7bZAq0ULH+z
+storage005.private.storage ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKOgJBoER0lX2Rx8UIfv/3MVJXNFn9RldYmpU+EqAc9H
diff --git a/ci-tools/known_hosts.staging b/ci-tools/known_hosts.staging
new file mode 100644
index 0000000000000000000000000000000000000000..2a015656e4c21498f2fe152723888046d9341c1a
--- /dev/null
+++ b/ci-tools/known_hosts.staging
@@ -0,0 +1,3 @@
+monitoring.privatestorage-staging.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINI9kvEBaOMvpWqcFH+6nFvRriBECKB4RFShdPiIMkk9
+payments.privatestorage-staging.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK0eO/01VFwdoZzpclrmu656eaMkE19BaxtDdkkFHMa8
+storage001.privatestorage-staging.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFP8L6OHCxq9XFd8ME8ZrCbmO5dGZDPH8I5dm0AwSGiN
diff --git a/ci-tools/update-grid-servers b/ci-tools/update-grid-servers
new file mode 100755
index 0000000000000000000000000000000000000000..c5206fd481096cc8eddb2a2c313cff7d92fe2db6
--- /dev/null
+++ b/ci-tools/update-grid-servers
@@ -0,0 +1,132 @@
+#!/usr/bin/env nix-shell
+#!nix-shell -i bash -p jp nix openssh
+
+#
+# Tell all servers belonging to a certain grid that they should update
+# themselves to the latest configuration associated with that grid.
+#
+
+set -euxo pipefail
+
+# Find the location of this script so we can refer to data files with a known
+# relative location.
+HERE=$(dirname $0)
+
+# Get the path to the ssh key which authorizes us to deliver this
+# notification.  This path contains a base64-encoded key because of
+# limitations placed on the values of GitLab job environment variables.  We'll
+# decode it later.
+ENCODED_DEPLOY_KEY_PATH=$1
+shift
+
+# Get the name of the grid to which we're going to deliver notification.  This
+# corresponds to the name of one of the directories in the top-level `morph`
+# directory.
+GRIDNAME=$1
+shift
+
+# Tell one server to update itself.
+update_one_node() {
+    grid_name=$1
+    shift
+
+    deploy_key_path=$1
+    shift
+
+    node=$1
+    shift
+
+    # Avoid both the "host key unknown" prompt and the possibility for a
+    # man-in-the-middle attack (on every single deploy!) by referring to a
+    # pre-initialized known hosts file for this grid.
+    #
+    # Then use the specified deploy key to authenticate as the deployment user
+    # and trigger the update on the host.  There's no command here because the
+    # deployment key is restricted *only* the deloyment update command and the
+    # ssh server will supply that command itself.
+    ssh -o "UserKnownHostsFile=${HERE}/known_hosts.${grid_name}" -i "${deploy_key_path}" "deployment@${node}"
+}
+
+# Tell all servers belonging to one grid to update themselves.
+update_grid_nodes() {
+    deploy_key_path=$1
+    shift
+
+    gridname=$1
+    shift
+
+    case "$gridname" in
+	"production")
+	    grid_dir=./morph/grid/production
+	    domain=private.storage
+	    ;;
+
+	"staging")
+	    grid_dir=./morph/grid/testing
+	    domain=privatestorage-staging.com
+	    ;;
+
+	*)
+	    echo "Unknown grid: ${gridname}"
+	    exit 1
+    esac
+
+    # Find the names of all hosts that belong to this grid.  This list includes
+    # one extra string, "network", which is morph configuration stuff and we need
+    # to filter out later.
+    nodes=$(nix eval --json "(builtins.concatStringsSep \" \" (builtins.attrNames (import $grid_dir/grid.nix)))" | jp --unquoted @)
+
+    # Tell every server in the network to update itself.
+    for node in ${nodes}; do
+	if [ "${node}" = "network" ]; then
+	    # This isn't a server, it's part of the morph configuration.
+	    continue
+	fi
+	update_one_node "${gridname}" "${deploy_key_path}" "${node}.${domain}"
+    done
+}
+
+decode_deploy_key() {
+    encoded_key_path=$1
+    shift
+
+    # Make sure the deploy key file is not readable by anyone else.  Not
+    # that there should be anyone else looking - but OpenSSH won't even read
+    # it if it looks like it is too open.
+    umask 077
+
+    # Make up a safe-ish place on the filesystem to write the key.
+    decoded_key_path="$(mktemp -d)/deploy_key"
+
+    # Decode the contents of the encoded key path into a decoded key path.
+    base64 --decode "${encoded_key_path}" > "${decoded_key_path}"
+
+    # If OpenSSH doesn't find a newline after the last line of the key
+    # material then it fails to parse it.  So, make sure there is one.  If
+    # there was already one, it's fine to have an extra.
+    echo >> "${decoded_key_path}"
+
+    # Remove the key from the filesystem to reduce the chance of unintentional
+    # disclosure.  Overall our handling of this key is still not *particulary*
+    # safe or secure but that's why the key is only authorized to perform a
+    # single very specific operation.
+    rm -v "${encoded_key_path}" >/dev/stderr
+
+    echo -n "${decoded_key_path}"
+}
+
+# Announce our intentions.
+show_banner() {
+    echo "Hello $GITLAB_USER_LOGIN from $CI_JOB_NAME. I was triggered by $CI_PIPELINE_SOURCE"
+    echo "and I am deploying the $CI_COMMIT_BRANCH branch to the $GRIDNAME environment."
+}
+
+show_banner
+
+DEPLOY_KEY_PATH="$(decode_deploy_key "${ENCODED_DEPLOY_KEY_PATH}")"
+
+# Update the deployment
+update_grid_nodes "${DEPLOY_KEY_PATH}" "${GRIDNAME}"
+
+# Remove the decoded key from the filesystem as well.
+rm -v "${DEPLOY_KEY_PATH}"
diff --git a/ci-tools/vulnerability-scan b/ci-tools/vulnerability-scan
index 3162e49511697ed0ea13e0121a67336405ce5225..48bf51e071a398f37565717a22b2066d3f905fbe 100755
--- a/ci-tools/vulnerability-scan
+++ b/ci-tools/vulnerability-scan
@@ -21,7 +21,7 @@ OUTPUT=$1
 [ -e scan-target ] && rm -v scan-target
 nix-shell --run '
 set -x
-if morph_result=$(morph build morph/grid/local/grid.nix 2>&1); then
+if morph_result=$(morph build morph/grid/testing/grid.nix 2>&1); then
   object=$(echo "$morph_result" | tail -n 1)
   ln -s "$object" scan-target
 else
diff --git a/docs.nix b/docs.nix
index 813a6cb432942fccd96b96ee07313ff84cf885c6..4c8b230a7eddb462bf47a4c3ee591e64fb3ce1ff 100644
--- a/docs.nix
+++ b/docs.nix
@@ -1,14 +1,2 @@
-{ pkgs ? import <nixpkgs> { } }:
-let
-  # NixOS 19.03 packaged graphviz has trouble rendering our architecture
-  # overview.  Latest from upstream does alright, though.  Use that.
-  make-graphviz = (import (pkgs.path + /pkgs/tools/graphics/graphviz/base.nix) {
-    rev = "b29d8e369011b832f72e0d250a05a0a15dcb5daa";
-    sha256 = "1w61filywn9cif2nryf6vd34mxxbvv25q34fd34am1rx70bk08ps";
-    version = "b29d8e369011b832f72e0d250a05a0a15dcb5daa";
-  });
-  graphviz = (pkgs.callPackage make-graphviz { }).overrideAttrs (old: {
-    patches = [];
-  });
-in
-  pkgs.callPackage ./privatestorageio.nix { inherit graphviz; }
+{ pkgs ? import ./nixpkgs-2105.nix { } }:
+pkgs.callPackage ./privatestorageio.nix { }
diff --git a/docs/source/ops/generating-keys.rst b/docs/source/ops/generating-keys.rst
index 47a1f4e91a876ac1919252c099654886f0bd128a..c2f7028f2bc263c9e5bac40f78ca0adfb4861415 100644
--- a/docs/source/ops/generating-keys.rst
+++ b/docs/source/ops/generating-keys.rst
@@ -1,9 +1,10 @@
 Generating keys
 ===============
 
-There's an example ``secrets`` repo in ``morph/grid/local/secrets``.
+There are example ``public-keys`` and ``private-keys`` repos in ``morph/grid/local/``.
 ``<grid>/config.json`` has the paths for the key files for the respective grid.
-Create a symlink named ``secrets`` to your secret key repository for the deployment you are working on.
+Create a symlink ``private-keys`` to your secret key repositories for the deployment you are working on.
+Create a directory named ``public-keys`` containing the corresponding public keys for the deployment.
 
 
 Stripe
@@ -55,22 +56,6 @@ Move the three .pem files into the payment's server ``/var/lib/letsencrypt/live/
 Monitoring VPN
 ``````````````
 
-Create Wireguard VPN key pairs in ``secrets/monitoringvpn/`` or where you have them.
-
-``tools/create-vpn-keys.sh`` holds a script to rotate all VPN keys at once::
+Create all of the Wireguard VPN keys for a grid::
 
   ./tools/create-vpn-keys.sh morph/grid/testing/grid.nix
-
-Or do it manually::
-
-  cd secrets/monitoringvpn
-  for i in 1 11 12 13 ; do
-    wg genkey | tee 172.23.23.${i}.key | wg pubkey > 172.23.23.${i}.pub
-  done
-
-  ln -s 172.23.23.1.key server.key
-  ln -s 172.23.23.1.pub server.pub
-
-And a shared VPN key for "post-quantum resistance"::
-
-  wg genpsk > preshared.key
diff --git a/morph/README.rst b/morph/README.rst
index 12472518ad8e061764d6812694c306e87553c843..96d03eb3cf522af6f1b0065105a2d57ab5c78f6a 100644
--- a/morph/README.rst
+++ b/morph/README.rst
@@ -42,8 +42,8 @@ grid
 
 Specific grid definitions live in subdirectories beneath this directory.
 
-secrets
-~~~~~~~
+private-keys
+~~~~~~~~~~~~
 
 This must be created and populated before the grid can be built or deployed.
 
@@ -55,10 +55,44 @@ This path is **ignored** by git.
 The intended workflow is that the secrets will be maintained on secure storage and a symlink to the correct location created here.
 This keeps the secrets themselves out of the git working tree as an extra protection against unintentionally committing them.
 
-An exception is the ``secrets`` directory in the ``local`` morph grid:
+An exception is the ``private-keys`` directory in the ``local`` morph grid:
 That directory is fully populated, provided as an example, and mostly: not very secret.
 Do not deploy these keys to machines reachable via the internet.
 
+Strictly speaking,
+this path is configurable in the grid's ``config.json`` but all three grids currently use this name.
+
+public-keys
+~~~~~~~~~~~
+
+This must be created and populated before the grid can be built or deployed.
+
+This directory contains any public key material necessary for operation of the grid.
+This includes the public keys corresponding to any private keys held in ``private-keys``.
+
+As for ``private-keys``,
+this path can be configured in the grid's ``config.json``.
+
+Star-crossed Keys
+^^^^^^^^^^^^^^^^^
+
+Where the system uses keypairs,
+the public and private parts of those keypairs are stored in different locations
+(``public-keys`` and ``private-keys`` mentioned above).
+This somewhat complicates key management because any key rotation involves changing key material in two location instead of just one.
+
+This complication is balanced against a specific operational goal:
+that our build systems operate without copies of our private keys.
+Our system configurations do currently have build-time dependencies on public keys.
+
+Splitting public keys and private keys across two different storage locations provides a simple mechanism for providing build systems with the public keys but withholding the private keys.
+
+In the future we may:
+* be sufficiently confident in the security of our build systems to let them have our private keys; or
+* remove the dependency upon public keys from the build process.
+
+Either of these directions would let us re-unify public/private-key storage and remove this complication.
+
 config.json
 ~~~~~~~~~~~
 
diff --git a/morph/grid/local/README.rst b/morph/grid/local/README.rst
index 345547244635734278aa76cb5cd59946f2afd37f..d30d8766a4ef5a8db228ef38374330734e69cba7 100644
--- a/morph/grid/local/README.rst
+++ b/morph/grid/local/README.rst
@@ -33,18 +33,11 @@ Use the local development environment
 
     install -d ~/.ssh ; vagrant ssh-config >> ~/.ssh/config
 
-5. Edit the generated configuration: Add the ``publicIP`` addresses from ``grid.nix`` to ssh config **Host** match blocks (**not** HostName) so the ``Host`` lines all read like::
-
-    Host payments 192.168.67.21
-      HostName 127.0.0.1
-      User vagrant
-      [...]
-
   Latest Morph honors the ``SSH_CONFIG_FILE`` environment variable (`since 3f90aa88 (March 2020, v 1.5.0) <https://github.com/DBCDK/morph/commit/3f90aa885fac1c29fce9242452fa7c0c505744ef#diff-d155ad793bd62e6ea4c44ba985049ecb13a4f4f32f799791b2bce695a16c0101>`_), so in the future this should get a bit more convenient.
 
 6. Add your SSH key to ``users.nix`` so you'll be able to log in after deploying the new configuration::
 
-    $EDITOR secrets/users.nix
+    $EDITOR public-keys/users.nix
 
 7. Then, build and deploy our software to the Vagrant VMs::
 
@@ -56,4 +49,3 @@ Use the local development environment
     morph upload-secrets grid.nix
 
   You should now be able to log in with the users and keys you set in your ``users.nix`` file.
-
diff --git a/morph/grid/local/Vagrantfile b/morph/grid/local/Vagrantfile
index 7ad95ca872a72e5da6c11b3269e2a824cf8a55f9..a871cbbe72e410de88a19596ff528391e32ff811 100644
--- a/morph/grid/local/Vagrantfile
+++ b/morph/grid/local/Vagrantfile
@@ -8,19 +8,29 @@ Vagrant.configure("2") do |config|
   # For a complete reference, please see the online documentation at
   # https://docs.vagrantup.com.
 
-  config.vm.define "payments" do |config|
+  config.vm.define "payments.localdev" do |config|
     config.vm.hostname = "payments"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
     config.vm.box_check_update = false
+
+    # To use the self-updating deployment system you need more memory.  Giving
+    # all of the VMs enough memory for this is rather taxing, though, and the
+    # self-updating deployment system is not particularly useful for local
+    # dev.  But should you want to:
+    #
+    # config.vm.provider "virtualbox" do |v|
+    #   v.memory = 4096
+    # end
+
     config.vm.network "private_network", ip: "192.168.67.21"
     # Add self signed SSL key for zkap-issuer:
-    config.vm.provision "file", source: "secrets/payments-localdev-ssl", destination: "/tmp/payments-localdev-ssl"
+    config.vm.provision "file", source: "private-keys/payments-localdev-ssl", destination: "/tmp/payments-localdev-ssl"
     config.vm.provision "shell", inline: "sudo mkdir -p /var/lib/letsencrypt/live/payments.localdev/"
     config.vm.provision "shell", inline: "sudo mv /tmp/payments-localdev-ssl/* /var/lib/letsencrypt/live/payments.localdev/"
   end
 
-  config.vm.define "storage1" do |config|
+  config.vm.define "storage1.localdev" do |config|
     config.vm.hostname = "storage1"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
@@ -28,7 +38,7 @@ Vagrant.configure("2") do |config|
     config.vm.network "private_network", ip: "192.168.67.22"
   end
 
-  config.vm.define "storage2" do |config|
+  config.vm.define "storage2.localdev" do |config|
     config.vm.hostname = "storage2"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
@@ -36,7 +46,7 @@ Vagrant.configure("2") do |config|
     config.vm.network "private_network", ip: "192.168.67.23"
   end
 
-  config.vm.define "monitoring" do |config|
+  config.vm.define "monitoring.localdev" do |config|
     config.vm.hostname = "monitoring"
     config.vm.box = "esselius/nixos"
     config.vm.box_version = "20.09"
@@ -54,4 +64,3 @@ Vagrant.configure("2") do |config|
   end
 
 end
-
diff --git a/morph/grid/local/config.json b/morph/grid/local/config.json
index 38f00367bf2fa36ad7663c89f7849146783b8515..8b23b6f1152be4fa94e8935342bf11f7706d036c 100644
--- a/morph/grid/local/config.json
+++ b/morph/grid/local/config.json
@@ -1,7 +1,7 @@
-{ "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "./secrets/stripe.secret"
-, "monitoringvpnKeyDir": "./secrets/monitoringvpn"
+{ "domain": "localdev"
+, "publicStoragePort": 8898
+, "publicKeyPath": "./public-keys"
+, "privateKeyPath": "./private-keys"
 , "monitoringvpnEndpoint": "192.168.67.24:51820"
 , "passValue": 1000000
 , "issuerDomains": ["payments.localdev"]
@@ -9,4 +9,5 @@
 , "allowedChargeOrigins": [
     "http://localhost:5000"
   ]
+, "monitoringGoogleOAuthClientID": ""
 }
diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix
index fdc0cde55be4f1b644c212ce20f6c3e44af8e3df..3def2d77556e8b82b5fd0dbd2513f3d08b7ea2c7 100644
--- a/morph/grid/local/grid.nix
+++ b/morph/grid/local/grid.nix
@@ -1,64 +1,89 @@
-# Load the helper function and call it with arguments tailored for the local
-# grid.  It will make the morph configuration for us.  We share this function
-# with the production grid and have one fewer possible point of divergence.
-import ../../lib/make-grid.nix {
-  name = "LocalDev";
-  config = ./config.json;
-  nodes = cfg:
-  let
-    sshUsers = import ./secrets/users.nix;
+let
+  pkgs = import <nixpkgs> { };
 
-    # Get absolute vpn key directory path, as a string:
-    monitoringvpnKeyDir = toString ./. + "/${cfg.monitoringvpnKeyDir}";
+  gridlib = import ../../lib;
+  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
+  config = rawConfig // {
+    sshUsers = import ./public-keys/users.nix;
 
-    # TBD: derive these automatically:
-    hostsMap = {
-      "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
-      "172.23.23.11" = [ "payments" "payments.monitoringvpn" ];
-      "172.23.23.12" = [ "storage1" "storage1.monitoringvpn" ];
-      "172.23.23.13" = [ "storage2" "storage2.monitoringvpn" ];
+    # 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 = {
+    services.private-storage.deployment = {
+      authorizedKey = builtins.readFile "${config.publicKeyPath}/deploy_key.pub";
+      gridName = "local";
     };
-    vpnClientIPs = [ "172.23.23.11" "172.23.23.12" "172.23.23.13" ];
-    nodeExporterTargets = [ "monitoring" "payments" "storage1" "storage2" ];
+  };
+
+  payments = {
+    imports = [
+      gridlib.issuer
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.21"; }))
+      (gridlib.customize-issuer (config // {
+          monitoringvpnIPv4 = "172.23.23.11";
+      }))
+      deployment
+    ];
+  };
 
-  in {
-    "payments" = import ../../lib/make-issuer.nix (cfg // rec {
-      publicIPv4 = "192.168.67.21";
-      monitoringvpnIPv4 = "172.23.23.11";
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.03";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+  storage1 = {
+    imports = [
+      gridlib.storage
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.22"; }))
+      (gridlib.customize-storage (config // {
+        monitoringvpnIPv4 = "172.23.23.12";
+        stateVersion = "19.09";
+      }))
+      deployment
+    ];
+  };
 
-    "storage1" = import ../../lib/make-testing.nix (cfg // rec {
-      publicIPv4 = "192.168.67.22";
-      monitoringvpnIPv4 = "172.23.23.12";
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.09";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+  storage2 = {
+    imports = [
+      gridlib.storage
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.23"; }))
+      (gridlib.customize-storage (config // {
+        monitoringvpnIPv4 = "172.23.23.13";
+        stateVersion = "19.09";
+      }))
+      deployment
+    ];
+  };
 
-    "storage2" = import ../../lib/make-testing.nix (cfg // rec {
-      publicIPv4 = "192.168.67.23";
-      monitoringvpnIPv4 = "172.23.23.13";
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.09";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+  monitoring = {
+    imports = [
+      gridlib.monitoring
+      (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.24"; }))
+      (gridlib.customize-monitoring {
+        inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
+        inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail;
+        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
+        monitoringvpnIPv4 = "172.23.23.1";
+        stateVersion = "19.09";
+      })
+      deployment
+    ];
+  };
+
+  # TBD: derive these automatically:
+  hostsMap = {
+    "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
+    "172.23.23.11" = [ "payments" "payments.monitoringvpn" ];
+    "172.23.23.12" = [ "storage1" "storage1.monitoringvpn" ];
+    "172.23.23.13" = [ "storage2" "storage2.monitoringvpn" ];
+  };
+  vpnClientIPs = [ "172.23.23.11" "172.23.23.12" "172.23.23.13" ];
+  nodeExporterTargets = [ "monitoring" "payments" "storage1" "storage2" ];
+  paymentExporterTargets = [ "payments" ];
 
-    "monitoring" = import ../../lib/make-monitoring.nix (cfg // rec {
-      publicIPv4 = "192.168.67.24";
-      monitoringvpnIPv4 = "172.23.23.1";
-      inherit vpnClientIPs;
-      inherit hostsMap;
-      inherit nodeExporterTargets;
-      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
-      stateVersion = "19.09";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-    });
+in {
+  network = {
+    description = "PrivateStorage.io LocalDev Grid";
   };
+  inherit payments monitoring storage1 storage2;
 }
diff --git a/morph/grid/local/private-keys/README.rst b/morph/grid/local/private-keys/README.rst
new file mode 100644
index 0000000000000000000000000000000000000000..684bf942a8010129f49cfcf79f5df1b60965ae45
--- /dev/null
+++ b/morph/grid/local/private-keys/README.rst
@@ -0,0 +1,44 @@
+Deployment Secrets
+==================
+
+Deploying PrivateStorageio requires certain secrets.
+For the localdev grid these secrets are kept in this (public) directory.
+This is intended to help make it as easy as possible to launch a local deployment.
+It also serves as an example of what secrets are required for any other deployment.
+
+You can find more information about some of these secrets in ``ops/generating-keys.rst``.
+
+deploy_key
+----------
+
+This is an SSH private key which will be authorized to trigger a deployment update on the deployment hosts themselves.
+The corresponding SSH public key is kept in the ``public-keys`` location.
+
+grafana-admin.password
+----------------------
+
+This is the initial admin password for the Grafana web admin on the monitoring host.
+
+stripe.secret
+-------------
+
+This is the Stripe secret key which the payment server uses to finalize payment processing using Stripe.
+The corresponding Stripe public key is kept in the ``public-keys`` location.
+
+ristretto.signing-key
+---------------------
+
+This is the Ristretto-group private key used by the ZKAP issuer.
+
+monitoringvpn
+-------------
+
+This directory holds Wireguard private keys for each of the hosts so they can participate in the deployment VPN.
+The corresponding public keys are kept in the ``public-keys`` location.
+
+payments-localdev-ssl
+---------------------
+
+This secret is *only* present for the localdev grid.
+This contains a TLS certificate and private key for the payment server.
+Other deployments will automatically generate a key and obtain a certificate from Let's Encrypt.
diff --git a/morph/grid/local/private-keys/deploy_key b/morph/grid/local/private-keys/deploy_key
new file mode 100644
index 0000000000000000000000000000000000000000..5c8807906eca2a818d2807e2512d03642d038edb
--- /dev/null
+++ b/morph/grid/local/private-keys/deploy_key
@@ -0,0 +1,7 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACADU1IBThyH0blWBG8afIA/h/bmVdUQkFAuAIQgAWE+ewAAAJjsA+8c7APv
+HAAAAAtzc2gtZWQyNTUxOQAAACADU1IBThyH0blWBG8afIA/h/bmVdUQkFAuAIQgAWE+ew
+AAAED6aLiQi/K2qG8sLsvV8Xar9PjJeFxKfb+GUvmseu8TqQNTUgFOHIfRuVYEbxp8gD+H
+9uZV1RCQUC4AhCABYT57AAAADmV4YXJrdW5AYmFyeW9uAQIDBAUGBw==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/morph/grid/local/private-keys/grafana-admin.password b/morph/grid/local/private-keys/grafana-admin.password
new file mode 100644
index 0000000000000000000000000000000000000000..a31f068f733c21aa08fe347fbd6780397c65541c
--- /dev/null
+++ b/morph/grid/local/private-keys/grafana-admin.password
@@ -0,0 +1 @@
+Naht3Pha
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.11.key b/morph/grid/local/private-keys/monitoringvpn/172.23.23.11.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.11.key
rename to morph/grid/local/private-keys/monitoringvpn/172.23.23.11.key
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.12.key b/morph/grid/local/private-keys/monitoringvpn/172.23.23.12.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.12.key
rename to morph/grid/local/private-keys/monitoringvpn/172.23.23.12.key
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.13.key b/morph/grid/local/private-keys/monitoringvpn/172.23.23.13.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.13.key
rename to morph/grid/local/private-keys/monitoringvpn/172.23.23.13.key
diff --git a/morph/grid/local/secrets/monitoringvpn/preshared.key b/morph/grid/local/private-keys/monitoringvpn/preshared.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/preshared.key
rename to morph/grid/local/private-keys/monitoringvpn/preshared.key
diff --git a/morph/grid/local/secrets/monitoringvpn/server.key b/morph/grid/local/private-keys/monitoringvpn/server.key
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/server.key
rename to morph/grid/local/private-keys/monitoringvpn/server.key
diff --git a/morph/grid/local/secrets/payments-localdev-ssl/cert.pem b/morph/grid/local/private-keys/payments-localdev-ssl/cert.pem
similarity index 100%
rename from morph/grid/local/secrets/payments-localdev-ssl/cert.pem
rename to morph/grid/local/private-keys/payments-localdev-ssl/cert.pem
diff --git a/morph/grid/local/secrets/payments-localdev-ssl/chain.pem b/morph/grid/local/private-keys/payments-localdev-ssl/chain.pem
similarity index 100%
rename from morph/grid/local/secrets/payments-localdev-ssl/chain.pem
rename to morph/grid/local/private-keys/payments-localdev-ssl/chain.pem
diff --git a/morph/grid/local/secrets/payments-localdev-ssl/privkey.pem b/morph/grid/local/private-keys/payments-localdev-ssl/privkey.pem
similarity index 100%
rename from morph/grid/local/secrets/payments-localdev-ssl/privkey.pem
rename to morph/grid/local/private-keys/payments-localdev-ssl/privkey.pem
diff --git a/morph/grid/local/secrets/ristretto.signing-key b/morph/grid/local/private-keys/ristretto.signing-key
similarity index 100%
rename from morph/grid/local/secrets/ristretto.signing-key
rename to morph/grid/local/private-keys/ristretto.signing-key
diff --git a/morph/grid/local/secrets/stripe.secret b/morph/grid/local/private-keys/stripe.secret
similarity index 100%
rename from morph/grid/local/secrets/stripe.secret
rename to morph/grid/local/private-keys/stripe.secret
diff --git a/morph/grid/local/public-keys/deploy_key.pub b/morph/grid/local/public-keys/deploy_key.pub
new file mode 100644
index 0000000000000000000000000000000000000000..15d38cefef61e5f2008efa949ff36245677f8426
--- /dev/null
+++ b/morph/grid/local/public-keys/deploy_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIANTUgFOHIfRuVYEbxp8gD+H9uZV1RCQUC4AhCABYT57 exarkun@baryon
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.11.pub b/morph/grid/local/public-keys/monitoringvpn/172.23.23.11.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.11.pub
rename to morph/grid/local/public-keys/monitoringvpn/172.23.23.11.pub
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.12.pub b/morph/grid/local/public-keys/monitoringvpn/172.23.23.12.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.12.pub
rename to morph/grid/local/public-keys/monitoringvpn/172.23.23.12.pub
diff --git a/morph/grid/local/secrets/monitoringvpn/172.23.23.13.pub b/morph/grid/local/public-keys/monitoringvpn/172.23.23.13.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/172.23.23.13.pub
rename to morph/grid/local/public-keys/monitoringvpn/172.23.23.13.pub
diff --git a/morph/grid/local/secrets/monitoringvpn/server.pub b/morph/grid/local/public-keys/monitoringvpn/server.pub
similarity index 100%
rename from morph/grid/local/secrets/monitoringvpn/server.pub
rename to morph/grid/local/public-keys/monitoringvpn/server.pub
diff --git a/morph/grid/local/secrets/users.nix b/morph/grid/local/public-keys/users.nix
similarity index 83%
rename from morph/grid/local/secrets/users.nix
rename to morph/grid/local/public-keys/users.nix
index 93a8b660c78fa12b1e20c6d560f78efb1b5684c7..412077c0d5d6d98024036e369dfa552604f2dc57 100644
--- a/morph/grid/local/secrets/users.nix
+++ b/morph/grid/local/public-keys/users.nix
@@ -1,4 +1,4 @@
-# Add your public key. Example: 
+# Add your public key. Example:
 # let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHx7wJQNqKn8jOC4AxySRL2UxidNp7uIK9ad3pMb1ifF flo@fs-la";
 let key = undefined;
 in { "root" = key; "vagrant" = key; }
diff --git a/morph/grid/production/.gitignore b/morph/grid/production/.gitignore
index db2fc0de62d01d6d7eec83f8f3e8c3b13b20392a..e3b6111c86090b06c38b9e5afd1fcd16838ddf47 100644
--- a/morph/grid/production/.gitignore
+++ b/morph/grid/production/.gitignore
@@ -1 +1 @@
-secrets
+private-keys
diff --git a/morph/grid/production/config.json b/morph/grid/production/config.json
index ef7dc53649febcd7beb7901bb3608204df197059..fcae1563a8fc0d3a8a11324fc6667105ae3179c8 100644
--- a/morph/grid/production/config.json
+++ b/morph/grid/production/config.json
@@ -1,7 +1,7 @@
-{ "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "./secrets/stripe.secret"
-, "monitoringvpnKeyDir": "./secrets/monitoringvpn"
+{ "domain": "private.storage"
+, "publicStoragePort": 8898
+, "privateKeyPath": "./private-keys"
+, "publicKeyPath": "./public-keys"
 , "monitoringvpnEndpoint": "monitoring.private.storage:51820"
 , "passValue": 1000000
 , "issuerDomains": [
@@ -15,4 +15,5 @@
   , "https://private.storage"
   , "https://www.private.storage"
   ]
+, "monitoringGoogleOAuthClientID": "802959152038-klpkk38sfnqmknn1ucg7pvs4hcc2k8ae.apps.googleusercontent.com"
 }
diff --git a/morph/grid/production/grid.nix b/morph/grid/production/grid.nix
index fee0c9be6faed47d4a702b5b53c2419cbb677ba6..e663d2243e4aa6078260e41f07f807f606e64ef6 100644
--- a/morph/grid/production/grid.nix
+++ b/morph/grid/production/grid.nix
@@ -1,117 +1,128 @@
-# Load the helper function and call it with arguments tailored for the testing
-# grid.  It will make the morph configuration for us.  We share this function
-# with the testing grid and have one fewer possible point of divergence.
-import ../../lib/make-grid.nix {
-  name = "Production";
-  config = ./config.json;
-  nodes = cfg:
-    let
-      sshUsers = import ./secrets/users.nix;
+# See morph/grid/local/grid.nix for additional commentary.
+let
+  pkgs = import <nixpkgs> { };
 
-      # Get absolute vpn key directory path, as a string:
-      monitoringvpnKeyDir = toString ./. + "/${cfg.monitoringvpnKeyDir}";
+  gridlib = import ../../lib;
+  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
+  config = rawConfig // {
+    sshUsers = import ./public-keys/users.nix;
 
-      # TBD: derive these automatically:
-      hostsMap = {
-        "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
-        "172.23.23.11" = [   "payments"   "payments.monitoringvpn" ];
-        "172.23.23.21" = [ "storage001" "storage001.monitoringvpn" ];
-        "172.23.23.22" = [ "storage002" "storage002.monitoringvpn" ];
-        "172.23.23.23" = [ "storage003" "storage003.monitoringvpn" ];
-        "172.23.23.24" = [ "storage004" "storage004.monitoringvpn" ];
-        "172.23.23.25" = [ "storage005" "storage005.monitoringvpn" ];
-      };
-      vpnClientIPs = [
-        "172.23.23.11"
-        "172.23.23.21"
-        "172.23.23.22"
-        "172.23.23.23"
-        "172.23.23.24"
-        "172.23.23.25"
-      ];
-      nodeExporterTargets = [
-        "monitoring"
-        "payments"
-        "storage001"
-        "storage002"
-        "storage003"
-        "storage004"
-        "storage005"
-      ];
+    # Convert relative paths to absolute so library code can resolve names
+    # correctly.
+    publicKeyPath = toString ./. + "/${rawConfig.publicKeyPath}";
+    privateKeyPath = toString ./. + "/${rawConfig.privateKeyPath}";
+  };
 
-    in {
-    # Here are the hosts that are in this morph network.  This is sort of like
-    # a server manifest.  We try to keep as many of the specific details as
-    # possible out of *this* file so that this file only grows as server count
-    # grows.  If it grows too much, we can load servers by listing contents of
-    # a directory or reading from another JSON file or some such.  For now,
-    # I'm just manually maintaining these entries.
-    #
-    # The name on the left of the `=` is mostly irrelevant but it does provide
-    # a default hostname for the server if the configuration on the right side
-    # doesn't specify one.
-    #
-    # The names must be unique!
-    "payments.privatestorage.io" = import ../../lib/make-issuer.nix (cfg // {
-      publicIPv4 = "18.184.142.208";
-      monitoringvpnIPv4 = "172.23.23.11";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.03";
-    });
+  # Configure deployment management authorization for all systems in the grid.
+  deployment = {
+    services.private-storage.deployment = {
+      authorizedKey = builtins.readFile "${config.publicKeyPath}/deploy_key.pub";
+      gridName = "production";
+    };
+  };
 
-    "storage001" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage001-config.nix;
-        inherit sshUsers;
-        hardware = ./storage001-hardware.nix;
-        stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.21";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage002" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage002-config.nix;
-        inherit sshUsers;
-        hardware = ./storage002-hardware.nix;
-        stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.22";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage003" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage003-config.nix;
-        inherit sshUsers;
-        hardware = ./storage003-hardware.nix;
-        stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.23";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage004" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage004-config.nix;
-        inherit sshUsers;
-        hardware = ./storage004-hardware.nix;
+  payments = {
+    imports = [
+      gridlib.issuer
+      gridlib.hardware-aws
+      (gridlib.customize-issuer (config // {
+        monitoringvpnIPv4 = "172.23.23.11";
+      }))
+      deployment
+    ];
+  };
+
+  monitoring = {
+    imports = [
+      gridlib.monitoring
+      gridlib.hardware-aws
+      (gridlib.customize-monitoring {
+        inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
+        inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail;
+        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
+        monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
-        monitoringvpnIPv4 = "172.23.23.24";
-        inherit monitoringvpnKeyDir;
-    });
-    "storage005" = import ../../lib/make-storage.nix (cfg // {
-        cfg = import ./storage005-config.nix;
-        inherit sshUsers;
-        hardware = ./storage005-hardware.nix;
-        stateVersion = "19.03";
-        monitoringvpnIPv4 = "172.23.23.25";
-        inherit monitoringvpnKeyDir;
-    });
+      })
+      deployment
+    ];
+  };
+
+  defineStorageNode = name: { vpnIP, stateVersion }:
+  let
+    nodecfg = import "${./.}/${name}-config.nix";
+    hardware ="${./.}/${name}-hardware.nix";
+  in {
+    imports = [
+      # Get some of the very lowest-level system configuration for this
+      # node.  This isn't all *completely* hardware related.  Maybe some
+      # more factoring is in order, someday.
+      hardware
+
+      # Slightly awkwardly, enable some of our hardware / network / bootloader options.
+      ../../../nixos/modules/100tb.nix
+
+      # Get all of the configuration that is common across all storage nodes.
+      gridlib.storage
+
+      # Then customize the storage system a little bit based on this node's particulars.
+      (gridlib.customize-storage (config // nodecfg // {
+        monitoringvpnIPv4 = vpnIP;
+        inherit stateVersion;
+      }))
+
+      # Also configure deployment management authorization
+      deployment
+    ];
+
+    # And supply configuration for those hardware / network / bootloader
+    # options.  See the 100tb module for handling of this value.  The module
+    # name is quoted because `1` makes `100tb` look an awful lot like a
+    # number.
+   "100tb".config = nodecfg;
+  };
+
+  # Define all of the storage nodes for this grid.
+  storageNodes = builtins.mapAttrs defineStorageNode {
+    storage001 = { vpnIP = "172.23.23.21"; stateVersion = "19.09"; };
+    storage002 = { vpnIP = "172.23.23.22"; stateVersion = "19.09"; };
+    storage003 = { vpnIP = "172.23.23.23"; stateVersion = "19.09"; };
+    storage004 = { vpnIP = "172.23.23.24"; stateVersion = "19.09"; };
+    storage005 = { vpnIP = "172.23.23.25"; stateVersion = "19.03"; };
+  };
+
+  # TBD: derive these automatically:
+  hostsMap = {
+    "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
+    "172.23.23.11" = [   "payments"   "payments.monitoringvpn" ];
+    "172.23.23.21" = [ "storage001" "storage001.monitoringvpn" ];
+    "172.23.23.22" = [ "storage002" "storage002.monitoringvpn" ];
+    "172.23.23.23" = [ "storage003" "storage003.monitoringvpn" ];
+    "172.23.23.24" = [ "storage004" "storage004.monitoringvpn" ];
+    "172.23.23.25" = [ "storage005" "storage005.monitoringvpn" ];
+  };
+  vpnClientIPs = [
+    "172.23.23.11"
+    "172.23.23.21"
+    "172.23.23.22"
+    "172.23.23.23"
+    "172.23.23.24"
+    "172.23.23.25"
+  ];
+  nodeExporterTargets = [
+    "monitoring"
+    "payments"
+    "storage001"
+    "storage002"
+    "storage003"
+    "storage004"
+    "storage005"
+  ];
+  paymentExporterTargets = [ "payments" ];
 
-    "monitoring" = import ../../lib/make-monitoring.nix (cfg // {
-      publicIPv4 = "monitoring.private.storage";
-      monitoringvpnIPv4 = "172.23.23.1";
-      inherit monitoringvpnKeyDir;
-      inherit vpnClientIPs;
-      inherit hostsMap;
-      inherit nodeExporterTargets;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.09";
-      inherit sshUsers;
-    });
+in {
+  network = {
+    description = "PrivateStorage.io Production Grid";
   };
-}
+  inherit payments;
+  inherit monitoring;
+} // storageNodes
diff --git a/morph/grid/production/public-keys/deploy_key.pub b/morph/grid/production/public-keys/deploy_key.pub
new file mode 100644
index 0000000000000000000000000000000000000000..3d9ea022d26654ba7b18bd3426a464049b58c9ea
--- /dev/null
+++ b/morph/grid/production/public-keys/deploy_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK50RwXncelNB4JAazoXEhCxXbJZ79qWcQMAWeX14H+W exarkun@baryon
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.1.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.1.pub
new file mode 100644
index 0000000000000000000000000000000000000000..79248b8afc2e5d58ce0e2829c34266d377e2ffa5
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.1.pub
@@ -0,0 +1 @@
+f4PF38t1ZRneFCV+12irDbMuG81WK6jiH0Ba+P+XtXM=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.11.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.11.pub
new file mode 100644
index 0000000000000000000000000000000000000000..c085058430258c7c5a4c3fe6a2a2e87ebce56543
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.11.pub
@@ -0,0 +1 @@
+yBdp154+SjyjTJM6ag1mbdnXORWrv/mJ01NJdkEe9VY=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.21.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.21.pub
new file mode 100644
index 0000000000000000000000000000000000000000..5c6351937d9d746d6c1e0ebca3439dc49a1f4574
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.21.pub
@@ -0,0 +1 @@
+G0//oetsCGa75x8rLsg98c9GT9a0ncf1yG9w2+5JV0M=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.22.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.22.pub
new file mode 100644
index 0000000000000000000000000000000000000000..1ec8fbe3f88c3d126b1c7a19a3c80ff55cedbe0c
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.22.pub
@@ -0,0 +1 @@
+Zq4OsMOTJ2NsVi00hB0x20mMqvoCrDUfleoI5rzIeEc=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.23.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.23.pub
new file mode 100644
index 0000000000000000000000000000000000000000..a5ce0ad526a0a0b949488304c05f0cc055695634
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.23.pub
@@ -0,0 +1 @@
+9ThSUgSNrykQEULj70QQyjlvtvGTmMPqsRMz8hc9xHA=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.24.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.24.pub
new file mode 100644
index 0000000000000000000000000000000000000000..c54c728a732d7ca083f9f5ac9e1cb7d82475101f
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.24.pub
@@ -0,0 +1 @@
+fPUnFOzBZRJDBdSR6iS5AaC40KKy/2REiM16hx+woxk=
diff --git a/morph/grid/production/public-keys/monitoringvpn/172.23.23.25.pub b/morph/grid/production/public-keys/monitoringvpn/172.23.23.25.pub
new file mode 100644
index 0000000000000000000000000000000000000000..0ae6bb2adee18a318237aa020ab222be0b240aa9
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/172.23.23.25.pub
@@ -0,0 +1 @@
+qS4rT+zjWrbXDhtEF4oyGv8/5oCIE1ZU9FF+O6AL8V4=
diff --git a/morph/grid/production/public-keys/monitoringvpn/server.pub b/morph/grid/production/public-keys/monitoringvpn/server.pub
new file mode 120000
index 0000000000000000000000000000000000000000..0e74cbd09e33c4771cfecb7efea12650c8bd3b51
--- /dev/null
+++ b/morph/grid/production/public-keys/monitoringvpn/server.pub
@@ -0,0 +1 @@
+172.23.23.1.pub
\ No newline at end of file
diff --git a/morph/grid/production/public-keys/users.nix b/morph/grid/production/public-keys/users.nix
new file mode 100644
index 0000000000000000000000000000000000000000..8b586703740765b7a3d462e74ca3ef3cced68da7
--- /dev/null
+++ b/morph/grid/production/public-keys/users.nix
@@ -0,0 +1,2 @@
+let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGN4VQm3BIQKEFTw6aPrEwNuShf640N+Py2LOKznFCRT exarkun@bottom";
+in { "root" = key; "jcalderone" = key; }
diff --git a/morph/grid/production/storage001-hardware.nix b/morph/grid/production/storage001-hardware.nix
index 4cd9f59b76dd77b6e6e85709b3fbee771677b641..b2ca97c1db1b9721b93f2662d6e8d34189d5a0ab 100644
--- a/morph/grid/production/storage001-hardware.nix
+++ b/morph/grid/production/storage001-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/f72c1f46-6723-45bf-9ef7-92f31cc37589";
@@ -30,9 +31,12 @@
       fsType = "zfs";
     };
 
-  swapDevices =
-    [ { device = "/dev/disk/by-uuid/f986a811-4912-4e9a-8bc3-01cb6926c4c6"; }
-    ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
+
 
   nix.maxJobs = lib.mkDefault 24;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage002-hardware.nix b/morph/grid/production/storage002-hardware.nix
index 4fc3a4097e05ec8c38c86db6bfce92e2a1af6f35..2f354ad29930f048f7eb20b54a1504ed87db85a1 100644
--- a/morph/grid/production/storage002-hardware.nix
+++ b/morph/grid/production/storage002-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/0e92ada9-effb-42e2-a26a-9cdb529bcdc7";
@@ -30,9 +31,11 @@
       fsType = "ext4";
     };
 
-  swapDevices =
-    [ { device = "/dev/disk/by-uuid/f762b5e2-bbdd-4a02-bbd9-0bf6b11e0ab5"; }
-    ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 24;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage003-hardware.nix b/morph/grid/production/storage003-hardware.nix
index 9882f5372cecd52794e1500bdef30e367008496e..d8ffe5d59fb39ba4a9c6b1b73313f199a2ed980b 100644
--- a/morph/grid/production/storage003-hardware.nix
+++ b/morph/grid/production/storage003-hardware.nix
@@ -13,6 +13,7 @@
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
   boot.supportedFilesystems = [ "zfs" ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/240fc1f6-cd55-48a3-ac80-5b3550a32ef5";
@@ -31,7 +32,11 @@
       fsType = "zfs";
     };
 
-  swapDevices = [ ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 24;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage004-hardware.nix b/morph/grid/production/storage004-hardware.nix
index 07de74e20ef58ab474b02248bcb6eed6189e1079..1fe78a76e813605d8e181d5a858062f77114ba38 100644
--- a/morph/grid/production/storage004-hardware.nix
+++ b/morph/grid/production/storage004-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/d628122e-05d9-4212-b6a5-4b9516d85dbe";
@@ -25,7 +26,11 @@
       fsType = "zfs";
     };
 
-  swapDevices = [ ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 32;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/production/storage005-hardware.nix b/morph/grid/production/storage005-hardware.nix
index 9a5ad02725e30b00619978035772d60bec9fcb8a..e8f7b6391b4cb1c8d3e6059c1fd09512a0cc370b 100644
--- a/morph/grid/production/storage005-hardware.nix
+++ b/morph/grid/production/storage005-hardware.nix
@@ -12,6 +12,7 @@
   boot.initrd.kernelModules = [ ];
   boot.kernelModules = [ "kvm-intel" ];
   boot.extraModulePackages = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
 
   fileSystems."/" =
     { device = "/dev/disk/by-uuid/2653c6bb-396f-4911-b9ff-b68de8f9715d";
@@ -30,7 +31,11 @@
     fsType = "zfs";
   };
 
-  swapDevices = [ ];
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
 
   nix.maxJobs = lib.mkDefault 32;
   powerManagement.cpuFreqGovernor = lib.mkDefault "powersave";
diff --git a/morph/grid/testing/.gitignore b/morph/grid/testing/.gitignore
index db2fc0de62d01d6d7eec83f8f3e8c3b13b20392a..e3b6111c86090b06c38b9e5afd1fcd16838ddf47 100644
--- a/morph/grid/testing/.gitignore
+++ b/morph/grid/testing/.gitignore
@@ -1 +1 @@
-secrets
+private-keys
diff --git a/morph/grid/testing/config.json b/morph/grid/testing/config.json
index a44b465f7f293f9d70c369a076c30b6cf810924f..a10840db52e8cd74bbac2a0ad38f4887c1a03258 100644
--- a/morph/grid/testing/config.json
+++ b/morph/grid/testing/config.json
@@ -1,7 +1,7 @@
-{ "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "./secrets/stripe.secret"
-, "monitoringvpnKeyDir": "./secrets/monitoringvpn"
+{ "domain": "privatestorage-staging.com"
+, "publicStoragePort": 8898
+, "privateKeyPath": "./private-keys"
+, "publicKeyPath": "./public-keys"
 , "monitoringvpnEndpoint": "monitoring.privatestorage-staging.com:51820"
 , "passValue": 1000000
 , "issuerDomains": [
@@ -14,4 +14,5 @@
   , "https://privatestorage-staging.com"
   , "https://www.privatestorage-staging.com"
   ]
+, "monitoringGoogleOAuthClientID": "802959152038-6esn1c6u2lm3j82lf29jvmn8s63hi8dc.apps.googleusercontent.com"
 }
diff --git a/morph/grid/testing/grid.nix b/morph/grid/testing/grid.nix
index e31a28f2eb7817f393f4e8b6b71972b7fd2f79f1..fbbbd9f13e49cfdc7fd2f0687fa2fe12df91ea33 100644
--- a/morph/grid/testing/grid.nix
+++ b/morph/grid/testing/grid.nix
@@ -1,54 +1,78 @@
-# Load the helper function and call it with arguments tailored for the testing
-# grid.  It will make the morph configuration for us.  We share this function
-# with the production grid and have one fewer possible point of divergence.
-import ../../lib/make-grid.nix {
-  name = "Testing";
-  config = ./config.json;
-  nodes = cfg:
-  let
-    sshUsers = import ./secrets/users.nix;
-
-    # Get absolute vpn key directory path, as a string:
-    monitoringvpnKeyDir = toString ./. + "/${cfg.monitoringvpnKeyDir}";
-
-    # TBD: derive these automatically:
-    hostsMap = {
-      "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
-      "172.23.23.11" = [ "payments"   "payments.monitoringvpn"   ];
-      "172.23.23.12" = [ "storage001" "storage001.monitoringvpn" ];
+# See morph/grid/local/grid.nix for additional commentary.
+let
+  pkgs = import <nixpkgs> { };
+
+  gridlib = import ../../lib;
+  rawConfig = pkgs.lib.trivial.importJSON ./config.json;
+  config = rawConfig // {
+    sshUsers = import ./public-keys/users.nix;
+
+    # 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 = {
+    services.private-storage.deployment = {
+      authorizedKey = builtins.readFile "${config.publicKeyPath}/deploy_key.pub";
+      gridName = "testing";
     };
-    vpnClientIPs = [ "172.23.23.11" "172.23.23.12" ];
-    nodeExporterTargets = [ "monitoring" "payments" "storage001" ];
-
-  in {
-    "payments" = import ../../lib/make-issuer.nix (cfg // {
-      publicIPv4 = "18.194.183.13";
-      monitoringvpnIPv4 = "172.23.23.11";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.03";
-    });
-
-    "storage001" = import ../../lib/make-testing.nix (cfg // {
-      publicIPv4 = "3.120.26.190";
-      monitoringvpnIPv4 = "172.23.23.12";
-      inherit monitoringvpnKeyDir;
-      inherit sshUsers;
-      hardware = ./testing001-hardware.nix;
-      stateVersion = "19.03";
-    });
-
-    "monitoring" = import ../../lib/make-monitoring.nix (cfg // {
-      publicIPv4 = "18.156.171.217";
-      monitoringvpnIPv4 = "172.23.23.1";
-      inherit monitoringvpnKeyDir;
-      inherit vpnClientIPs;
-      inherit hostsMap;
-      inherit nodeExporterTargets;
-      hardware = ../../lib/issuer-aws.nix;
-      stateVersion = "19.09";
-      inherit sshUsers;
-    });
   };
+
+  payments = {
+    imports = [
+      gridlib.issuer
+      gridlib.hardware-aws
+      (gridlib.customize-issuer (config // {
+        monitoringvpnIPv4 = "172.23.23.11";
+      }))
+      deployment
+    ];
+  };
+
+  storage001 = {
+    imports = [
+      gridlib.storage
+      gridlib.hardware-aws
+      ./testing001-hardware.nix
+      (gridlib.customize-storage (config // {
+        monitoringvpnIPv4 = "172.23.23.12";
+        stateVersion = "19.03";
+      }))
+      deployment
+    ];
+  };
+
+  monitoring = {
+    imports = [
+      gridlib.monitoring
+      gridlib.hardware-aws
+      (gridlib.customize-monitoring {
+        inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
+        inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail;
+        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
+        monitoringvpnIPv4 = "172.23.23.1";
+        stateVersion = "19.09";
+      })
+      deployment
+    ];
+  };
+
+  # TBD: derive these automatically:
+  hostsMap = {
+    "172.23.23.1"  = [ "monitoring" "monitoring.monitoringvpn" ];
+    "172.23.23.11" = [ "payments"   "payments.monitoringvpn"   ];
+    "172.23.23.12" = [ "storage001" "storage001.monitoringvpn" ];
+  };
+  vpnClientIPs = [ "172.23.23.11" "172.23.23.12" ];
+  nodeExporterTargets = [ "monitoring" "payments" "storage001" ];
+  paymentExporterTargets = [ "payments" ];
+
+in {
+  network = {
+    description = "PrivateStorage.io Testing Grid";
+  };
+  inherit payments monitoring storage001;
 }
diff --git a/morph/grid/testing/public-keys/deploy_key.pub b/morph/grid/testing/public-keys/deploy_key.pub
new file mode 100644
index 0000000000000000000000000000000000000000..2dafd3cce2c83b8e4a32815c37c51ee890ba846c
--- /dev/null
+++ b/morph/grid/testing/public-keys/deploy_key.pub
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB88qfLdoR5Pq9Us7vOVc6wBWmIDxme9MXYQSxxO+8/X exarkun@baryon
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.1.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.1.pub
new file mode 100644
index 0000000000000000000000000000000000000000..94e7f1592034419c8a561531811bd6e63241271c
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.1.pub
@@ -0,0 +1 @@
+iVS3L2DkH/pHAhiPpuduBMKlICPYmchHFfCg6n2ReUI=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.11.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.11.pub
new file mode 100644
index 0000000000000000000000000000000000000000..ed5b6822bd633df6b704fa0eda0e9250d4b198e2
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.11.pub
@@ -0,0 +1 @@
+sGUEH9+Mli1E1BFBMAHgPsnVlaD1EJKFaYOJ+dpyLy0=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.12.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.12.pub
new file mode 100644
index 0000000000000000000000000000000000000000..0c79d3a917db9f5caed071eabeae9d4974d660db
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.12.pub
@@ -0,0 +1 @@
+wvpkXigLG2zvmLhxsV2cmN/IgF+nLednV6uENvI6fh0=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/172.23.23.13.pub b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.13.pub
new file mode 100644
index 0000000000000000000000000000000000000000..31fd40caf83b95fd8e4566af21b9d6e59a70e629
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/172.23.23.13.pub
@@ -0,0 +1 @@
+5t9t6DOcYMQJNtnsG5/Ek+OmSX1mZgbMAHSWlJQKuxc=
diff --git a/morph/grid/testing/public-keys/monitoringvpn/server.pub b/morph/grid/testing/public-keys/monitoringvpn/server.pub
new file mode 120000
index 0000000000000000000000000000000000000000..0e74cbd09e33c4771cfecb7efea12650c8bd3b51
--- /dev/null
+++ b/morph/grid/testing/public-keys/monitoringvpn/server.pub
@@ -0,0 +1 @@
+172.23.23.1.pub
\ No newline at end of file
diff --git a/morph/grid/testing/public-keys/users.nix b/morph/grid/testing/public-keys/users.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d6a965011065cfe39713adfb797c190eb8dd1ecd
--- /dev/null
+++ b/morph/grid/testing/public-keys/users.nix
@@ -0,0 +1,9 @@
+let
+  jcalderone = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN4GenAY/YLGuf1WoMXyyVa3S9i4JLQ0AG+pt7nvcLlQ exarkun@baryon";
+  flo = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHx7wJQNqKn8jOC4AxySRL2UxidNp7uIK9ad3pMb1ifF flo@fs-la";
+in
+  {
+    "root" = jcalderone;
+    inherit jcalderone;
+    inherit flo;
+  }
diff --git a/morph/grid/testing/testing001-hardware.nix b/morph/grid/testing/testing001-hardware.nix
index 958a247862a7e4bb2581e7d1bb85cc0f85f3ea24..5dceb16af1deaeb4668e67cbb65715ae79aa55d9 100644
--- a/morph/grid/testing/testing001-hardware.nix
+++ b/morph/grid/testing/testing001-hardware.nix
@@ -1,12 +1,9 @@
 {
-  imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
-  ec2.hvm = true;
-
   boot.supportedFilesystems = [ "zfs" ];
   networking.hostId = "10000000";
 
   # Manually created using:
-  #   zpool create -m legacy -o ashift=12 root raidz /dev/disk/by-id/{nvme-nvme.1d0f-766f6c3038623133353836383465643436363430-416d617a6f6e20456c617374696320426c6f636b2053746f7265-00000001,nvme-nvme.1d0f-766f6c3034653531383066303134633436653034-416d617a6f6e20456c617374696320426c6f636b2053746f7265-00000001,nvme-nvme.1d0f-766f6c3062333164633831386366623231373730-416d617a6f6e20456c617374696320426c6f636b2053746f7265-00000001,nvme-nvme.1d0f-766f6c3061353939623438336661353933636664-416d617a6f6e20456c617374696320426c6f636b2053746f7265-00000001}
+  #   zpool create -m legacy -o ashift=12 root /dev/disk/by-id/nvme-Amazon_Elastic_Block_Store_vol047fc3ea6bafdcc58
   fileSystems."/storage" = {
     device = "root";
     fsType = "zfs";
diff --git a/morph/lib/customize-issuer.nix b/morph/lib/customize-issuer.nix
new file mode 100644
index 0000000000000000000000000000000000000000..1c0d668fbd4ae59bab115c2116b7fa377395dcfc
--- /dev/null
+++ b/morph/lib/customize-issuer.nix
@@ -0,0 +1,94 @@
+# Define a function which returns a value which fills in all the holes left by
+# ``issuer.nix``.
+{
+  # A path on the deployment system of a directory containing all of the
+  # public keys for the system.  For example, this holds Wireguard public keys
+  # for the VPN configuration and SSH public keys to configure SSH
+  # authentication.
+  publicKeyPath
+
+  # A path on the deployment system of a directory containing all of the
+  # corresponding private keys for the system.
+, privateKeyPath
+
+  # A string giving the IP address and port number (":"-separated) of the VPN
+  # server.
+, monitoringvpnEndpoint
+
+  # A string giving the VPN IPv4 address for this system.
+, monitoringvpnIPv4
+
+  # A string giving the domain name associated with this grid.  This is meant
+  # to be combined with the hostname for this system to produce a
+  # fully-qualified domain name.  For example, an issuer might have "payments"
+  # as its hostname and belong to a grid with the domain
+  # "example-grid.invalid".  This ``domain`` parameter should have the value
+  # ``"example-grid.invalid"`` for the system figure out that
+  # ``payments.example-grid.invalid`` is the name of this system.
+, domain
+
+  # A set mapping usernames as strings to SSH public keys as strings.  For
+  # each element of the site, the indicated user is configured on the system
+  # with the indicated SSH key as an authorized key.
+, sshUsers
+
+  # A string giving an email address to use for Let's Encrypt registration and
+  # certificate issuance.
+, letsEncryptAdminEmail
+
+  # A list of strings giving the domain names that point at this issuer
+  # system.  These will all be included in Let's Encrypt certificate.
+, issuerDomains
+
+  # A list of strings giving CORS Origins will the issuer will be configured
+  # to allow.
+, allowedChargeOrigins
+, ...
+}:
+{ config, ... }: {
+  # The morph default deployment target the name of the node in the network
+  # attrset.  We don't always want to give the node its proper public address
+  # there (because it depends on which domain is associated with the grid
+  # being configured and using variable names complicates a lot of things).
+  # Instead, just tell morph how to reach the node here - by using its fully
+  # qualified domain name.
+  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
+
+  deployment.secrets = {
+    # A path on the deployment system to a file containing the Ristretto
+    # signing key.  This is used as the source of the Ristretto signing key
+    # morph secret.
+    "ristretto-signing-key".source = "${privateKeyPath}/ristretto.signing-key";
+
+    # A path on the deployment system to a file containing the Stripe secret
+    # key.  This is used as the source of the Stripe secret key morph secret.
+    "stripe-secret-key".source = "${privateKeyPath}/stripe.secret";
+
+    # ``.../monitoringvpn`` is a path on the deployment system of a directory
+    # containing a number of VPN-related secrets.  This is expected to contain
+    # a number of files named like ``<VPN IPv4 address>.key`` containing the
+    # VPN private key for the corresponding host.  It must also contain
+    # ``server.pub`` and ``preshared.key`` holding the VPN server's public key
+    # and the pre-shared key, respectively.  All of these things are used as
+    # the sources of various VPN-related morph secrets.
+    "monitoringvpn-secret-key".source = "${privateKeyPath}/monitoringvpn/${monitoringvpnIPv4}.key";
+    "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
+  };
+
+  networking.domain = domain;
+
+  services.private-storage.sshUsers = sshUsers;
+  services.private-storage.monitoring.vpn.client = {
+    enable = true;
+    ip = monitoringvpnIPv4;
+    endpoint = monitoringvpnEndpoint;
+    endpointPublicKeyFile = "${publicKeyPath}/monitoringvpn/server.pub";
+  };
+
+  services.private-storage-issuer = {
+    inherit letsEncryptAdminEmail allowedChargeOrigins;
+    domains = issuerDomains;
+  };
+
+  system.stateVersion = "19.03";
+}
diff --git a/morph/lib/customize-monitoring.nix b/morph/lib/customize-monitoring.nix
new file mode 100644
index 0000000000000000000000000000000000000000..f5b820a272fcfd4ea7106af32ad2fd0ac5c8ece3
--- /dev/null
+++ b/morph/lib/customize-monitoring.nix
@@ -0,0 +1,107 @@
+# Define a function which returns a value which fills in all the holes left by
+# ``monitoring.nix``.
+{
+  # A set mapping VPN IP addresses as strings to lists of hostnames as
+  # strings.  The system's ``/etc/hosts`` will be populated with this
+  # information.  Apart from helping with normal forward resolution, this
+  # *also* gives us reverse resolution from the VPN IPs to hostnames which
+  # allows Grafana to show us hostnames instead of VPN IP addresses.
+  hostsMap
+
+  # See ``customize-issuer.nix``.
+, publicKeyPath
+, privateKeyPath
+, monitoringvpnIPv4
+, domain
+, letsEncryptAdminEmail
+
+  # A list of VPN IP addresses as strings indicating which clients will be
+  # allowed onto the VPN.
+, vpnClientIPs
+
+  # A list of VPN clients (IP addresses or hostnames) as strings indicating
+  # which nodes to scrape "nodeExporter" metrics from.
+, nodeExporterTargets
+
+  # A list of VPN clients (IP addresses or hostnames) as strings indicating
+  # which nodes to scrape "nginxExporter" metrics from.
+, nginxExporterTargets ? []
+
+  # A list of VPN clients (IP addresses or hostnames) as strings indicating
+  # which nodes to scrape PaymentServer metrics from.
+, paymentExporterTargets ? []
+
+  # A string containing the GSuite OAuth2 ClientID to use to authenticate
+  # logins to Grafana.
+, googleOAuthClientID
+
+  # A string giving the NixOS state version for the system.
+, stateVersion
+, ...
+}:
+{ config, ... }: {
+  # See customize-issuer.nix for an explanatoin of targetHost value.
+  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
+
+  deployment.secrets = 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
+    # avoids trying to read it and then failing).  Even if the secret did
+    # exist, if SSO is disabled there's no point sending the secret to the
+    # server.
+    #
+    # Also, we have to define this whole secret here so that we can configure
+    # it completely or not at all.  morph gets angry if we half configure it
+    # (say, by just omitting the "source" value).
+    grafanaSSO =
+      if googleOAuthClientID == ""
+      then { }
+      else {
+        "grafana-google-sso-secret" = {
+          source = "${privateKeyPath}/grafana-google-sso.secret";
+          destination = "/run/keys/grafana-google-sso.secret";
+          owner.user = config.systemd.services.grafana.serviceConfig.User;
+          owner.group = config.users.users.grafana.group;
+          permissions = "0400";
+          action = ["sudo" "systemctl" "restart" "grafana.service"];
+        };
+        "grafana-admin-password" = {
+          source = "${privateKeyPath}/grafana-admin.password";
+          destination = "/run/keys/grafana-admin.password";
+          owner.user = config.systemd.services.grafana.serviceConfig.User;
+          owner.group = config.users.users.grafana.group;
+          permissions = "0400";
+          action = ["sudo" "systemctl" "restart" "grafana.service"];
+        };
+      };
+    monitoringvpn = {
+      "monitoringvpn-private-key".source = "${privateKeyPath}/monitoringvpn/server.key";
+      "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
+    };
+    in
+      grafanaSSO // monitoringvpn;
+
+  networking.domain = domain;
+  networking.hosts = hostsMap;
+
+  services.private-storage.monitoring.vpn.server = {
+    enable = true;
+    ip = monitoringvpnIPv4;
+    inherit vpnClientIPs;
+    pubKeysPath = "${publicKeyPath}/monitoringvpn";
+  };
+
+  services.private-storage.monitoring.prometheus = {
+    inherit nodeExporterTargets;
+    inherit nginxExporterTargets;
+    inherit paymentExporterTargets;
+  };
+
+  services.private-storage.monitoring.grafana = {
+    inherit letsEncryptAdminEmail;
+    inherit googleOAuthClientID;
+    domain = "${config.networking.hostName}.${config.networking.domain}";
+  };
+
+  system.stateVersion = stateVersion;
+}
diff --git a/morph/lib/customize-storage.nix b/morph/lib/customize-storage.nix
new file mode 100644
index 0000000000000000000000000000000000000000..68655874efd9ba39b52dacfdddaedb54863ed769
--- /dev/null
+++ b/morph/lib/customize-storage.nix
@@ -0,0 +1,47 @@
+# Define a function which returns a value which fills in all the holes left by
+# ``storage.nix``.
+{
+  # See ``customize-issuer.nix``
+  privateKeyPath
+, publicKeyPath
+, monitoringvpnEndpoint
+, monitoringvpnIPv4
+, sshUsers
+, domain
+
+  # An integer giving the value of a single pass in byte×months.
+, passValue
+
+  # An integer giving the port number to include in Tahoe storage service
+  # advertisements and on which to listen for storage connections.
+, publicStoragePort
+
+  # A string giving the NixOS state version for the system.
+, stateVersion
+, ...
+}:
+{ config, ... }: {
+  # See customize-issuer.nix for an explanatoin of targetHost value.
+  deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
+
+  deployment.secrets = {
+    "ristretto-signing-key".source = "${privateKeyPath}/ristretto.signing-key";
+    "monitoringvpn-secret-key".source = "${privateKeyPath}/monitoringvpn/${monitoringvpnIPv4}.key";
+    "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
+  };
+
+  networking.domain = domain;
+
+  services.private-storage = {
+    inherit sshUsers passValue publicStoragePort;
+  };
+
+  services.private-storage.monitoring.vpn.client = {
+    enable = true;
+    ip = monitoringvpnIPv4;
+    endpoint = monitoringvpnEndpoint;
+    endpointPublicKeyFile = "${publicKeyPath}/monitoringvpn/server.pub";
+  };
+
+  system.stateVersion = stateVersion;
+}
diff --git a/morph/lib/default.nix b/morph/lib/default.nix
new file mode 100644
index 0000000000000000000000000000000000000000..bdd92f4bfe52eba2e19df3ac73a087a4af4a53dc
--- /dev/null
+++ b/morph/lib/default.nix
@@ -0,0 +1,16 @@
+# Gather up the grid library functionality and present it in a (somewhat)
+# coherent public interface.  Application code should prefer these names over
+# directly importing the source files in this directory.
+{
+  hardware-aws = import ./issuer-aws.nix;
+  hardware-virtual = import ./hardware-virtual.nix;
+
+  issuer = import ./issuer.nix;
+  customize-issuer = import ./customize-issuer.nix;
+
+  storage = import ./storage.nix;
+  customize-storage = import ./customize-storage.nix;
+
+  monitoring = import ./monitoring.nix;
+  customize-monitoring = import ./customize-monitoring.nix;
+}
diff --git a/morph/grid/local/virtual-hardware.nix b/morph/lib/hardware-virtual.nix
similarity index 95%
rename from morph/grid/local/virtual-hardware.nix
rename to morph/lib/hardware-virtual.nix
index d5e9067bd5f3b3ca2ea1bb46746253fa39b25cf6..cf1582792bff77c491210ee5e91f99bfbffbf9f3 100644
--- a/morph/grid/local/virtual-hardware.nix
+++ b/morph/lib/hardware-virtual.nix
@@ -11,6 +11,7 @@
 
   boot.initrd.availableKernelModules = [ "ata_piix" "sd_mod" "sr_mod" ];
   boot.initrd.kernelModules = [ ];
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
   boot.kernelModules = [ ];
   boot.extraModulePackages = [ ];
 
@@ -33,4 +34,3 @@
   # We want to push packages with morph without having to sign them
   nix.trustedUsers = [ "@wheel" "root" "vagrant" ];
 }
-
diff --git a/morph/lib/issuer-aws.nix b/morph/lib/issuer-aws.nix
index b4d4757ad5597b69363ef12e4297aec80913f00e..a66ab72addd43da1feb96bdd86d46312ec327fd3 100644
--- a/morph/lib/issuer-aws.nix
+++ b/morph/lib/issuer-aws.nix
@@ -1,4 +1,20 @@
-{
+{ name, lib, ... }: {
   imports = [ <nixpkgs/nixos/modules/virtualisation/amazon-image.nix> ];
+
+  # amazon-image.nix isn't quite aware of nvme-attached storage so give it a
+  # little help configuring grub.
+  boot.loader.grub.device = lib.mkForce "/dev/nvme0n1";
+
   ec2.hvm = true;
+  boot.kernel.sysctl = { "vm.swappiness" = 0; };
+  swapDevices = [ {
+    device = "/var/swapfile";
+    size = 8192; # megabytes
+    randomEncryption = true;
+  } ];
+
+  # Break the tie between AWS and morph for the hostname by forcing the
+  # morph-supplied name.  See also
+  # <https://github.com/DBCDK/morph/issues/146>.
+  networking.hostName = name;
 }
diff --git a/morph/lib/issuer.nix b/morph/lib/issuer.nix
new file mode 100644
index 0000000000000000000000000000000000000000..51046b436e297cdc5034134e3503556e8030588c
--- /dev/null
+++ b/morph/lib/issuer.nix
@@ -0,0 +1,59 @@
+# This is all of the static NixOS system configuration necessary to specify an
+# "issuer"-type system.  The configuration has various holes in it which must
+# be filled somehow.  These holes correspond to configuration which is not
+# statically known.  This value is suitable for use as a module to be imported
+# into a more complete system configuration.  It is expected that the holes
+# will be filled by a sibling module created by ``customize-issuer.nix``.
+rec {
+  deployment = {
+    secrets = {
+      "ristretto-signing-key" = {
+        destination = "/run/keys/ristretto.signing-key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
+      };
+      "stripe-secret-key" = {
+        destination = "/run/keys/stripe.secret-key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
+      };
+
+      "monitoringvpn-secret-key" = {
+        destination = "/run/keys/monitoringvpn/client.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+      "monitoringvpn-preshared-key" = {
+        destination = "/run/keys/monitoringvpn/preshared.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+    };
+  };
+
+  imports = [
+    # 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
+  ];
+
+  services.private-storage-issuer = {
+    enable = true;
+    tls = true;
+    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
+    stripeSecretKeyPath = deployment.secrets.stripe-secret-key.destination;
+    database = "SQLite3";
+    databasePath = "/var/db/vouchers.sqlite3";
+  };
+}
diff --git a/morph/lib/make-grid.nix b/morph/lib/make-grid.nix
deleted file mode 100644
index de10df1e9a62ee0ac7fde98070743ee4a9cf484b..0000000000000000000000000000000000000000
--- a/morph/lib/make-grid.nix
+++ /dev/null
@@ -1,19 +0,0 @@
-# Define a function for making a morph configuration for a storage grid.  It
-# takes two arguments.  A string like "Production" giving the name of the grid
-# and a function that takes the grid configuration as an argument and returns
-# a set of nodes specifying the addresses and NixOS configurations for each
-# server in the morph network.
-{ name, config, nodes }:
-let
-  pkgs = import <nixpkgs> { };
-  # Load our JSON configuration for later use.
-  cfg = pkgs.lib.trivial.importJSON config;
-in
-{
-  network =  {
-    # Make all of the hosts in this network use the nixpkgs we pinned above.
-    inherit pkgs;
-    # This is just for human consumption as far as I can tell.
-    description = "PrivateStorage.io ${name} Grid";
-  };
-} // (nodes cfg)
diff --git a/morph/lib/make-issuer.nix b/morph/lib/make-issuer.nix
deleted file mode 100644
index bbdf0cebbf770738e9ccb997daec75e58df021b5..0000000000000000000000000000000000000000
--- a/morph/lib/make-issuer.nix
+++ /dev/null
@@ -1,91 +0,0 @@
-{ hardware
-, ristrettoSigningKeyPath
-, stripeSecretKeyPath
-, issuerDomains
-, letsEncryptAdminEmail
-, allowedChargeOrigins
-, sshUsers
-, stateVersion
-, publicIPv4
-, monitoringvpnKeyDir ? null
-, monitoringvpnIPv4 ? null
-, monitoringvpnEndpoint ? null
-, ...
-}: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              monitoringvpnEndpoint != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-secret-key" = {
-      source = monitoringvpnKeyDir + "/${monitoringvpnIPv4}.key";
-      destination = "/run/keys/monitoringvpn/client.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-  deployment = {
-    targetHost = publicIPv4;
-
-    secrets = {
-      "ristretto-signing-key" = {
-        source = ristrettoSigningKeyPath;
-        destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
-      };
-      "stripe-secret-key" = {
-        source = stripeSecretKeyPath;
-        destination = "/run/keys/stripe.secret-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
-      };
-    } // vpnSecrets;
-  };
-
-  imports = [
-    hardware
-    ../../nixos/modules/issuer.nix
-    ../../nixos/modules/monitoring/vpn/client.nix
-    ../../nixos/modules/monitoring/exporters/node.nix
-  ];
-
-  services.private-storage.sshUsers = sshUsers;
-  services.private-storage-issuer = {
-    enable = true;
-    tls = true;
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    stripeSecretKeyPath = deployment.secrets.stripe-secret-key.destination;
-    database = "SQLite3";
-    databasePath = "/var/db/vouchers.sqlite3";
-    inherit letsEncryptAdminEmail;
-    domains = issuerDomains;
-    inherit allowedChargeOrigins;
-  };
-
-  system.stateVersion = stateVersion;
-
-  services.private-storage.monitoring.vpn.client = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    endpoint = monitoringvpnEndpoint;
-    endpointPublicKeyFile = monitoringvpnKeyDir + "/server.pub";
-  };
-}
diff --git a/morph/lib/make-monitoring.nix b/morph/lib/make-monitoring.nix
deleted file mode 100644
index 592a859657e624e8fdf5632f8144c5acc6919e8c..0000000000000000000000000000000000000000
--- a/morph/lib/make-monitoring.nix
+++ /dev/null
@@ -1,77 +0,0 @@
-{ publicIPv4
-, hardware
-, publicStoragePort
-, ristrettoSigningKeyPath
-, passValue
-, sshUsers
-, stateVersion
-, monitoringvpnIPv4 ? null
-, monitoringvpnKeyDir ? null
-, vpnClientIPs ? null
-, nodeExporterTargets ? []
-, nginxExporterTargets ? []
-, hostsMap ? {}
-, ... }: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              vpnClientIPs != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-private-key" = {
-      source = monitoringvpnKeyDir + "/server.key";
-      destination = "/run/keys/monitoringvpn/server.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-
-  deployment = {
-    targetHost = publicIPv4;
-    secrets = vpnSecrets;
-  };
-
-  imports = [
-    hardware
-    ../../nixos/modules/monitoring/vpn/server.nix
-    ../../nixos/modules/monitoring/server/grafana.nix
-    ../../nixos/modules/monitoring/server/prometheus.nix
-    ../../nixos/modules/monitoring/exporters/node.nix
-    # Loki 0.3.0 from Nixpkgs 19.09 is too old and does not work:
-    # ../../nixos/modules/monitoring/server/loki.nix
-  ];
-
-  services.private-storage.monitoring.vpn.server = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    inherit vpnClientIPs;
-    pubKeysPath = monitoringvpnKeyDir;
-  };
-
-  services.private-storage.monitoring.grafana = {
-    domain = "monitoring.private.storage";
-    prometheusUrl = "http://localhost:9090/";
-    lokiUrl = "http://localhost:3100/";
-  };
-
-  services.private-storage.monitoring.prometheus = {
-    inherit nodeExporterTargets;
-    inherit nginxExporterTargets;
-  };
-
-  system.stateVersion = stateVersion;
-
-  networking.hosts = hostsMap;
-}
diff --git a/morph/lib/make-storage.nix b/morph/lib/make-storage.nix
deleted file mode 100644
index 6619336d758f69a677e9178592357480aed3f0c8..0000000000000000000000000000000000000000
--- a/morph/lib/make-storage.nix
+++ /dev/null
@@ -1,109 +0,0 @@
-# Define the function that defines the node.
-{ cfg                        # Get the configuration that's specific to this node.
-, hardware                   # The path to the hardware configuration for this node.
-, publicStoragePort          # The storage port number on which to accept connections.
-, ristrettoSigningKeyPath    # The *local* path to the Ristretto signing key file.
-, passValue                  # Bytes component of size×time value of passes.
-, sshUsers                   # Users for which to configure SSH access to this node.
-, stateVersion               # The value for system.stateVersion on this node.
-                             # This value determines the NixOS release with
-                             # which your system is to be compatible, in order
-                             # to avoid breaking some software such as
-                             # database servers. You should change this only
-                             # after NixOS release notes say you should.
-, monitoringvpnKeyDir ? null # The directory that holds the VPN keys.
-, monitoringvpnIPv4 ? null   # This node's IP in the monitoring VPN.
-, monitoringvpnEndpoint ? null # The VPN server and port.
-, ...
-}: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              monitoringvpnEndpoint != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-secret-key" = {
-      source = monitoringvpnKeyDir + "/${monitoringvpnIPv4}.key";
-      destination = "/run/keys/monitoringvpn/client.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-  deployment = {
-    targetHost = cfg.publicIPv4;
-
-    secrets = {
-      "ristretto-signing-key" = {
-        source = ristrettoSigningKeyPath;
-        destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        # Service name here matches the name defined by our tahoe-lafs nixos
-        # module.  It would be nice to not have to hard-code it here.  Can we
-        # extract it from the tahoe-lafs nixos module somehow?
-        action = ["sudo" "systemctl" "restart" "tahoe.storage.service"];
-      };
-    } // vpnSecrets;
-  };
-
-  # Any extra NixOS modules to load on this server.
-  imports = [
-    # Include the results of the hardware scan.
-    hardware
-    # Configure it as a system operated by 100TB.
-    ../../nixos/modules/100tb.nix
-    # Bring in our module for configuring the Tahoe-LAFS service and other
-    # Private Storage-specific things.
-    ../../nixos/modules/private-storage.nix
-    # Connect to the monitoringvpn.
-    ../../nixos/modules/monitoring/vpn/client.nix
-    # Expose base system metrics over the monitoringvpn.
-    ../../nixos/modules/monitoring/exporters/node.nix
-  ];
-
-  # Pass the configuration specific to this host to the 100TB module to be
-  # expanded into a complete system configuration.  See the 100tb module for
-  # handling of this value.
-  #
-  # The module name is quoted because `1` makes `100tb` look an awful lot like
-  # it should be a number.
-  "100tb".config = cfg;
-
-  # Turn on the Private Storage (Tahoe-LAFS) service.
-  services.private-storage = {
-    # Yep.  Turn it on.
-    enable = true;
-    # Get the public IPv4 address from the node configuration.
-    inherit (cfg) publicIPv4;
-    # And the port to operate on is specified via parameter.
-    inherit publicStoragePort;
-    # Give it the Ristretto signing key, too, to support authorization.
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    # Assign the configured pass value.
-    inherit passValue;
-    # It gets the users, too.
-    inherit sshUsers;
-  };
-
-  system.stateVersion = stateVersion;
-
-  services.private-storage.monitoring.vpn.client = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    endpoint = monitoringvpnEndpoint;
-    endpointPublicKeyFile = monitoringvpnKeyDir + "/server.pub";
-  };
-}
diff --git a/morph/lib/make-testing.nix b/morph/lib/make-testing.nix
deleted file mode 100644
index 3f6e767db5ee734a8ca2314b216d4fa602c01907..0000000000000000000000000000000000000000
--- a/morph/lib/make-testing.nix
+++ /dev/null
@@ -1,80 +0,0 @@
-{ publicIPv4
-, hardware
-, publicStoragePort
-, ristrettoSigningKeyPath
-, passValue
-, sshUsers
-, stateVersion
-, monitoringvpnKeyDir ? null
-, monitoringvpnIPv4 ? null
-, monitoringvpnEndpoint ? null
-, ... }: let
-
-  enableVpn = monitoringvpnKeyDir != null &&
-              monitoringvpnIPv4 != null &&
-              monitoringvpnEndpoint != null;
-
-  vpnSecrets = if !enableVpn then {} else {
-    "monitoringvpn-secret-key" = {
-      source = monitoringvpnKeyDir + "/${monitoringvpnIPv4}.key";
-      destination = "/run/keys/monitoringvpn/client.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-    "monitoringvpn-preshared-key" = {
-      source = monitoringvpnKeyDir + "/preshared.key";
-      destination = "/run/keys/monitoringvpn/preshared.key";
-      owner.user = "root";
-      owner.group = "root";
-      permissions = "0400";
-      action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
-    };
-  };
-
-in rec {
-
-  deployment = {
-    targetHost = publicIPv4;
-
-    secrets = {
-      "ristretto-signing-key" = {
-        source = ristrettoSigningKeyPath;
-        destination = "/run/keys/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
-        permissions = "0400";
-        # Service name here matches the name defined by our tahoe-lafs nixos
-        # module.  It would be nice to not have to hard-code it here.  Can we
-        # extract it from the tahoe-lafs nixos module somehow?
-        action = ["sudo" "systemctl" "restart" "tahoe.storage.service"];
-      };
-    } // vpnSecrets;
-  };
-
-  imports = [
-    hardware
-    ../../nixos/modules/private-storage.nix
-    ../../nixos/modules/monitoring/vpn/client.nix
-    ../../nixos/modules/monitoring/exporters/node.nix
-  ];
-
-  services.private-storage =
-  { enable = true;
-    inherit publicIPv4;
-    inherit publicStoragePort;
-    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
-    inherit passValue;
-    inherit sshUsers;
-  };
-
-  system.stateVersion = stateVersion;
-
-  services.private-storage.monitoring.vpn.client = if !enableVpn then {} else {
-    enable = true;
-    ip = monitoringvpnIPv4;
-    endpoint = monitoringvpnEndpoint;
-    endpointPublicKeyFile = monitoringvpnKeyDir + "/server.pub";
-  };
-}
diff --git a/morph/lib/monitoring.nix b/morph/lib/monitoring.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d8af93b24119ba6dff5ce63a5b2d16fbd18edb71
--- /dev/null
+++ b/morph/lib/monitoring.nix
@@ -0,0 +1,34 @@
+# Similar to ``issuer.nix`` but for a "monitoring"-type system.  Holes are
+# filled by ``customize-monitoring.nix``.
+rec {
+  deployment = {
+    secrets = {
+      "monitoringvpn-private-key" = {
+        destination = "/run/keys/monitoringvpn/server.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+      "monitoringvpn-preshared-key" = {
+        destination = "/run/keys/monitoringvpn/preshared.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+    };
+  };
+
+  imports = [
+    # 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
+    ../../nixos/modules/monitoring/exporters/node.nix
+    # Loki 0.3.0 from Nixpkgs 19.09 is too old and does not work:
+    # ../../nixos/modules/monitoring/server/loki.nix
+  ];
+}
diff --git a/morph/lib/storage.nix b/morph/lib/storage.nix
new file mode 100644
index 0000000000000000000000000000000000000000..ebad3d17e17e0098f6e098d61d7c614fde91b31e
--- /dev/null
+++ b/morph/lib/storage.nix
@@ -0,0 +1,53 @@
+# Similar to ``issuer.nix`` but for a "storage"-type system.  Holes are filled
+# by ``customize-storage.nix``.
+rec {
+  deployment = {
+    secrets = {
+      "ristretto-signing-key" = {
+        destination = "/run/keys/ristretto.signing-key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        # Service name here matches the name defined by our tahoe-lafs nixos
+        # module.  It would be nice to not have to hard-code it here.  Can we
+        # extract it from the tahoe-lafs nixos module somehow?
+        action = ["sudo" "systemctl" "restart" "tahoe.storage.service"];
+      };
+      "monitoringvpn-secret-key" = {
+        destination = "/run/keys/monitoringvpn/client.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+      "monitoringvpn-preshared-key" = {
+        destination = "/run/keys/monitoringvpn/preshared.key";
+        owner.user = "root";
+        owner.group = "root";
+        permissions = "0400";
+        action = ["sudo" "systemctl" "restart" "wireguard-monitoringvpn.service"];
+      };
+    };
+  };
+
+  # Any extra NixOS modules to load on this server.
+  imports = [
+    # 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
+    # Connect to the monitoringvpn.
+    ../../nixos/modules/monitoring/vpn/client.nix
+    # Expose base system metrics over the monitoringvpn.
+    ../../nixos/modules/monitoring/exporters/node.nix
+  ];
+
+  # Turn on the Private Storage (Tahoe-LAFS) service.
+  services.private-storage = {
+    # Yep.  Turn it on.
+    enable = true;
+    # Give it the Ristretto signing key to support authorization.
+    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
+  };
+}
diff --git a/morph/grid/local/vagrant-guest.nix b/morph/lib/vagrant-guest.nix
similarity index 92%
rename from morph/grid/local/vagrant-guest.nix
rename to morph/lib/vagrant-guest.nix
index 9e8e6d8ccab25d98d11738ff7df4a574c5cfd724..360671f5e8391571d37da6db37b2de8dc02b66bd 100644
--- a/morph/grid/local/vagrant-guest.nix
+++ b/morph/lib/vagrant-guest.nix
@@ -1,6 +1,6 @@
 # Minimal configuration that vagrant depends on
 
-{ config, pkgs, ... }:
+{ config, pkgs, lib, ... }:
 let
   # Vagrant uses an insecure shared private key by default, but we
   # don't use the authorizedKeys attribute under users because it should be
@@ -22,8 +22,10 @@ in
   # Enable the OpenSSH daemon.
   services.openssh.enable = true;
 
-  # Wireguard kernel module
-  boot.extraModulePackages = [ config.boot.kernelPackages.wireguard ];
+  # Wireguard kernel module for Kernels < 5.6
+  boot = lib.mkIf (lib.versionOlder pkgs.linuxPackages.kernel.version "5.6") {
+    extraModulePackages = [ config.boot.kernelPackages.wireguard ] ;
+  };
 
   # Enable DBus
   services.dbus.enable    = true;
diff --git a/nixos/modules/deployment.nix b/nixos/modules/deployment.nix
new file mode 100755
index 0000000000000000000000000000000000000000..b0a5e3c4c761d188922a076643fcd3a25a4b81f0
--- /dev/null
+++ b/nixos/modules/deployment.nix
@@ -0,0 +1,118 @@
+# A NixOS module which enables remotely-triggered deployment updates.
+{ config, lib, pkgs, ... }:
+let
+  # A handy alias for our part of the configuration.
+  cfg = config.services.private-storage.deployment;
+
+  # Compute an authorized_keys line that allows the holder of a certain key to
+  # execute a certain command *only*.
+  restrictedKey =
+    { authorizedKey, command, gridName }:
+    # `restrict` means "disable all the things" then `command` means "but
+    # enable running this one command" (the client does not have to supply the
+    # command; if they authenticate, this is the command that will run).
+    "restrict,command=\"${command} ${gridName}\" ${authorizedKey}";
+in {
+  options = {
+    services.private-storage.deployment.authorizedKey = lib.mkOption {
+      type = lib.types.str;
+      example = lib.literalExample ''
+        ssh-ed25519 AAAAC3N...
+      '';
+      description = ''
+        The SSH public key to authorize to trigger a deployment update.
+      '';
+    };
+    services.private-storage.deployment.gridName = lib.mkOption {
+      type = lib.types.str;
+      example = lib.literalExample "staging";
+      description = ''
+        The name of the grid configuration to use to update this deployment.
+      '';
+    };
+  };
+
+  config = {
+    # Configure the system to use our binary cache so that deployment updates
+    # only require downloading pre-built software, not building it ourselves.
+    nix = {
+      binaryCachePublicKeys = [
+        "saxtons.private.storage:MplOcEH8G/6mRlhlKkbA8GdeFR3dhCFsSszrspE/ZwY="
+      ];
+      binaryCaches = [
+        "http://saxtons.private.storage"
+      ];
+    };
+
+    # Create a one-time service that will set up an ssh key that allows the
+    # deployment user to authorize as root to perform the system update with
+    # `morph deploy`.
+    systemd.services.authorize-morph-as-root = {
+      enable = true;
+      serviceConfig = {
+        # Tell systemd that the service is a process that runs and then exits.
+        # By being "oneshot" instead of "simple" any dependencies are not
+        # started until after the process exits.  We have no dependencies yet
+        # but if we did it would be more correct for them to wait until we are
+        # done.
+        #
+        # It is not clear that "oneshot" means "run once" though (maybe it
+        # does, I can't tell) so the script is robust in the face of repeated
+        # runs even though it should only ever need to be run once.
+        Type = "oneshot";
+      };
+      wantedBy = [
+        # Run this to reach the multi-user target, a good target that is
+        # reached in the typical course of system startup.
+        "multi-user.target"
+      ];
+      # Here's the program to run for this unit.  It's a shell script that
+      # creates an ssh key that authorized root access via ssh and give it to
+      # the deployment user.  If such a key appears to exist already, do
+      # nothing.
+      script = ''
+        KEY=~deployment/.ssh/morph_key
+        TMP="$KEY"_tmp
+        if [ ! -e "$KEY" ]; then
+          mkdir -p ~deployment/.ssh ~root/.ssh
+          chown deployment ~deployment/.ssh
+          ${pkgs.openssh}/bin/ssh-keygen -f "$TMP"
+          cat "$TMP".pub >> ~root/.ssh/authorized_keys
+          mv "$TMP".pub "$KEY".pub
+          mv "$TMP" "$KEY"
+          chown deployment "$KEY"
+        fi
+      '';
+    };
+
+    # Raise the hard-limit on the size of $XDG_RUNTIME_DIR (ie
+    # /run/user/<uid>).  The default of 10% is too small on some systems for
+    # the temporary state morph creates to do the self-update.
+    services.logind.extraConfig = ''
+      RuntimeDirectorySize=50%
+    '';
+
+    # Configure the deployment user.
+    users.users.deployment = {
+      # A user must be either normal or system.  A normal user uses the
+      # default shell, has a home directory created for it at the usual
+      # location, and is in the "users" group.  That's pretty much what we
+      # want for the deployment user.
+      isNormalUser = true;
+
+      packages = [
+        # update-deployment dependencies
+        pkgs.morph
+        pkgs.git
+      ];
+
+      # Authorize the supplied key to run the deployment update command.
+      openssh.authorizedKeys.keys = [
+        (restrictedKey {
+          inherit (cfg) authorizedKey gridName;
+          command = ./update-deployment;
+        })
+      ];
+    };
+  };
+}
diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix
index fb93ce35cce8c9cadbad5a04e888b0cca991f9c7..da56a43012b7e53a6d5ced17123eb3d898b24f3e 100644
--- a/nixos/modules/issuer.nix
+++ b/nixos/modules/issuer.nix
@@ -2,6 +2,8 @@
 # ZKAPs.
 { lib, pkgs, 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.
@@ -11,7 +13,7 @@ in {
   options = {
     services.private-storage-issuer.enable = lib.mkEnableOption "PrivateStorage ZKAP Issuer Service";
     services.private-storage-issuer.package = lib.mkOption {
-      default = pkgs.zkapissuer.components.exes."PaymentServer-exe";
+      default = ourpkgs.zkapissuer.components.exes."PaymentServer-exe";
       type = lib.types.package;
       example = lib.literalExample "pkgs.zkapissuer.components.exes.\"PaymentServer-exe\"";
       description = ''
diff --git a/nixos/modules/monitoring/server/grafana-config/services-overview.json b/nixos/modules/monitoring/server/grafana-config/services-overview.json
new file mode 100644
index 0000000000000000000000000000000000000000..8ee7b130ce917183edfa26a7a53c1a8df1353303
--- /dev/null
+++ b/nixos/modules/monitoring/server/grafana-config/services-overview.json
@@ -0,0 +1,664 @@
+{
+  "annotations": {
+    "list": [
+      {
+        "builtIn": 1,
+        "datasource": "-- Grafana --",
+        "enable": true,
+        "hide": true,
+        "iconColor": "rgba(0, 211, 255, 1)",
+        "name": "Annotations & Alerts",
+        "type": "dashboard"
+      }
+    ]
+  },
+  "description": "RED: Requests-Errors-Duration for our services",
+  "editable": true,
+  "gnetId": null,
+  "graphTooltip": 0,
+  "id": 2,
+  "links": [],
+  "panels": [
+    {
+      "collapsed": false,
+      "datasource": null,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 0
+      },
+      "id": 18,
+      "panels": [],
+      "title": "Payments v1/stripe/charge",
+      "type": "row"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "HTTPS responses per second",
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 1
+      },
+      "id": 4,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "rate(http_responses_total{path=\"v1/stripe/charge\"}[5m])",
+          "instant": false,
+          "legendFormat": "{{status}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/stripe/charge RPS",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "",
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 1
+      },
+      "id": 15,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": true,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"4XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Client error (4XX) rate",
+          "refId": "A"
+        },
+        {
+          "expr": "sum(http_responses_total{path=\"v1/stripe/charge\", status=\"5XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Server error (5XX) rate",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/stripe/charge error rate",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "percentunit",
+          "label": null,
+          "logBase": 1,
+          "max": "100",
+          "min": "0",
+          "show": true
+        },
+        {
+          "format": "percent",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "cacheTimeout": null,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "Requests taking longer than 1 s, between 1 sec and 10 msec, and 10 msec and below",
+      "fill": 2,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 1
+      },
+      "id": 12,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pluginVersion": "6.4.3",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 0.01s",
+          "refId": "A"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"1.0\"} - ignoring(le) http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 1s",
+          "refId": "D"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"+Inf\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/stripe/charge\", le=\"1.0\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "> 1s",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/stripe/charge durations",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "collapsed": false,
+      "datasource": null,
+      "gridPos": {
+        "h": 1,
+        "w": 24,
+        "x": 0,
+        "y": 8
+      },
+      "id": 11,
+      "panels": [],
+      "title": "Payments v1/redeem",
+      "type": "row"
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "HTTPS responses per second",
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 0,
+        "y": 9
+      },
+      "id": 2,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "rate(http_responses_total{path=\"v1/redeem\"}[5m])",
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "{{status}}",
+          "refId": "A"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/redeem RPS",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "fill": 1,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 8,
+        "y": 9
+      },
+      "id": 16,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": true,
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": true,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "sum(http_responses_total{path=\"v1/redeem\", status=\"4XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Client error (4XX) rate",
+          "refId": "A"
+        },
+        {
+          "expr": "sum(http_responses_total{path=\"v1/redeem\", status=\"5XX\"}) / sum(http_responses_total{path=\"v1/redeem\"})",
+          "legendFormat": "Server error (5XX) rate",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/redeem error rate",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "percentunit",
+          "label": null,
+          "logBase": 1,
+          "max": "100",
+          "min": "0",
+          "show": true
+        },
+        {
+          "format": "percent",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": false
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    },
+    {
+      "aliasColors": {},
+      "bars": false,
+      "cacheTimeout": null,
+      "dashLength": 10,
+      "dashes": false,
+      "datasource": null,
+      "description": "Requests taking longer than 1 s, between 1 sec and 10 msec, and 10 msec and below",
+      "fill": 2,
+      "fillGradient": 0,
+      "gridPos": {
+        "h": 7,
+        "w": 8,
+        "x": 16,
+        "y": 9
+      },
+      "id": 13,
+      "legend": {
+        "avg": false,
+        "current": false,
+        "max": false,
+        "min": false,
+        "show": true,
+        "total": false,
+        "values": false
+      },
+      "lines": true,
+      "linewidth": 1,
+      "links": [],
+      "nullPointMode": "null",
+      "options": {
+        "dataLinks": []
+      },
+      "percentage": false,
+      "pluginVersion": "6.4.3",
+      "pointradius": 2,
+      "points": false,
+      "renderer": "flot",
+      "seriesOverrides": [],
+      "spaceLength": 10,
+      "stack": false,
+      "steppedLine": false,
+      "targets": [
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 0.01s",
+          "refId": "A"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"1.0\"} - ignoring(le) http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"0.01\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "=< 1s",
+          "refId": "D"
+        },
+        {
+          "expr": "http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"+Inf\"} - ignoring(le)  http_request_duration_seconds_bucket{path=\"v1/redeem\", le=\"1.0\"}",
+          "format": "time_series",
+          "hide": false,
+          "instant": false,
+          "intervalFactor": 1,
+          "legendFormat": "> 1s",
+          "refId": "B"
+        }
+      ],
+      "thresholds": [],
+      "timeFrom": null,
+      "timeRegions": [],
+      "timeShift": null,
+      "title": "v1/redeem durations",
+      "tooltip": {
+        "shared": true,
+        "sort": 0,
+        "value_type": "individual"
+      },
+      "type": "graph",
+      "xaxis": {
+        "buckets": null,
+        "mode": "time",
+        "name": null,
+        "show": true,
+        "values": []
+      },
+      "yaxes": [
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        },
+        {
+          "format": "short",
+          "label": null,
+          "logBase": 1,
+          "max": null,
+          "min": null,
+          "show": true
+        }
+      ],
+      "yaxis": {
+        "align": false,
+        "alignLevel": null
+      }
+    }
+  ],
+  "refresh": "",
+  "schemaVersion": 20,
+  "style": "dark",
+  "tags": [],
+  "templating": {
+    "list": []
+  },
+  "time": {
+    "from": "now-3h",
+    "to": "now"
+  },
+  "timepicker": {
+    "refresh_intervals": [
+      "5s",
+      "10s",
+      "30s",
+      "1m",
+      "5m",
+      "15m",
+      "30m",
+      "1h",
+      "2h",
+      "1d"
+    ]
+  },
+  "timezone": "",
+  "title": "Services overview",
+  "uid": "JX3RVEk7k",
+  "version": 6
+}
diff --git a/nixos/modules/monitoring/server/grafana.nix b/nixos/modules/monitoring/server/grafana.nix
index d5724e7188cab5155d7f1976420185388caf5d64..2fd9e7f7c83217afc4943e644f6d3161e56c49f9 100644
--- a/nixos/modules/monitoring/server/grafana.nix
+++ b/nixos/modules/monitoring/server/grafana.nix
@@ -7,6 +7,16 @@
 
 let
   cfg = config.services.private-storage.monitoring.grafana;
+  grafanaAuth = if (cfg.googleOAuthClientID == "") then {
+                  anonymous.enable = true;
+                } else {
+                  google.enable = true;
+                  # Grafana considers it "sign up" to let in a user it has
+                  # never seen before.
+                  google.allowSignUp = true;
+                  google.clientSecretFile = cfg.googleOAuthClientSecretFile;
+                  google.clientId = cfg.googleOAuthClientID;
+                };
 
 in {
   options.services.private-storage.monitoring.grafana = {
@@ -18,19 +28,45 @@ in {
     prometheusUrl = lib.mkOption
     { type = lib.types.str;
       example = lib.literalExample "http://prometheus:9090/";
-      default = "http://prometheus:9090/";
+      default = "http://localhost:9090/";
       description = "The URL of the Prometheus host to access";
     };
     lokiUrl = lib.mkOption
     { type = lib.types.str;
       example = lib.literalExample "http://loki:3100/";
-      default = "http://loki:3100/";
+      default = "http://localhost:3100/";
       description = "The URL of the Loki host to access";
     };
+    letsEncryptAdminEmail = lib.mkOption
+    { type = lib.types.str;
+      description = ''
+        An email address to give to Let's Encrypt as an
+        operational contact for the service's TLS certificate.
+      '';
+    };
+    googleOAuthClientID = lib.mkOption
+    { type = lib.types.str;
+      example = lib.literalExample "grafana-staging-345678";
+      default = "replace-by-your-client-id-or-set-empty-string-for-anonymous-access";
+      description = "The GSuite OAuth2 SSO Client ID.  Empty string turns SSO auth off and anonymous (free for all) access on.";
+    };
+    googleOAuthClientSecretFile = lib.mkOption
+    { type = lib.types.path;
+      example = lib.literalExample "/var/secret/monitoring-gsuite-client-secret";
+      default = /run/keys/grafana-google-sso.secret;
+      description = "The path to the GSuite SSO secret file.";
+    };
+    adminPasswordFile = lib.mkOption
+    { type = lib.types.path;
+      example = lib.literalExample "/var/secret/monitoring-admin-password";
+      default = /run/keys/grafana-admin.password;
+      description = "A file containing the password for the Grafana Admin account.";
+    };
   };
 
   config = {
-    # networking.firewall.allowedTCPPorts = [ 80 443 ];
+    # Port 80 for ACME ssl retrieval only. 443 for nginx -> grafana.
+    networking.firewall.allowedTCPPorts = [ 80 443 ];
 
     services.grafana = {
       enable = true;
@@ -38,42 +74,79 @@ in {
       port = 2342;
       addr = "127.0.0.1";
 
-      # All three are required to forego the user/pass prompt:
-      auth.anonymous.enable = true;
-      auth.anonymous.org_role = "Admin";
-      auth.anonymous.org_name = "Main Org.";
-    };
+      # No phoning home
+      analytics.reporting.enable = false;
 
-    services.grafana.provision = {
-      enable = true;
-      # See https://grafana.com/docs/grafana/latest/administration/provisioning/#datasources
-      datasources = [{
-        name = "Prometheus";
-        type = "prometheus";
-        access = "proxy";
-        url = cfg.prometheusUrl;
-        isDefault = true;
-      } {
-        name = "Loki";
-        type = "loki";
-        access = "proxy";
-        url = cfg.lokiUrl;
-      }];
-      # See https://grafana.com/docs/grafana/latest/administration/provisioning/#dashboards
-      dashboards = [{
-        name = "provisioned";
-        options.path = ./grafana-config;
-      }];
+      # Force Grafana to believe it is reachable via https on the default port
+      # number because that's where the nginx that forwards traffic to it is
+      # listening.  Grafana's own server listens on an internal address that
+      # doesn't matter to anyone except our nginx instance.
+      rootUrl = "https://%(domain)s/";
+
+      extraOptions = {
+        # Defend against DNS rebinding attacks.
+        SERVER_ENFORCE_DOMAIN = "true";
+      };
+
+      auth = {
+        anonymous.org_role = "Admin";
+        anonymous.org_name = "Main Org.";
+      } // grafanaAuth;
+
+      # Give users that come through GSuite SSO the highest possible privileges:
+      users.autoAssignOrgRole = "Editor";
+
+      # Read the admin password from a file in our secrets folder:
+      security.adminPasswordFile = cfg.adminPasswordFile;
+
+      provision = {
+        enable = true;
+        # See https://grafana.com/docs/grafana/latest/administration/provisioning/#datasources
+        datasources = [{
+          name = "Prometheus";
+          type = "prometheus";
+          access = "proxy";
+          url = cfg.prometheusUrl;
+          isDefault = true;
+        } {
+          name = "Loki";
+          type = "loki";
+          access = "proxy";
+          url = cfg.lokiUrl;
+        }];
+        # See https://grafana.com/docs/grafana/latest/administration/provisioning/#dashboards
+        dashboards = [{
+          name = "provisioned";
+          options.path = ./grafana-config;
+        }];
+      };
     };
 
     # nginx reverse proxy
-    services.nginx.enable = true;
-    services.nginx.virtualHosts.${config.services.grafana.domain} = {
-      locations."/" = {
-        proxyPass = "http://127.0.0.1:${toString config.services.grafana.port}";
-        proxyWebsockets = true;
+    security.acme.email = cfg.letsEncryptAdminEmail;
+    security.acme.acceptTerms = true;
+    services.nginx = {
+      enable = true;
+
+      recommendedGzipSettings = true;
+      recommendedOptimisation = true;
+      recommendedProxySettings = true;
+      recommendedTlsSettings = true;
+
+      # Only allow PFS-enabled ciphers with AES256:
+      sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL";
+
+      virtualHosts.${config.services.grafana.domain} = {
+        enableACME = true;
+        forceSSL = true;
+        locations."/" = {
+          proxyPass = "http://127.0.0.1:${toString config.services.grafana.port}";
+          proxyWebsockets = true;
+        };
       };
     };
+
+    # Let Grafana read from keys, if necessary.
+    users.users.grafana.extraGroups = [ "keys" ];
   };
 }
-
diff --git a/nixos/modules/monitoring/server/prometheus.nix b/nixos/modules/monitoring/server/prometheus.nix
index 36c2ba6402559771dff8771b1369842e21f7ff7f..c92261ccad2ebdd8dd34dda9027e66962b345be9 100644
--- a/nixos/modules/monitoring/server/prometheus.nix
+++ b/nixos/modules/monitoring/server/prometheus.nix
@@ -26,6 +26,11 @@ in {
       example = lib.literalExample "[ node1 node2 ]";
       description = "List of nodes (hostnames or IPs) to scrape.";
     };
+    paymentExporterTargets = lib.mkOption {
+      type = with lib.types; listOf str;
+      example = lib.literalExample "[ node1 node2 ]";
+      description = "List of nodes (hostnames or IPs) to scrape.";
+    };
   };
 
   config = rec {
@@ -49,6 +54,15 @@ in {
           }];
           relabel_configs = [ dropPortNumber ];
         }
+        {
+          job_name = "payment-exporters";
+          scheme = "https";
+          tls_config.insecure_skip_verify = true;
+          static_configs = [{
+            targets = cfg.paymentExporterTargets;
+          }];
+          relabel_configs = [ dropPortNumber ];
+        }
       ];
     };
   };
diff --git a/nixos/modules/monitoring/vpn/client.nix b/nixos/modules/monitoring/vpn/client.nix
index dbd50b82ef5b09495e332e6fbb7ac5676f5ac322..ed1933e34d715fba0933f32d606e989b4d1ed4ec 100644
--- a/nixos/modules/monitoring/vpn/client.nix
+++ b/nixos/modules/monitoring/vpn/client.nix
@@ -48,7 +48,7 @@ in {
     };
     endpointPublicKeyFile = lib.mkOption {
       type = lib.types.path;
-      example = lib.literalExample ../PrivateStorageSecrets/monitoringvpn/server.pub;
+      example = lib.literalExample ./monitoringvpn/server.pub;
       description = ''
         File with base64 public key generated by <command>cat private.key | wg pubkey > pubkey.pub</command>.
       '';
@@ -71,4 +71,3 @@ in {
     };
   };
 }
-
diff --git a/nixos/modules/monitoring/vpn/server.nix b/nixos/modules/monitoring/vpn/server.nix
index 2374ddc8657fb299fb83155cbabe328cd54c1aaf..3c41e0209bb7fe18f1a81a44ab509c8442372bbf 100644
--- a/nixos/modules/monitoring/vpn/server.nix
+++ b/nixos/modules/monitoring/vpn/server.nix
@@ -51,7 +51,7 @@ in {
     };
     pubKeysPath = lib.mkOption {
       type = lib.types.path;
-      example = lib.literalExample ../PrivateStorageSecrets/monitoringvpn;
+      example = lib.literalExample ./monitoringvpn;
       description = ''
         The path to the directory that holds the public keys.
       '';
@@ -69,4 +69,3 @@ in {
     };
   };
 }
-
diff --git a/nixos/modules/private-storage.nix b/nixos/modules/private-storage.nix
index 52720e618973c57b41aade87585c7ab758abff22..fa5fea837c544e66ae8811a2e3c468a67a18759e 100644
--- a/nixos/modules/private-storage.nix
+++ b/nixos/modules/private-storage.nix
@@ -8,6 +8,9 @@ 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";
@@ -18,6 +21,12 @@ let
   # NOTE: This is promised by the service privacy policy.  It *may not* be
   # raised without following the process for updating the privacy policy.
   max-incident-age = "29d";
+
+  fqdn = "${
+    assert config.networking.hostName != null; config.networking.hostName
+    }.${
+    assert config.networking.domain != null; config.networking.domain
+    }";
 in
 {
   imports = [
@@ -31,19 +40,20 @@ in
   options =
   { services.private-storage.enable = lib.mkEnableOption "private storage service";
     services.private-storage.tahoe.package = lib.mkOption
-    { default = pkgs.privatestorage;
+    { default = ourpkgs.privatestorage;
       type = lib.types.package;
       example = lib.literalExample "pkgs.tahoelafs";
       description = ''
         The package to use for the Tahoe-LAFS daemon.
       '';
     };
-    services.private-storage.publicIPv4 = lib.mkOption
-    { default = "127.0.0.1";
+    services.private-storage.publicAddress = lib.mkOption
+    { default = "${fqdn}";
       type = lib.types.str;
-      example = lib.literalExample "192.0.2.0";
+      example = lib.literalExample "storage.example.invalid";
       description = ''
-        An IPv4 address to advertise for this storage service.
+        A publicly-visible address to use in Tahoe-LAFS advertisements for
+        this storage service.
       '';
     };
     services.private-storage.introducerFURL = lib.mkOption
@@ -63,7 +73,7 @@ in
       '';
     };
     services.private-storage.issuerRootURL = lib.mkOption
-    { default = "https://issuer.privatestorage.io/";
+    { default = "https://issuer.${config.networking.domain}/";
       type = lib.types.str;
       example = lib.literalExample "https://example.invalid/";
       description = ''
@@ -122,7 +132,7 @@ in
           # First, in the syntax which it uses to listen.
           "tub.port" = "tcp:${toString cfg.publicStoragePort}";
           # Second, in the syntax it advertises to in the fURL.
-          "tub.location" = "tcp:${cfg.publicIPv4}:${toString cfg.publicStoragePort}";
+          "tub.location" = "tcp:${cfg.publicAddress}:${toString cfg.publicStoragePort}";
         };
         storage =
         { enabled = true;
@@ -153,7 +163,7 @@ in
 
     environment.systemPackages = [
       # Provide a useful tool for reporting about shares.
-      pkgs.leasereport
+      ourpkgs.leasereport
     ];
 
   };
diff --git a/nixos/modules/ssh.nix b/nixos/modules/ssh.nix
index 667bdd26215b4e0978781244741dd4c5313cefbd..3e90528322c153d6b96679af5d914c4e753b49bf 100644
--- a/nixos/modules/ssh.nix
+++ b/nixos/modules/ssh.nix
@@ -40,12 +40,6 @@
         # Agent forwarding is fraught.  It can be used by an attacker to
         # leverage one compromised system into more.  Discourage its use.
         AllowAgentForwarding no
-
-        # Only allow authentication as one of the configured users, not random
-        # other (often system-managed) users.  Possibly this is also
-        # superfluous!  NixOS system users have nologin as their shell ... so they
-        # cannot log in anyway.
-        AllowUsers ${builtins.concatStringsSep " " (builtins.attrNames cfg.sshUsers)}
       '';
     };
 
diff --git a/nixos/modules/tests/private-storage.nix b/nixos/modules/tests/private-storage.nix
index cbf4c5937ca6780ce9e931d6ceec91c29643fbc3..353abc891fafd1cc988e47a1befa530a012470dc 100644
--- a/nixos/modules/tests/private-storage.nix
+++ b/nixos/modules/tests/private-storage.nix
@@ -115,7 +115,7 @@ in {
         ];
         services.private-storage = {
           enable = true;
-          publicIPv4 = "storage";
+          publicAddress = "storage";
           introducerFURL = introducerFURL;
           issuerRootURL = issuerURL;
           inherit ristrettoSigningKeyPath;
diff --git a/nixos/modules/update-deployment b/nixos/modules/update-deployment
new file mode 100755
index 0000000000000000000000000000000000000000..d8d32ff64eb52123be448ed598d00ab2bc1850da
--- /dev/null
+++ b/nixos/modules/update-deployment
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+
+set -euxo pipefail
+
+# Accept the name of the grid this system is part of as a parameter.  This
+# lets us pick the correct morph grid source file later on.
+GRIDNAME=$1
+shift
+
+# Determine the right branch name to use for the particular grid we've been
+# told we belong to.  The grid name is a parameter to this script we can
+# re-use it across all of our grids.  See deployment.nix for the ssh
+# configuration that controls what value is actually passed when an update is
+# triggered.
+case "${GRIDNAME}" in
+    "local")
+	BRANCH="develop"
+	;;
+
+    "testing")
+	BRANCH="staging"
+	;;
+
+    "production")
+	BRANCH="production"
+	;;
+
+    *)
+	echo "Unknown grid: ${GRIDNAME}"
+	exit 1
+esac
+
+# This is where we will maintain a checkout of PrivateStorageio for morph to
+# use to compute the desired state.
+CHECKOUT="${HOME}/PrivateStorageio"
+
+# This is the address of the git remote where we can get the latest
+# PrivateStorageio.
+REPO="https://whetstone.privatestorage.io/privatestorage/PrivateStorageio.git"
+
+if [ -e "${CHECKOUT}" ]; then
+    # It exists already so just make sure it contains the latest changes from
+    # the canonical repository.
+    git -C "${CHECKOUT}" fetch
+else
+    # It doesn't exist so clone it.
+    git clone "${REPO}" "${CHECKOUT}"
+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 [ "${GRIDNAME}" = "local" ]; then
+    KEY="$(cat /etc/ssh/authorized_keys.d/vagrant)"
+    sed -i "s_undefined_\"${KEY}\"_" "${CHECKOUT}"/morph/grid/${GRIDNAME}/public-keys/users.nix
+fi
+
+# Compute a log message explaining what we're doing.
+LOG_MESSAGE="$(date --iso-8601=seconds) $(git -C "${CHECKOUT}" rev-parse HEAD)"
+
+# Make sure we use the right credentials and ask for the right account when
+# morph makes the connection.  morph's deployment target for each host is the
+# full domain name (even though the host is only named with the unqualified
+# hostname in the morph grid definition) so compute an ssh config section that
+# matches that.  Regardless, point this effort at localhost because we *know*
+# it's just us we want to update.
+cat > ~/.ssh/config <<EOF
+Host $(hostname).$(domainname)
+  HostName 127.0.0.1
+  IdentityFile ~/.ssh/morph_key
+  User root
+EOF
+
+# Make sure known_hosts has the host key in it.
+ssh -o StrictHostKeyChecking=no "$(hostname).$(domainname)" ":"
+
+# Set nixpkgs to our preferred version for the morph build.  Annoyingly, we
+# can't just use nixpkgs-2105.nix as our nixpkgs because some code (in morph,
+# at least) wants <nixpkgs> to be a fully-resolved path to a nixpkgs tree.
+# For example, morph evaluated `import <nixpkgs/lib>` which would turn into
+# something like `import nixpkgs-2105.nix/lib` which is nonsense.
+#
+# So instead, import our nixpkgs which forces it to be instantiated in the
+# store, then ask for its path, then set NIX_PATH to that.
+export NIX_PATH="nixpkgs=$(nix eval "(import ${CHECKOUT}/nixpkgs-2105.nix { }).path")"
+
+# Attempt to update just this host.  Choose the morph grid definition matching
+# the grid we belong to and limit the morph deployment update to the host
+# matching our name.  morph uses just the bare hostname without the domain
+# part.
+if morph deploy "${CHECKOUT}"/morph/grid/"${GRIDNAME}"/grid.nix switch --on "$(hostname)"; then
+    # The deployment succeeded.  Record success along with context we pre-computed.
+    echo "${LOG_MESSAGE} OK" >> ${HOME}/updates.txt
+    exit 0
+else
+    # Oops.  Not so fortunate.  Record failure.
+    echo "${LOG_MESSAGE} FAIL" >> ${HOME}/updates.txt
+    exit 1
+fi
diff --git a/nixos/system-tests.nix b/nixos/system-tests.nix
index b2556d4692ee0c3eff96554fa7c13df59ec94507..5f51d01dd57267b75b3742c76c03c1393676d426 100644
--- a/nixos/system-tests.nix
+++ b/nixos/system-tests.nix
@@ -1,6 +1,6 @@
 # The overall system test suite for PrivateStorageio NixOS configuration.
 let
-  pkgs = import <nixpkgs> { };
+  pkgs = import ../nixpkgs-ps.nix { };
 in {
   private-storage = pkgs.nixosTest ./modules/tests/private-storage.nix;
   tahoe = pkgs.nixosTest ./modules/tests/tahoe.nix;
diff --git a/nixpkgs-2105.json b/nixpkgs-2105.json
index d441d00995a78a20cc8677a85ced2a675a9502ae..76950db1870cb62d68e655f5ca4be90f3fcbf6be 100644
--- a/nixpkgs-2105.json
+++ b/nixpkgs-2105.json
@@ -1,4 +1,4 @@
-{ "name": "stable2105"
+{ "name": "release2105"
 , "url": "https://releases.nixos.org/nixos/21.05/nixos-21.05.804.5de44c15758/nixexprs.tar.xz"
 , "sha256": "002zvc16hyrbs0icx1qj255c9dqjpdxx4bhhfjndlj3kwn40by0m"
 }
diff --git a/nixpkgs-2105.nix b/nixpkgs-2105.nix
new file mode 100644
index 0000000000000000000000000000000000000000..536d913b89ba6a57d8d683381ea1c8f40e026b4f
--- /dev/null
+++ b/nixpkgs-2105.nix
@@ -0,0 +1 @@
+import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs-2105.json)))
diff --git a/nixpkgs-ps.json b/nixpkgs-ps.json
new file mode 100644
index 0000000000000000000000000000000000000000..97449535de722fdaf89d8551c6bd6d124e3818c7
--- /dev/null
+++ b/nixpkgs-ps.json
@@ -0,0 +1,4 @@
+{ "name": "nixpkgs"
+, "url": "https://github.com/PrivateStorageio/nixpkgs/archive/a5cbaadd9676e8c568061e92bbf5ad6a5d884ded.tar.gz"
+, "sha256": "0q5zknsp0qb25ag9zr9bw1ap7pb3f76bxsw81ahxkzj4z5dw6k2f"
+}
diff --git a/nixpkgs-ps.nix b/nixpkgs-ps.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d98a53843052fda824f4ed7e34db50524df36ce2
--- /dev/null
+++ b/nixpkgs-ps.nix
@@ -0,0 +1 @@
+import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs-ps.json)))
diff --git a/nixpkgs.json b/nixpkgs.json
deleted file mode 100644
index 33b343ef3498ae226218f59be257e808e9a88c7e..0000000000000000000000000000000000000000
--- a/nixpkgs.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{ "name": "nixpkgs"
-, "url": "https://github.com/PrivateStorageio/nixpkgs/archive/8c7a61c658e32eaccf666e5fe818a996c36a988f.tar.gz"
-, "sha256": "1ln0a8c20qykm57wl901lixny1fcfmzgbavd7pbjk6jbnfij59bl"
-}
diff --git a/shell.nix b/shell.nix
index 2c1c5123da656d34fafe0883b50ef49c578c6c8b..f3d2750edd68e4861e6d0700e0259c1ce86f817a 100644
--- a/shell.nix
+++ b/shell.nix
@@ -1,13 +1,12 @@
 let
-  nixpkgs = import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs.json))) { };
-  stable2105 = import (builtins.fetchTarball (builtins.fromJSON (builtins.readFile ./nixpkgs-2105.json))) { };
+  release2105 = import ./nixpkgs-2105.nix { };
 in
-{ pkgs ? nixpkgs }:
+{ pkgs ? release2105 }:
 pkgs.mkShell {
-  NIX_PATH = "nixpkgs=${nixpkgs.path}";
+  NIX_PATH = "nixpkgs=${pkgs.path}";
   buildInputs = [
     pkgs.morph
-    stable2105.vagrant
+    pkgs.vagrant
     pkgs.jp
   ];
 }
diff --git a/tools/create-vpn-keys.sh b/tools/create-vpn-keys.sh
index e092a8ced698bd3a3bb2d4acc3ca07a3a8e6032d..c81225ec340db38d551e9d0c6c13d7bd44e21a4d 100755
--- a/tools/create-vpn-keys.sh
+++ b/tools/create-vpn-keys.sh
@@ -4,7 +4,7 @@
 # Parameters:
 #   file: path to grid.nix of morph deployment
 #
-# Output: Key files for all monitoring VPN hosts in secrets/monitoringvpn
+# Output: Key files for all monitoring VPN hosts in {private,public}-keys/monitoringvpn
 #         relative to the grid.nix
 #
 # The server key will also be symlinked to server.{key,pub}.
@@ -19,7 +19,8 @@ if [[ $# -ne 1 ]]; then
 fi
 
 SRC=$(dirname $0)
-VPN_SECRETS=$(dirname $1)/secrets/monitoringvpn
+VPN_SECRETS=$(dirname $1)/private-keys/monitoringvpn
+VPN_PUBLIC=$(dirname $1)/public-keys/monitoringvpn
 
 CONFIG=$(nix-instantiate --strict --json --eval "${SRC}"/get-vpn-config.nix --arg pathToGrid "${1}")
 
@@ -27,14 +28,15 @@ MONITORING_IPS=$(echo $CONFIG | jp --unquoted "join(' ', clientIPs)")
 VPNSERVER_IP=$(echo $CONFIG | jp --unquoted "serverIP")
 
 mkdir -p "${VPN_SECRETS}"
+mkdir -p "${VPN_PUBLIC}"
 
 for i in $MONITORING_IPS $VPNSERVER_IP; do
-  wg genkey | tee "${VPN_SECRETS}"/${i}.key | wg pubkey > "${VPN_SECRETS}"/${i}.pub
+  wg genkey | tee "${VPN_SECRETS}"/${i}.key | wg pubkey > "${VPN_PUBLIC}"/${i}.pub
 done
 
 wg genpsk > "${VPN_SECRETS}"/preshared.key
 
 ln -fs $VPNSERVER_IP.key "${VPN_SECRETS}"/server.key
-ln -fs $VPNSERVER_IP.pub "${VPN_SECRETS}"/server.pub
+ln -fs $VPNSERVER_IP.pub "${VPN_PUBLIC}"/server.pub
 
 # EOF