diff --git a/morph/grid/local/config.json b/morph/grid/local/config.json
index 9a929d2cf4613874379fdcc7a52f241c10f63f18..8b23b6f1152be4fa94e8935342bf11f7706d036c 100644
--- a/morph/grid/local/config.json
+++ b/morph/grid/local/config.json
@@ -9,4 +9,5 @@
 , "allowedChargeOrigins": [
     "http://localhost:5000"
   ]
+, "monitoringGoogleOAuthClientID": ""
 }
diff --git a/morph/grid/local/grid.nix b/morph/grid/local/grid.nix
index 4e82fa29db5df7da2c9e977f978e67228e527be5..3def2d77556e8b82b5fd0dbd2513f3d08b7ea2c7 100644
--- a/morph/grid/local/grid.nix
+++ b/morph/grid/local/grid.nix
@@ -61,7 +61,8 @@ let
       (gridlib.hardware-virtual ({ publicIPv4 = "192.168.67.24"; }))
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
-        inherit (config) domain publicKeyPath privateKeyPath;
+        inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail;
+        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
         monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
       })
diff --git a/morph/grid/production/config.json b/morph/grid/production/config.json
index 092e4dff7b4c026c816afdd85b2a454089204141..fcae1563a8fc0d3a8a11324fc6667105ae3179c8 100644
--- a/morph/grid/production/config.json
+++ b/morph/grid/production/config.json
@@ -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 3fa9e287d2cbbb9e5b8e03baaad5912003f99cae..e663d2243e4aa6078260e41f07f807f606e64ef6 100644
--- a/morph/grid/production/grid.nix
+++ b/morph/grid/production/grid.nix
@@ -38,7 +38,8 @@ let
       gridlib.hardware-aws
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
-        inherit (config) domain publicKeyPath privateKeyPath;
+        inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail;
+        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
         monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
       })
diff --git a/morph/grid/testing/config.json b/morph/grid/testing/config.json
index 8b94959557364d8af8f1f4aa61c5647b46db9932..a10840db52e8cd74bbac2a0ad38f4887c1a03258 100644
--- a/morph/grid/testing/config.json
+++ b/morph/grid/testing/config.json
@@ -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 85cbe54057c3a234e36e66289725e5d46a6f197f..fbbbd9f13e49cfdc7fd2f0687fa2fe12df91ea33 100644
--- a/morph/grid/testing/grid.nix
+++ b/morph/grid/testing/grid.nix
@@ -51,7 +51,8 @@ let
       gridlib.hardware-aws
       (gridlib.customize-monitoring {
         inherit hostsMap vpnClientIPs nodeExporterTargets paymentExporterTargets;
-        inherit (config) domain publicKeyPath privateKeyPath;
+        inherit (config) domain publicKeyPath privateKeyPath letsEncryptAdminEmail;
+        googleOAuthClientID = config.monitoringGoogleOAuthClientID;
         monitoringvpnIPv4 = "172.23.23.1";
         stateVersion = "19.09";
       })
diff --git a/morph/lib/customize-monitoring.nix b/morph/lib/customize-monitoring.nix
index 05fe45107e44c583c495ee55aeb9e351ba3871f1..36bb564a3d26eca419c46dcdef070584e6ff5d7d 100644
--- a/morph/lib/customize-monitoring.nix
+++ b/morph/lib/customize-monitoring.nix
@@ -13,6 +13,7 @@
 , privateKeyPath
 , monitoringvpnIPv4
 , domain
+, letsEncryptAdminEmail
 
   # A list of VPN IP addresses as strings indicating which clients will be
   # allowed onto the VPN.
@@ -30,6 +31,10 @@
   # 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
 , ...
@@ -38,10 +43,35 @@
   # See customize-issuer.nix for an explanatoin of targetHost value.
   deployment.targetHost = "${config.networking.hostName}.${config.networking.domain}";
 
-  deployment.secrets = {
-    "monitoringvpn-private-key".source = "${privateKeyPath}/monitoringvpn/server.key";
-    "monitoringvpn-preshared-key".source = "${privateKeyPath}/monitoringvpn/preshared.key";
-  };
+  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"];
+        };
+      };
+    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;
@@ -59,5 +89,11 @@
     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/monitoring.nix b/morph/lib/monitoring.nix
index fa769d5ebcb32d893310136291064a85c09beee2..d8af93b24119ba6dff5ce63a5b2d16fbd18edb71 100644
--- a/morph/lib/monitoring.nix
+++ b/morph/lib/monitoring.nix
@@ -31,10 +31,4 @@ rec {
     # Loki 0.3.0 from Nixpkgs 19.09 is too old and does not work:
     # ../../nixos/modules/monitoring/server/loki.nix
   ];
-
-  services.private-storage.monitoring.grafana = {
-    domain = "monitoring.private.storage";
-    prometheusUrl = "http://localhost:9090/";
-    lokiUrl = "http://localhost:3100/";
-  };
 }
diff --git a/nixos/modules/monitoring/server/grafana.nix b/nixos/modules/monitoring/server/grafana.nix
index 425c15f46735d81ac2f916ebb4009e7664bd0728..d320907e8e71562b47829850ff85245c265d5040 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,39 @@ 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.";
+    };
   };
 
   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;
@@ -41,12 +71,23 @@ in {
       # No phoning home
       analytics.reporting.enable = false;
 
-      # 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.";
+      # 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";
+      };
     };
 
+    services.grafana.auth = {
+      anonymous.org_role = "Admin";
+      anonymous.org_name = "Main Org.";
+    } // grafanaAuth;
+
     services.grafana.provision = {
       enable = true;
       # See https://grafana.com/docs/grafana/latest/administration/provisioning/#datasources
@@ -70,13 +111,30 @@ in {
     };
 
     # 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" ];
   };
 }
-