diff --git a/DEPLOYMENT-NOTES.rst b/DEPLOYMENT-NOTES.rst
new file mode 100644
index 0000000000000000000000000000000000000000..5de83386dbb939cc3cfe2a8b68c198f218934933
--- /dev/null
+++ b/DEPLOYMENT-NOTES.rst
@@ -0,0 +1,14 @@
+Deployment notes
+================
+
+- 2021-09-07 `Manage access to payment metrics <https://whetstone.privatestorage.io/privatestorage/PrivateStorageio/-/merge_requests/146>`_ requires moving and chown'ing the PaymentServer database on the ``payments`` host::
+
+   mkdir /var/lib/zkapissuer
+
+   mv /var/db/vouchers.sqlite3 /var/lib/zkapissuer/vouchers.sqlite3
+
+   chown -R zkapissuer:zkapissuer /var/lib/zkapissuer
+
+   chmod 750 /var/lib/zkapissuer
+   chmod 640 /var/lib/zkapissuer/vouchers.sqlite3
+
diff --git a/morph/lib/issuer.nix b/morph/lib/issuer.nix
index a14d70e0634ad150d3c076abcdfc36fb6a167513..d60af799888c97ec8f97a061d40b54d3f2db82a7 100644
--- a/morph/lib/issuer.nix
+++ b/morph/lib/issuer.nix
@@ -13,16 +13,16 @@ in {
       "ristretto-signing-key" = {
         destination = "/run/keys/ristretto.signing-key";
         source = "${privateKeyPath}/ristretto.signing-key";
-        owner.user = "root";
-        owner.group = "root";
+        owner.user = "zkapissuer";
+        owner.group = "zkapissuer";
         permissions = "0400";
         action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
       };
       "stripe-secret-key" = {
         destination = "/run/keys/stripe.secret-key";
         source = "${privateKeyPath}/stripe.secret";
-        owner.user = "root";
-        owner.group = "root";
+        owner.user = "zkapissuer";
+        owner.group = "zkapissuer";
         permissions = "0400";
         action = ["sudo" "systemctl" "restart" "zkapissuer.service"];
       };
@@ -56,6 +56,6 @@ in {
     ristrettoSigningKeyPath = config.deployment.secrets.ristretto-signing-key.destination;
     stripeSecretKeyPath = config.deployment.secrets.stripe-secret-key.destination;
     database = "SQLite3";
-    databasePath = "/var/db/vouchers.sqlite3";
+    databasePath = "/var/lib/zkapissuer/vouchers.sqlite3";
   };
 }
diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix
index e712ac0d3bbbcafcafd07552e69488e046e3e7e2..0433c4f011578bdecea023220c68d6d5047eae35 100644
--- a/nixos/modules/issuer.nix
+++ b/nixos/modules/issuer.nix
@@ -109,11 +109,13 @@ in {
 
   config =
     let
-      certroot = "/var/lib/letsencrypt/live";
       # We'll refer to this collection of domains by the first domain in the
       # list.
       domain = builtins.head cfg.domains;
-      certServiceName = "cert-${domain}";
+      certServiceName = "acme-${domain}";
+      # Payment server internal http port (arbitrary, non-priviledged):
+      internalHttpPort = "1061";
+
     in lib.mkIf cfg.enable {
     # Add a systemd service to run PaymentServer.
     systemd.services.zkapissuer = {
@@ -123,7 +125,14 @@ 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 "${certServiceName}.service";
+      # ACME will issue an interim self-signed certificate, which we want to
+      # use at least in the local dev network.  But if ACME cannot get the
+      # created key signed by LE (probably because the host is not reachable
+      # from outside, or the domain is not a legit TLD) the ACME cert service
+      # will "fail". We still want to start our PaymentServer. Hence a weaker
+      # "wants" instead of a "requires" dependency.
+      # When ACME receives a fully signed cert from LE, it will reload NGINX.
+      wants = lib.optional cfg.tls "${certServiceName}.service";
 
       after = [
         # Make sure there is a network so we can bind to all of the
@@ -140,6 +149,26 @@ in {
       serviceConfig.Restart = "always";
       serviceConfig.Type = "simple";
 
+      # Run w/o privileges
+      serviceConfig = {
+        DynamicUser = false;
+        User = "zkapissuer";
+        Group = "zkapissuer";
+      };
+
+      # Make systemd create a User/Group owned directory for PaymentServer
+      # state. According to the docs at
+      # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectory=
+      # "The specified directory names must be relative" ... this
+      # makes systemd create /var/lib/zkapissuer/ for us:
+      serviceConfig.StateDirectory = "zkapissuer";
+      serviceConfig.StateDirectoryMode = "0750";
+
+      # Bail if there is still an old (root-owned) DB file on this system.
+      # If you hit this, and this /var/db/ file is indeed current, move it to
+      # /var/lib/zkapissuer/vouchers.sqlite3 and chown it to zkapissuer:zkapissuer.
+      unitConfig.AssertPathExists = "!/var/db/vouchers.sqlite3";
+
       script =
         let
           # Compute the right command line arguments to pass to it.  The
@@ -152,16 +181,7 @@ in {
             if cfg.database == "Memory"
               then "--database Memory"
               else "--database SQLite3 --database-path ${cfg.databasePath}";
-          httpsArgs =
-            if cfg.tls
-            then
-              "--https-port 443 " +
-              "--https-certificate-path ${certroot}/${domain}/cert.pem " +
-              "--https-certificate-chain-path ${certroot}/${domain}/chain.pem " +
-              "--https-key-path ${certroot}/${domain}/privkey.pem"
-            else
-              # Only for automated testing.
-              "--http-port 80";
+          httpArgs = "--http-port ${internalHttpPort}";
 
           prefixOption = s: "--cors-origin=" + s;
           originStrings = map prefixOption cfg.allowedChargeOrigins;
@@ -173,33 +193,21 @@ in {
             "--stripe-endpoint-scheme ${cfg.stripeEndpointScheme} " +
             "--stripe-endpoint-port ${toString cfg.stripeEndpointPort}";
         in
-          "${cfg.package}/bin/PaymentServer-exe ${originArgs} ${issuerArgs} ${databaseArgs} ${httpsArgs} ${stripeArgs}";
+          "${cfg.package}/bin/PaymentServer-exe ${originArgs} ${issuerArgs} ${databaseArgs} ${httpArgs} ${stripeArgs}";
     };
 
-    # Certificate renewal.  A short-lived service meant to be repeatedly
-    # activated to request a new certificate be issued, if the current one is
-    # close to expiring.
-    systemd.services.${certServiceName} = {
-      enable = cfg.tls;
-      description = "Certificate ${domain}";
-      # Activate this unit periodically so that certbot can determine if the
-      # certificate expiration time is close enough to warrant a renewal
-      # request.
-      startAt = "weekly";
-
-      serviceConfig = {
-        ExecStart =
-        let
-          configArgs = "--config-dir /var/lib/letsencrypt --work-dir /var/run/letsencrypt --logs-dir /var/run/log/letsencrypt";
-        in
-          pkgs.writeScript "cert-${domain}-start.sh" ''
-          #!${pkgs.runtimeShell} -e
-          # Register if necessary.
-          ${pkgs.certbot}/bin/certbot register ${configArgs} --non-interactive --agree-tos -m ${cfg.letsEncryptAdminEmail} || true
-          # Obtain the certificate.
-          ${pkgs.certbot}/bin/certbot certonly ${configArgs} --non-interactive --standalone --expand --domains ${builtins.concatStringsSep "," cfg.domains}
-          '';
-      };
+    # PaymentServer runs as this user and group by default
+    # Mind the comments in nixpkgs/nixos/modules/misc/ids.nix: "When adding a uid,
+    # make sure it doesn't match an existing gid. And don't use uids above 399!"
+    ids.uids.zkapissuer = 397;
+    ids.gids.zkapissuer = 397;
+    users.extraGroups.zkapissuer.gid = config.ids.gids.zkapissuer;
+    users.extraUsers.zkapissuer = {
+      uid = config.ids.uids.zkapissuer;
+      isNormalUser = false;
+      group = "zkapissuer";
+      # Let PaymentServer read from keys, if necessary.
+      extraGroups = [ "keys" ];
     };
 
     # Open 80 and 443 for the certbot HTTP server and the PaymentServer HTTPS server.
@@ -207,5 +215,38 @@ in {
       80
       443
     ];
+
+    # NGINX reverse proxy
+    security.acme.email = cfg.letsEncryptAdminEmail;
+    security.acme.acceptTerms = true;
+    services.nginx = {
+      enable = true;
+
+      recommendedGzipSettings = true;
+      recommendedOptimisation = true;
+      recommendedProxySettings = true;
+      recommendedTlsSettings = true;
+
+      virtualHosts."${domain}" = {
+        serverAliases = builtins.tail cfg.domains;
+        enableACME = cfg.tls;
+        forceSSL = cfg.tls;
+        locations."/v1/" = {
+          # Only forward requests beginning with /v1/ so
+          # we pass less scanning spam on to our backend
+          # Want a regex instead? try locations."~ /v\d+/"
+          proxyPass = "http://127.0.0.1:${internalHttpPort}";
+        };
+        locations."/metrics" = {
+          # Only allow our monitoringvpn subnet
+          extraConfig = ''
+            allow 172.23.23.0/24;
+            deny all;
+          '';
+          proxyPass = "http://127.0.0.1:${internalHttpPort}";
+        };
+      };
+    };
+
   };
 }
diff --git a/nixos/modules/tests/private-storage.nix b/nixos/modules/tests/private-storage.nix
index 2687718bcba3f07cf9e229dadf071489445e4a54..3e8009b01d6c8a803909dcf08573029273c66bde 100644
--- a/nixos/modules/tests/private-storage.nix
+++ b/nixos/modules/tests/private-storage.nix
@@ -296,5 +296,14 @@ in {
       code, log = client.execute('cat /tmp/stdout /tmp/stderr')
       client.log(log)
       raise
+
+    # The issuer metrics should be accessible from the monitoring network.
+    issuer.execute('ifconfig lo:fauxvpn 172.23.23.2/24')
+    issuer.wait_until_succeeds("nc -z 172.23.23.2 80")
+    issuer.succeed('curl --silent --insecure --fail --output /dev/null http://172.23.23.2/metrics')
+    # The issuer metrics should NOT be accessible from any other network.
+    issuer.fail('curl --silent --insecure --fail --output /dev/null http://localhost/metrics')
+    client.fail('curl --silent --insecure --fail --output /dev/null http://issuer/metrics')
+    issuer.execute('ifconfig lo:fauxvpn down')
   '';
 }