diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 7a8b4e79d3675947cb6f24351fa4c4706313af27..205bedaf0b86d05a8f87ffcf368d6024d2fd57cb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,7 +1,19 @@
+default:
+  # Guide the choice of an appropriate runner for all these jobs.
+  # https://docs.gitlab.com/ee/ci/runners/#runner-runs-only-tagged-jobs
+  tags:
+    - "nixos"
+    - "shell"
+
 docs:
   stage: "build"
   script:
     - "nix-shell --run 'nix-build docs.nix'"
+    - "cp --recursive --no-preserve=mode result/docs/. docs/build/"
+  artifacts:
+    paths:
+      - "docs/build/"
+    expose_as: "documentation"
 
 unit-tests:
   stage: "test"
diff --git a/README.rst b/README.rst
index d9e3e310ec775cbaec28019360befbcb535db9f6..d3d9f088db4f8b976f3f55852715762280bb93c0 100644
--- a/README.rst
+++ b/README.rst
@@ -13,7 +13,6 @@ Documentation
 
 There is documentation for:
 
-* Users: ``docs/user/README.rst``
 * Operators/Admins: ``docs/ops/README.rst``
 * Developers: ``docs/dev/README.rst``
 
diff --git a/docs/source/dev/README.rst b/docs/source/dev/README.rst
index e1a793f3ac70a320ed52c20e0b2128696455a7c9..904e8b3be07bdcc1473a3c1fe22afe8ffb0e15a2 100644
--- a/docs/source/dev/README.rst
+++ b/docs/source/dev/README.rst
@@ -45,5 +45,11 @@ Architecture overview
 .. graphviz:: architecture-overview.dot
 
 
+.. include::
+      ../../../morph/grid/local/README.rst
+
+
+
+
 .. _Nix: https://nixos.org/nix
 
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 6ae149f7ce9845d877c953f3e095edba02455765..eb6b59ec783b8aa6dfb3227ca51fe47e5f1b6bc7 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -11,7 +11,6 @@ Howdy!  We separated the documentation into parts addressing different audiences
 .. toctree::
    :maxdepth: 2
 
-   Users <user/README>
    Administrators <ops/README>
    Developers <dev/README>
 
diff --git a/docs/source/user/README.rst b/docs/source/user/README.rst
deleted file mode 100644
index 2a6e4b912cb6dfafb250149eb8aab095c34ce023..0000000000000000000000000000000000000000
--- a/docs/source/user/README.rst
+++ /dev/null
@@ -1,5 +0,0 @@
-User documentation
-==================
-
-This will contain the user documentation for this project.
-
diff --git a/morph/README.rst b/morph/README.rst
index d4a89a373a1bef767ad26859d495f1528a4fb7ca..1f48d5e0ad30b1f44a1a2cee8c5aa6f0669bcc75 100644
--- a/morph/README.rst
+++ b/morph/README.rst
@@ -42,6 +42,19 @@ grid
 
 Specific grid definitions live in subdirectories beneath this directory.
 
+secrets
+~~~~~~~
+
+This must be created and populated before the grid can be built or deployed.
+
+This directory contains all of the secrets necessary to deploy the grid.
+Secrets beneath this directory are referenced by ``config.json`` and ``grid.nix``
+(and possibly elsewhere).
+Some of the paths are configurable and some are just convention.
+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.
+
 config.json
 ~~~~~~~~~~~
 
diff --git a/morph/grid/local/README.rst b/morph/grid/local/README.rst
new file mode 100644
index 0000000000000000000000000000000000000000..8887c297bc0e2aa300e27f86ff7a0f08535028a5
--- /dev/null
+++ b/morph/grid/local/README.rst
@@ -0,0 +1,111 @@
+Set up and use a network of local development VMs
+-------------------------------------------------
+
+... using `Vagrant <https://www.vagrantup.com/>`_ to manage VirtualBox VMs [#]_.
+To get started, first install Vagrant and make sure it works.
+One possible way to do it in NixOS:
+
+1. Install Vagrant, by adding the packages:
+
+  - ``vagrant`` (orchestrating virtual machines on the command line)
+     - Only use when version >= 2.2.16 has become available.  Else see below.
+  - Optional: ``packer`` (for creating your own VM images)
+
+2. Add configuration to install and enable VirtualBox:
+
+  - ``virtualisation.virtualbox.host.enable = true;``
+
+3. Add your user to the ``vboxusers`` group, for example:
+
+  - ``users.extraGroups.vboxusers.members = [ "flo" "jp" ];``
+
+
+.. [#] The author of this documentation wasted a lot of time trying to get Vagrant to work with KVM/libvirt.  Issues with networking that looked like guest misconfigurations vanished after changing to the better-tested combination of Vagrant and VirtualBox.
+
+
+Pre-Vagrant 2.2.16: Get Vagrant with the required fixes for NixOS guests
+````````````````````````````````````````````````````````````````````````
+
+The Vagrant nixos-guest template `received a critical update on 2021-03-08 <https://github.com/hashicorp/vagrant/commit/990d94ed9d0b3092e855bc1bb9deeeb7aa7792cf>`_ which came out with Vagrant version 2.2.16.
+
+If you run an older Nixpkgs, retrieve and use the latest Vagrant development version like so::
+
+  NIX_PATH=nixpkgs=https://github.com/NixOS/nixpkgs/archive/refs/heads/master.tar.gz nix-shell -p vagrant
+
+
+Generating and deploying keys
+`````````````````````````````
+
+``config.json`` has the paths for the Ristretto and the Stripe secret key files.
+
+Here is a Ristretto key you can use, randomly generated just now::
+
+  SILOWzbnkBjxC1hGde9d5Q3Ir/4yLosCLEnEQGAxEQE=
+
+Generate your own like this::
+
+  [flo@la:~/PrivateStorageio]$ nix-shell
+  [nix-shell:~/PrivateStorageio]$ nix-shell -p zkapissuer.components.exes.PaymentServer-generate-key
+  [nix-shell:~/PrivateStorageio]$ PaymentServer-generate-key
+  SILOWzbnkBjxC1hGde9d5Q3Ir/4yLosCLEnEQGAxEQE=
+
+Make sure you write it into the key file `without any leading or trailing white space, also without newlines <https://github.com/LeastAuthority/python-challenge-bypass-ristretto/issues/37>`_.
+For example::
+
+  echo -n "SILOWzbnkBjxC1hGde9d5Q3Ir/4yLosCLEnEQGAxEQE=" > ristretto.signing-key
+
+For the Stripe key any random bytes with a little light formatting "work" - at least to make our software happy - but if you want to be able to interact with Stripe and have payments (even pretend payments) move all the way through the system you should get a Stripe account and generate a key w/ them.
+Lauri can get you added to our "dev" Stripe account, too, though I forget how important that is for ad hoc dev/testing.
+
+I think this will work for generating random Stripe secret keys (that our software will load, I think, but Stripe will reject)::
+
+  >>> import base64, os
+  >>> print((b"sk_test_" + base64.b64encode(os.urandom(25)).strip(b"=")).decode("ascii"))
+  sk_test_Dr+XLVjkC0oO3Zw8Ws0yWtDLqR1sM+/fmw
+
+Public keys are the same but "pk_test" instead of "sk_test" ("test" is for "test mode" key that can only process pretend txns; for real txns there are keys with "live" embedded).
+
+The ZKAPIssuer.service needs a working TLS certificate and expects it in the certbot directory for the domain you configured, in my case::
+
+  openssl req -x509 -newkey rsa:4096 -nodes -keyout privkey.pem -out cert.pem -days 3650
+  touch chain.pem
+
+Move the three .pem files into the payment's server ``/var/lib/letsencrypt/live/payments.localdev/`` directory and issue a ``sudo systemctl restart zkapissuer.service``.
+
+
+Use the local development environment
+`````````````````````````````````````
+
+1. Build and start the VMs::
+
+    VAGRANT_DEFAULT_PROVIDER=virtualbox vagrant up
+
+2. Then, once::
+
+    vagrant ssh-config > ./vagrant-ssh-config
+
+3. Edit the output: Add the IPs from ``grid.nix`` to the ``vagrant-ssh-config`` **Host match blocks** so the config reads like::
+
+    Host payments1 192.168.67.21
+      HostName 192.168.67.21
+      User vagrant
+      Port 22
+      [...]
+
+4.  Then, make morph use this ssh config either - with newer morph [#]_ - by pointing it to it::
+
+     export SSH_CONFIG_FILE=./vagrant-ssh-config
+
+  Or, with older morph, adding the config to your user's ``~/.ssh/config`` file.
+
+  .. [#]  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>`_.
+
+5. Then, build and deploy our software to the Vagrant VMs::
+
+    morph build grid.nix
+    morph push grid.nix
+    morph upload-secrets grid.nix
+    morph deploy grid.nix switch
+
+  You will now be able to log in with the users and keys you set in your ``localdev-users.nix`` file.
+
diff --git a/morph/grid/local/Vagrantfile b/morph/grid/local/Vagrantfile
new file mode 100644
index 0000000000000000000000000000000000000000..81dec21ee701d761b4347f11df43398d78d4b528
--- /dev/null
+++ b/morph/grid/local/Vagrantfile
@@ -0,0 +1,45 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+# This Vagrantfile worked for Florian Sesser using Vagrant 2.2.16dev and
+# the VirtualBox Hypervisor. Earlier Vagrant and LibVirt did not work.
+
+Vagrant.configure("2") do |config|
+  # For a complete reference, please see the online documentation at
+  # https://docs.vagrantup.com.
+
+  config.vm.define "payments1" do |config|
+    config.vm.hostname = "payments1"
+    config.vm.box = "esselius/nixos"
+    config.vm.box_version = "20.09"
+    config.vm.box_check_update = false
+    config.vm.network "private_network", ip: "192.168.67.21"
+  end
+
+  config.vm.define "storage1" do |config|
+    config.vm.hostname = "storage1"
+    config.vm.box = "esselius/nixos"
+    config.vm.box_version = "20.09"
+    config.vm.box_check_update = false
+    config.vm.network "private_network", ip: "192.168.67.22"
+  end
+
+  config.vm.define "storage2" do |config|
+    config.vm.hostname = "storage2"
+    config.vm.box = "esselius/nixos"
+    config.vm.box_version = "20.09"
+    config.vm.box_check_update = false
+    config.vm.network "private_network", ip: "192.168.67.23"
+  end
+
+  # To make the VMs assign the static IPs to the network interfaces we need a rebuild:
+  config.vm.provision "shell", inline: "echo '{nix.trustedUsers = [ \"@wheel\" \"root\" \"vagrant\" ];}' > /etc/nixos/custom-configuration.nix"
+  config.vm.provision "shell", inline: "nixos-rebuild switch"
+
+  config.trigger.after :up do |trigger|
+    trigger.info = "Hostname and IP address this host actually uses:"
+    trigger.run_remote = {inline: "echo `hostname` `ifconfig | egrep -o '192.168.67.[0-9]* '`"}
+  end
+
+end
+
diff --git a/morph/grid/local/config.json b/morph/grid/local/config.json
new file mode 100644
index 0000000000000000000000000000000000000000..e970c3c6c29498497e5fad19d04ed56a56471ec1
--- /dev/null
+++ b/morph/grid/local/config.json
@@ -0,0 +1,10 @@
+{ "publicStoragePort": 8898
+, "ristrettoSigningKeyPath": "../../PrivateStorageSecrets/ristretto.signing-key"
+, "stripeSecretKeyPath": "../../PrivateStorageSecrets/privatestorageio-testing-stripe.secret"
+, "passValue": 1000000
+, "issuerDomains": ["payments.localdev"]
+, "letsEncryptAdminEmail": "florian@privatestorage.io"
+, "allowedChargeOrigins": [
+    "http://localhost:5000"
+  ]
+}
diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix
new file mode 100644
index 0000000000000000000000000000000000000000..fdda12e4537cd281a8d0768f0a95aa0608eca36b
--- /dev/null
+++ b/morph/grid/local/grid.nix
@@ -0,0 +1,32 @@
+# 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 ../../../../PrivateStorageSecrets/localdev-users.nix;
+  in {
+    "payments1" = import ../../lib/make-issuer.nix (rec {
+      publicIPv4 = "192.168.67.21";
+      inherit sshUsers;
+      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
+      stateVersion = "19.03";
+    } // cfg);
+
+    "storage1" = import ../../lib/make-testing.nix (rec {
+      publicIPv4 = "192.168.67.22";
+      inherit sshUsers;
+      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
+      stateVersion = "19.09";
+    } // cfg);
+
+    "storage2" = import ../../lib/make-testing.nix (rec {
+      publicIPv4 = "192.168.67.23";
+      inherit sshUsers;
+      hardware = import ./virtual-hardware.nix ({ inherit publicIPv4; });
+      stateVersion = "19.09";
+    } // cfg);
+  };
+}
diff --git a/morph/grid/local/vagrant-guest.nix b/morph/grid/local/vagrant-guest.nix
new file mode 100644
index 0000000000000000000000000000000000000000..8505b2f34dba067bf2c39a1645145bc626d30cf8
--- /dev/null
+++ b/morph/grid/local/vagrant-guest.nix
@@ -0,0 +1,86 @@
+# Minimal configuration that vagrant depends on
+
+{ config, pkgs, ... }:
+let
+  # Vagrant uses an insecure shared private key by default, but we
+  # don't use the authorizedKeys attribute under users because it should be
+  # removed on first boot and replaced with a random one. This script sets
+  # the correct permissions and installs the temporary key if no
+  # ~/.ssh/authorized_keys exists.
+  install-vagrant-ssh-key = pkgs.writeScriptBin "install-vagrant-ssh-key" ''
+    #!${pkgs.runtimeShell}
+    if [ ! -e ~/.ssh/authorized_keys ]; then
+      mkdir -m 0700 -p ~/.ssh
+      echo "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA6NF8iallvQVp22WDkTkyrtvp9eWW6A8YVr+kz4TjGYe7gHzIw+niNltGEFHzD8+v1I2YJ6oXevct1YeS0o9HZyN1Q9qgCgzUFtdOKLv6IedplqoPkcmF0aYet2PkEDo3MlTBckFXPITAMzF8dJSIFo9D8HfdOV0IAdx4O7PtixWKn5y2hMNG0zQPyUecp4pzC6kivAIhyfHilFR61RGL+GPXQ2MWZWFYbAGjyiYJnAmCP3NOTd0jMZEnDkbUvxhMmBYSdETk1rRgm+R4LOzFUGaHqHDLKLX+FIPKcF96hrucXzcWyLbIbEgE98OHlnVYCzRdK8jlqm8tehUc9c9WhQ== vagrant insecure public key" >> ~/.ssh/authorized_keys
+      chmod 0600 ~/.ssh/authorized_keys
+    fi
+  '';
+in
+{
+  # Services to enable:
+
+  # Enable the OpenSSH daemon.
+  services.openssh.enable = true;
+
+  # Enable DBus
+  services.dbus.enable    = true;
+
+  # Replace ntpd by timesyncd
+  services.timesyncd.enable = true;
+
+  # Packages for Vagrant
+  environment.systemPackages = with pkgs; [
+    findutils
+    gnumake
+    iputils
+    jq
+    nettools
+    netcat
+    nfs-utils
+    rsync
+  ];
+
+  users.users.root = { password = "vagrant"; };
+
+  # Creates a "vagrant" group & user with password-less sudo access
+  users.groups.vagrant = {
+    name = "vagrant";
+    members = [ "vagrant" ];
+  };
+  users.extraUsers.vagrant = {
+    isNormalUser    = true;
+    createHome      = true;
+    group           = "vagrant";
+    extraGroups     = [ "users" "wheel" ];
+    password        = "vagrant";
+    home            = "/home/vagrant";
+    useDefaultShell = true;
+  };
+
+  systemd.services.install-vagrant-ssh-key = {
+    description = "Vagrant SSH key install (if needed)";
+    after = [ "fs.target" ];
+    wants = [ "fs.target" ];
+    wantedBy = [ "multi-user.target" ];
+    serviceConfig = {
+      ExecStart = "${install-vagrant-ssh-key}/bin/install-vagrant-ssh-key";
+      User = "vagrant";
+      # So it won't be (needlessly) restarted:
+      RemainAfterExit = true;
+    };
+  };
+
+  security.sudo.wheelNeedsPassword = false;
+
+  security.sudo.extraConfig =
+    ''
+      Defaults:root,%wheel env_keep+=LOCALE_ARCHIVE
+      Defaults:root,%wheel env_keep+=NIX_PATH
+      Defaults:root,%wheel env_keep+=TERMINFO_DIRS
+      Defaults env_keep+=SSH_AUTH_SOCK
+      Defaults lecture = never
+      root   ALL=(ALL) SETENV: ALL
+      %wheel ALL=(ALL) NOPASSWD: ALL, SETENV: ALL
+    '';
+}
+
diff --git a/morph/grid/local/virtual-hardware.nix b/morph/grid/local/virtual-hardware.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d5e9067bd5f3b3ca2ea1bb46746253fa39b25cf6
--- /dev/null
+++ b/morph/grid/local/virtual-hardware.nix
@@ -0,0 +1,36 @@
+{ publicIPv4, ... }:
+{
+  imports = [ ./vagrant-guest.nix ];
+
+  virtualisation.virtualbox.guest.enable = true;
+
+  # Use the GRUB 2 boot loader.
+  boot.loader.grub.enable = true;
+  boot.loader.grub.version = 2;
+  boot.loader.grub.device = "/dev/sda";
+
+  boot.initrd.availableKernelModules = [ "ata_piix" "sd_mod" "sr_mod" ];
+  boot.initrd.kernelModules = [ ];
+  boot.kernelModules = [ ];
+  boot.extraModulePackages = [ ];
+
+  # remove the fsck that runs at startup. It will always fail to run, stopping
+  # your boot until you press *.
+  boot.initrd.checkJournalingFS = false;
+
+  networking.interfaces.enp0s8.ipv4.addresses = [{
+    address = publicIPv4;
+    prefixLength = 24;
+  }];
+
+  fileSystems."/storage" = { fsType = "tmpfs"; };
+  fileSystems."/" =
+    { device = "/dev/sda1";
+      fsType = "ext4";
+    };
+  swapDevices = [ ];
+
+  # We want to push packages with morph without having to sign them
+  nix.trustedUsers = [ "@wheel" "root" "vagrant" ];
+}
+
diff --git a/morph/grid/production/.gitignore b/morph/grid/production/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..db2fc0de62d01d6d7eec83f8f3e8c3b13b20392a
--- /dev/null
+++ b/morph/grid/production/.gitignore
@@ -0,0 +1 @@
+secrets
diff --git a/morph/grid/production/config.json b/morph/grid/production/config.json
index ec60acc70dcdc90409b84e0b19ce9c2cb3d27cfa..e71cb8b4b5f999e3059f0669c2bc3f92f29242a6 100644
--- a/morph/grid/production/config.json
+++ b/morph/grid/production/config.json
@@ -1,11 +1,16 @@
 { "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "../../PrivateStorageSecrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "../../PrivateStorageSecrets/stripe.secret"
+, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
+, "stripeSecretKeyPath": "./secrets/stripe.secret"
 , "passValue": 1000000
-, "issuerDomain": "payments.privatestorage.io"
+, "issuerDomains": [
+    "payments.privatestorage.io"
+  , "payments.private.storage"
+  ]
 , "letsEncryptAdminEmail": "jean-paul@privatestorage.io"
 , "allowedChargeOrigins": [
     "https://privatestorage.io"
   , "https://www.privatestorage.io"
+  , "https://private.storage"
+  , "https://www.private.storage"
   ]
 }
diff --git a/morph/grid/production/grid.nix b/morph/grid/production/grid.nix
index fc3b9b5b6a5f3a532c05a42159bbcb2f0e6c798d..69a17602f0499acaa8b45adca5e45c8acc637110 100644
--- a/morph/grid/production/grid.nix
+++ b/morph/grid/production/grid.nix
@@ -6,7 +6,7 @@ import ../../lib/make-grid.nix {
   config = ./config.json;
   nodes = cfg:
     let
-      sshUsers = import ../../../../PrivateStorageSecrets/production-users.nix;
+      sshUsers = import ./users.nix;
     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
@@ -20,7 +20,7 @@ import ../../lib/make-grid.nix {
     # doesn't specify one.
     #
     # The names must be unique!
-    "payments.privatestorage.io" = import ../../lib/issuer.nix ({
+    "payments.privatestorage.io" = import ../../lib/make-issuer.nix ({
       publicIPv4 = "18.184.142.208";
       inherit sshUsers;
       hardware = ../../lib/issuer-aws.nix;
diff --git a/morph/grid/production/users.nix b/morph/grid/production/users.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d3520076636b56c0b07055a135becaf6a77b798f
--- /dev/null
+++ b/morph/grid/production/users.nix
@@ -0,0 +1,2 @@
+let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGN4VQm3BIQKEFTw6aPrEwNuShf640N+Py2LOKznFCRT exarkun@bottom";
+in { "root" = key; jcalderone = key; }
diff --git a/morph/grid/testing/.gitignore b/morph/grid/testing/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..db2fc0de62d01d6d7eec83f8f3e8c3b13b20392a
--- /dev/null
+++ b/morph/grid/testing/.gitignore
@@ -0,0 +1 @@
+secrets
diff --git a/morph/grid/testing/config.json b/morph/grid/testing/config.json
index e9b3b96940ab6cc73ef6b6f8dc2ec89a83a463d9..ec28840a2857c621a22658efc14368e4c07aa5db 100644
--- a/morph/grid/testing/config.json
+++ b/morph/grid/testing/config.json
@@ -1,8 +1,11 @@
 { "publicStoragePort": 8898
-, "ristrettoSigningKeyPath": "../../PrivateStorageSecrets/ristretto.signing-key"
-, "stripeSecretKeyPath": "../../PrivateStorageSecrets/privatestorageio-testing-stripe.secret"
+, "ristrettoSigningKeyPath": "./secrets/ristretto.signing-key"
+, "stripeSecretKeyPath": "./secrets/stripe.secret"
 , "passValue": 1000000
-, "issuerDomain": "payments.privatestorage-staging.com"
+, "issuerDomains": [
+    "payments.privatestorage-staging.com"
+  , "payments.extra.privatestorage-staging.com"
+  ]
 , "letsEncryptAdminEmail": "jean-paul@privatestorage.io"
 , "allowedChargeOrigins": [
     "http://localhost:5000"
diff --git a/morph/grid/testing/grid.nix b/morph/grid/testing/grid.nix
index 9d5de19491b02409909fdbb9ca649d22c402ffd2..90acab60b7bf6a12f2c5ff3ff93cb93e479491aa 100644
--- a/morph/grid/testing/grid.nix
+++ b/morph/grid/testing/grid.nix
@@ -6,15 +6,10 @@ import ../../lib/make-grid.nix {
   config = ./config.json;
   nodes = cfg:
   let
-    importDef = default: path: (
-      if builtins.pathExists path
-      then import path
-      else default
-    );
-    sshUsers = importDef {} ../../../../PrivateStorageSecrets/staging-users.nix;
+    sshUsers = import ./users.nix;
   in {
-    "payments" = import ../../lib/issuer.nix ({
-      publicIPv4 = "18.197.42.120";
+    "payments" = import ../../lib/make-issuer.nix ({
+      publicIPv4 = "18.194.183.13";
       inherit sshUsers;
       hardware = ../../lib/issuer-aws.nix;
       stateVersion = "19.03";
diff --git a/morph/grid/testing/users.nix b/morph/grid/testing/users.nix
new file mode 100644
index 0000000000000000000000000000000000000000..d3520076636b56c0b07055a135becaf6a77b798f
--- /dev/null
+++ b/morph/grid/testing/users.nix
@@ -0,0 +1,2 @@
+let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGN4VQm3BIQKEFTw6aPrEwNuShf640N+Py2LOKznFCRT exarkun@bottom";
+in { "root" = key; jcalderone = key; }
diff --git a/morph/lib/issuer.nix b/morph/lib/make-issuer.nix
similarity index 75%
rename from morph/lib/issuer.nix
rename to morph/lib/make-issuer.nix
index 7510b6b82e8b298387394e5ff8082445a2fe18e7..5625b565c452d1fc9c8bf84eb1720cecd197f124 100644
--- a/morph/lib/issuer.nix
+++ b/morph/lib/make-issuer.nix
@@ -1,21 +1,22 @@
 { hardware
 , ristrettoSigningKeyPath
 , stripeSecretKeyPath
-, issuerDomain
+, issuerDomains
 , letsEncryptAdminEmail
 , allowedChargeOrigins
 , sshUsers
 , stateVersion
 , publicIPv4
 , ...
-}: {
+}: rec {
   deployment = {
+    targetUser = "root";
     targetHost = publicIPv4;
 
     secrets = {
       "ristretto-signing-key" = {
         source = ristrettoSigningKeyPath;
-        destination = "/var/secrets/ristretto.signing-key";
+        destination = "/run/keys/ristretto.signing-key";
         owner.user = "root";
         owner.group = "root";
         permissions = "0400";
@@ -23,7 +24,7 @@
       };
       "stripe-secret-key" = {
         source = stripeSecretKeyPath;
-        destination = "/var/secrets/stripe.secret-key";
+        destination = "/run/keys/stripe.secret-key";
         owner.user = "root";
         owner.group = "root";
         permissions = "0400";
@@ -41,12 +42,12 @@
   services.private-storage-issuer = {
     enable = true;
     tls = true;
-    ristrettoSigningKeyPath = "/var/secrets/ristretto.signing-key";
-    stripeSecretKeyPath = "/var/secrets/stripe.secret-key";
+    ristrettoSigningKeyPath = deployment.secrets.ristretto-signing-key.destination;
+    stripeSecretKeyPath = deployment.secrets.stripe-secret-key.destination;
     database = "SQLite3";
     databasePath = "/var/db/vouchers.sqlite3";
     inherit letsEncryptAdminEmail;
-    domain = issuerDomain;
+    domains = issuerDomains;
     inherit allowedChargeOrigins;
   };
 
diff --git a/morph/lib/make-storage.nix b/morph/lib/make-storage.nix
index c286106a18caca4a575305f0fa897aedaac60c55..25f3a95bfb5e66a4b42c5f2f82b4fdacbaed4b41 100644
--- a/morph/lib/make-storage.nix
+++ b/morph/lib/make-storage.nix
@@ -14,12 +14,13 @@
 , ...
 }: rec {
   deployment = {
+    targetUser = "root";
     targetHost = cfg.publicIPv4;
 
     secrets = {
       "ristretto-signing-key" = {
         source = ristrettoSigningKeyPath;
-        destination = "/var/secrets/ristretto.signing-key";
+        destination = "/run/keys/ristretto.signing-key";
         owner.user = "root";
         owner.group = "root";
         permissions = "0400";
diff --git a/morph/lib/make-testing.nix b/morph/lib/make-testing.nix
index ee1e2db49ba337578312866f737e216f961dc152..ed6ba27e70726cb08c38850cd09f08b37897fcfa 100644
--- a/morph/lib/make-testing.nix
+++ b/morph/lib/make-testing.nix
@@ -1,12 +1,13 @@
 { publicIPv4, hardware, publicStoragePort, ristrettoSigningKeyPath, passValue, sshUsers, stateVersion, ... }: rec {
 
   deployment = {
+    targetUser = "root";
     targetHost = publicIPv4;
 
     secrets = {
       "ristretto-signing-key" = {
         source = ristrettoSigningKeyPath;
-        destination = "/var/secrets/ristretto.signing-key";
+        destination = "/run/keys/ristretto.signing-key";
         owner.user = "root";
         owner.group = "root";
         permissions = "0400";
diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix
index 6ad4f1b8fa01d570e8b6e1d5c4acfbeb42757822..fb93ce35cce8c9cadbad5a04e888b0cca991f9c7 100644
--- a/nixos/modules/issuer.nix
+++ b/nixos/modules/issuer.nix
@@ -18,12 +18,11 @@ in {
         The package to use for the ZKAP issuer.
       '';
     };
-    services.private-storage-issuer.domain = lib.mkOption {
-      default = "payments.privatestorage.io";
-      type = lib.types.str;
-      example = lib.literalExample "payments.example.com";
+    services.private-storage-issuer.domains = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      example = lib.literalExample [ "payments.example.com" ];
       description = ''
-        The domain name at which the issuer is reachable.
+        The domain names at which the issuer is reachable.
       '';
     };
     services.private-storage-issuer.tls = lib.mkOption {
@@ -115,6 +114,10 @@ in {
   config =
     let
       certroot = "/var/lib/letsencrypt/live";
+      # We'll refer to this collection of domains by the first domain in the
+      # list.
+      domain = builtins.head cfg.domains;
+      certServiceName = "cert-${domain}";
     in lib.mkIf cfg.enable {
     # Add a systemd service to run PaymentServer.
     systemd.services.zkapissuer = {
@@ -124,7 +127,7 @@ in {
 
       # Make sure we have a certificate the first time, if we are running over
       # TLS and require a certificate.
-      requires = lib.optional cfg.tls "cert-${cfg.domain}.service";
+      requires = lib.optional cfg.tls "${certServiceName}.service";
 
       after = [
         # Make sure there is a network so we can bind to all of the
@@ -133,7 +136,7 @@ in {
       ] ++
         # Make sure we run after the certificate is issued, if we are running
         # over TLS and require a certificate.
-        lib.optional cfg.tls "cert-${cfg.domain}.service";
+        lib.optional cfg.tls "${certServiceName}.service";
 
       # It really shouldn't ever exit on its own!  If it does, it's a bug
       # we'll have to fix.  Restart it and hope it doesn't happen too much
@@ -157,9 +160,9 @@ in {
             if cfg.tls
             then
               "--https-port 443 " +
-              "--https-certificate-path ${certroot}/${cfg.domain}/cert.pem " +
-              "--https-certificate-chain-path ${certroot}/${cfg.domain}/chain.pem " +
-              "--https-key-path ${certroot}/${cfg.domain}/privkey.pem"
+              "--https-certificate-path ${certroot}/${domain}/cert.pem " +
+              "--https-certificate-chain-path ${certroot}/${domain}/chain.pem " +
+              "--https-key-path ${certroot}/${domain}/privkey.pem"
             else
               # Only for automated testing.
               "--http-port 80";
@@ -179,20 +182,20 @@ in {
 
     # Certificate renewal.  We must declare that we *require* it in our
     # service above.
-    systemd.services."cert-${cfg.domain}" = {
+    systemd.services."${certServiceName}" = {
       enable = true;
-      description = "Issue/Renew certificate for ${cfg.domain}";
+      description = "Certificate ${domain}";
       serviceConfig = {
         ExecStart =
         let
           configArgs = "--config-dir /var/lib/letsencrypt --work-dir /var/run/letsencrypt --logs-dir /var/run/log/letsencrypt";
         in
-          pkgs.writeScript "cert-${cfg.domain}-start.sh" ''
+          pkgs.writeScript "cert-${domain}-start.sh" ''
           #!${pkgs.runtimeShell} -e
           # Register if necessary.
           ${pkgs.certbot}/bin/certbot register ${configArgs} --non-interactive --agree-tos -m ${cfg.letsEncryptAdminEmail} || true
           # Obtain the certificate.
-          ${pkgs.certbot}/bin/certbot certonly ${configArgs} --non-interactive --standalone --domains ${cfg.domain}
+          ${pkgs.certbot}/bin/certbot certonly ${configArgs} --non-interactive --standalone --expand --domains ${builtins.concatStringsSep "," cfg.domains}
           '';
       };
     };
diff --git a/nixos/modules/tests/private-storage.nix b/nixos/modules/tests/private-storage.nix
index e085f8bc7142da4067745bdee233c82e7b1e8d1c..cbf4c5937ca6780ce9e931d6ceec91c29643fbc3 100644
--- a/nixos/modules/tests/private-storage.nix
+++ b/nixos/modules/tests/private-storage.nix
@@ -134,7 +134,7 @@ in {
 
       services.private-storage-issuer = {
         enable = true;
-        domain = "issuer";
+        domains = ["issuer"];
         tls = false;
         issuer = "Ristretto";
         inherit ristrettoSigningKeyPath;