summary refs log tree commit diff
path: root/nixos
diff options
context:
space:
mode:
authorLucas Savva <lucas@m1cr0man.com>2020-06-19 20:27:46 +0100
committerLucas Savva <lucas@m1cr0man.com>2020-09-02 19:22:43 +0100
commit982c5a1f0e7f282f856391304aa4da7bb36c45b8 (patch)
tree4cf0e93b6cd4e1ae2371c0d9184fca87ae8e43ca /nixos
parent6ab387699a9f23201cf76091d0f7d4ff09fa510e (diff)
downloadnixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.tar
nixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.tar.gz
nixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.tar.bz2
nixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.tar.lz
nixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.tar.xz
nixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.tar.zst
nixpkgs-982c5a1f0e7f282f856391304aa4da7bb36c45b8.zip
nixos/acme: Restructure module
- Use an acme user and group, allow group override only
- Use hashes to determine when certs actually need to regenerate
- Avoid running lego more than necessary
- Harden permissions
- Support "systemctl clean" for cert regeneration
- Support reuse of keys between some configuration changes
- Permissions fix services solves for previously root owned certs
- Add a note about multiple account creation and emails
- Migrate extraDomains to a list
- Deprecate user option
- Use minica for self-signed certs
- Rewrite all tests

I thought of a few more cases where things may go wrong,
and added tests to cover them. In particular, the web server
reload services were depending on the target - which stays alive,
meaning that the renewal timer wouldn't be triggering a reload
and old certs would stay on the web servers.

I encountered some problems ensuring that the reload took place
without accidently triggering it as part of the test. The sync
commands I added ended up being essential and I'm not sure why,
it seems like either node.succeed ends too early or there's an
oddity of the vm's filesystem I'm not aware of.

- Fix duplicate systemd rules on reload services

Since useACMEHost is not unique to every vhost, if one cert
was reused many times it would create duplicate entries in
${server}-config-reload.service for wants, before and
ConditionPathExists
Diffstat (limited to 'nixos')
-rw-r--r--nixos/modules/security/acme.nix644
-rw-r--r--nixos/modules/security/acme.xml8
-rw-r--r--nixos/modules/services/networking/prosody.xml5
-rw-r--r--nixos/modules/services/web-servers/apache-httpd/default.nix77
-rw-r--r--nixos/modules/services/web-servers/nginx/default.nix88
-rw-r--r--nixos/tests/acme.nix343
-rw-r--r--nixos/tests/common/acme/client/default.nix11
-rw-r--r--nixos/tests/common/acme/server/default.nix72
-rw-r--r--nixos/tests/common/acme/server/mkcerts.nix69
-rwxr-xr-xnixos/tests/common/acme/server/mkcerts.sh6
-rw-r--r--nixos/tests/common/acme/server/snakeoil-certs.nix207
-rw-r--r--nixos/tests/postfix-raise-smtpd-tls-security-level.nix3
-rw-r--r--nixos/tests/postfix.nix13
13 files changed, 810 insertions, 736 deletions
diff --git a/nixos/modules/security/acme.nix b/nixos/modules/security/acme.nix
index 29635dbe864..91b7dd0c989 100644
--- a/nixos/modules/security/acme.nix
+++ b/nixos/modules/security/acme.nix
@@ -1,11 +1,309 @@
-{ config, lib, pkgs, ... }:
+{ config, lib, pkgs, options, ... }:
 with lib;
 let
-
   cfg = config.security.acme;
 
+  # Used to calculate timer accuracy for coalescing
+  numCerts = length (builtins.attrNames cfg.certs);
+  _24hSecs = 60 * 60 * 24;
+
+  # There are many services required to make cert renewals work.
+  # They all follow a common structure:
+  #   - They inherit this commonServiceConfig
+  #   - They all run as the acme user
+  #   - They all use BindPath and StateDirectory where possible
+  #     to set up a sort of build environment in /tmp
+  # The Group can vary depending on what the user has specified in
+  # security.acme.certs.<cert>.group on some of the services.
+  commonServiceConfig = {
+      Type = "oneshot";
+      User = "acme";
+      Group = mkDefault "acme";
+      UMask = 0027;
+      StateDirectoryMode = 750;
+      ProtectSystem = "full";
+      PrivateTmp = true;
+
+      WorkingDirectory = "/tmp";
+  };
+
+  # In order to avoid race conditions creating the CA for selfsigned certs,
+  # we have a separate service which will create the necessary files.
+  selfsignCAService = {
+    description = "Generate self-signed certificate authority";
+
+    path = with pkgs; [ minica ];
+
+    unitConfig = {
+      ConditionPathExists = "!/var/lib/acme/.minica/key.pem";
+    };
+
+    serviceConfig = commonServiceConfig // {
+      StateDirectory = "acme/.minica";
+      BindPaths = "/var/lib/acme/.minica:/tmp/ca";
+    };
+
+    # Working directory will be /tmp
+    script = ''
+      minica \
+        --ca-key ca/key.pem \
+        --ca-cert ca/cert.pem \
+        --domains selfsigned.local
+
+      chmod 600 ca/*
+    '';
+  };
+
+  # Previously, all certs were owned by whatever user was configured in
+  # config.security.acme.certs.<cert>.user. Now everything is owned by and
+  # run by the acme user.
+  userMigrationService = {
+    description = "Fix owner and group of all ACME certificates";
+
+    script = with builtins; concatStringsSep "\n" (mapAttrsToList (cert: data: ''
+      for fixpath in /var/lib/acme/${escapeShellArg cert} /var/lib/acme/.lego/${escapeShellArg cert}; do
+        if [ -d "$fixpath" ]; then
+          chmod -R 750 "$fixpath"
+          chown -R acme:${data.group} "$fixpath"
+        fi
+      done
+    '') certConfigs);
+
+    # We don't want this to run every time a renewal happens
+    serviceConfig.RemainAfterExit = true;
+  };
+
+  certToConfig = cert: data: let
+    acmeServer = if data.server != null then data.server else cfg.server;
+    useDns = data.dnsProvider != null;
+    destPath = "/var/lib/acme/${cert}";
+
+    # Minica and lego have a "feature" which replaces * with _. We need
+    # to make this substitution to reference the output files from both programs.
+    # End users never see this since we rename the certs.
+    keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
+
+    # FIXME when mkChangedOptionModule supports submodules, change to that.
+    # This is a workaround
+    extraDomains = data.extraDomainNames ++ (
+      optionals
+      (data.extraDomains != "_mkMergedOptionModule")
+      (builtins.attrNames data.extraDomains)
+    );
+
+    # Create hashes for cert data directories based on configuration
+    hashData = with builtins; ''
+      ${data.domain} ${data.keyType}
+      ${concatStringsSep " " (
+        extraDomains
+        ++ data.extraLegoFlags
+        ++ data.extraLegoRunFlags
+        ++ data.extraLegoRenewFlags
+      )}
+      ${toString acmeServer} ${toString data.dnsProvider}
+      ${toString data.ocspMustStaple}
+    '';
+    mkHash = with builtins; val: substring 0 20 (hashString "sha256" val);
+    certDir = mkHash hashData;
+    othersHash = mkHash "${toString acmeServer} ${data.keyType}";
+    keyDir = "key-" + othersHash;
+    accountDir = "/var/lib/acme/.lego/accounts/" + othersHash;
+
+    protocolOpts = if useDns then (
+      [ "--dns" data.dnsProvider ]
+      ++ optionals (!data.dnsPropagationCheck) [ "--dns.disable-cp" ]
+    ) else (
+      [ "--http" "--http.webroot" data.webroot ]
+    );
+
+    commonOpts = [
+      "--accept-tos" # Checking the option is covered by the assertions
+      "--path" "."
+      "-d" data.domain
+      "--email" data.email
+      "--key-type" data.keyType
+    ] ++ protocolOpts
+      ++ optionals data.ocspMustStaple [ "--must-staple" ]
+      ++ optionals (acmeServer != null) [ "--server" acmeServer ]
+      ++ concatMap (name: [ "-d" name ]) extraDomains
+      ++ data.extraLegoFlags;
+
+    runOpts = escapeShellArgs (
+      commonOpts
+      ++ [ "run" ]
+      ++ data.extraLegoRunFlags
+    );
+    renewOpts = escapeShellArgs (
+      commonOpts
+      ++ [ "renew" "--reuse-key" "--days" (toString cfg.validMinDays) ]
+      ++ data.extraLegoRenewFlags
+    );
+
+  in {
+    inherit accountDir;
+
+    webroot = data.webroot;
+    group = data.group;
+
+    renewTimer = {
+      description = "Renew ACME Certificate for ${cert}";
+      wantedBy = [ "timers.target" ];
+      timerConfig = {
+        OnCalendar = cfg.renewInterval;
+        Unit = "acme-${cert}.service";
+        Persistent = "yes";
+
+        # Allow systemd to pick a convenient time within the day
+        # to run the check.
+        # This allows the coalescing of multiple timer jobs.
+        # We divide by the number of certificates so that if you
+        # have many certificates, the renewals are distributed over
+        # the course of the day to avoid rate limits.
+        AccuracySec = "${toString (_24hSecs / numCerts)}s";
+
+        # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
+        RandomizedDelaySec = "24h";
+      };
+    };
+
+    selfsignService = {
+      description = "Generate self-signed certificate for ${cert}";
+      after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ];
+      wants = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ];
+
+      path = with pkgs; [ minica ];
+
+      unitConfig = {
+        ConditionPathExists = "!/var/lib/acme/${cert}/key.pem";
+      };
+
+      serviceConfig = commonServiceConfig // {
+        Group = data.group;
+
+        StateDirectory = "acme/${cert}";
+
+        BindPaths = "/var/lib/acme/.minica:/tmp/ca /var/lib/acme/${cert}:/tmp/${keyName}";
+      };
+
+      # Working directory will be /tmp
+      # minica will output to a folder sharing the name of the first domain
+      # in the list, which will be ${data.domain}
+      script = ''
+        minica \
+          --ca-key ca/key.pem \
+          --ca-cert ca/cert.pem \
+          --domains ${escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))}
+
+        # Create files to match directory layout for real certificates
+        cd '${keyName}'
+        cp ../ca/cert.pem chain.pem
+        cat cert.pem chain.pem > fullchain.pem
+        cat key.pem fullchain.pem > full.pem
+
+        chmod 640 *
+
+        # Group might change between runs, re-apply it
+        chown 'acme:${data.group}' *
+      '';
+    };
+
+    renewService = {
+      description = "Renew ACME certificate for ${cert}";
+      after = [ "network.target" "network-online.target" "acme-selfsigned-${cert}.service" "acme-fixperms.service" ];
+      wants = [ "network-online.target" "acme-selfsigned-${cert}.service" "acme-fixperms.service" ];
+
+      # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099
+      wantedBy = optionals (!config.boot.isContainer) [ "multi-user.target" ];
+
+      path = with pkgs; [ lego coreutils ];
+
+      serviceConfig = commonServiceConfig // {
+        Group = data.group;
+
+        # AccountDir dir will be created by tmpfiles to ensure correct permissions
+        # And to avoid deletion during systemctl clean
+        # acme/.lego/${cert} is listed so that it is deleted during systemctl clean
+        StateDirectory = "acme/${cert} acme/.lego/${cert} acme/.lego/${cert}/${certDir} acme/.lego/${cert}/${keyDir}";
+
+        # Needs to be space separated, but can't use a multiline string because that'll include newlines
+        BindPaths =
+          "${accountDir}:/tmp/accounts " +
+          "/var/lib/acme/${cert}:/tmp/out " +
+          "/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates " +
+          "/var/lib/acme/.lego/${cert}/${keyDir}:/tmp/keys";
+
+        # Only try loading the credentialsFile if the dns challenge is enabled
+        EnvironmentFile = mkIf useDns data.credentialsFile;
+      };
+
+      # Working directory will be /tmp
+      script = ''
+        set -euo pipefail
+
+        # Safely copy keyDir contents into certificates (it might be empty).
+        cp -af keys/. certificates/
+
+        # Check if we can renew
+        if [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' ]; then
+          lego ${renewOpts}
+
+        # Otherwise do a full run
+        else
+          lego ${runOpts}
+        fi
+
+        chmod 640 certificates/*
+        chmod -R 700 accounts/*
+
+        # Group might change between runs, re-apply it
+        chown 'acme:${data.group}' certificates/*
+
+        # Copy the key to keyDir
+        cp -pf 'certificates/${keyName}.key' 'keys/'
+
+        # Copy all certs to the "real" certs directory
+        CERT='certificates/${keyName}.crt'
+        CERT_CHANGED=no
+        if [ -e "$CERT" -a "$CERT" -nt out/fullchain.pem ]; then
+          CERT_CHANGED=yes
+          cp -p 'certificates/${keyName}.crt' out/fullchain.pem
+          cp -p 'certificates/${keyName}.key' out/key.pem
+          cp -p 'certificates/${keyName}.issuer.crt' out/chain.pem
+          ln -sf fullchain.pem out/cert.pem
+          cat out/key.pem out/fullchain.pem > out/full.pem
+        fi
+
+        if [ "$CERT_CHANGED" = "yes" ]; then
+          cd out
+          set +euo pipefail
+          ${data.postRun}
+        fi
+      '';
+    };
+  };
+
+  certConfigs = mapAttrs certToConfig cfg.certs;
+
   certOpts = { name, ... }: {
     options = {
+      # user option has been removed
+      user = mkOption {
+        visible = false;
+        default = "_mkRemovedOptionModule";
+      };
+
+      # allowKeysForGroup option has been removed
+      allowKeysForGroup = mkOption {
+        visible = false;
+        default = "_mkRemovedOptionModule";
+      };
+
+      # extraDomains was replaced with extraDomainNames
+      extraDomains = mkOption {
+        visible = false;
+        default = "_mkMergedOptionModule";
+      };
+
       webroot = mkOption {
         type = types.nullOr types.str;
         default = null;
@@ -41,35 +339,19 @@ let
         description = "Contact email address for the CA to be able to reach you.";
       };
 
-      user = mkOption {
-        type = types.str;
-        default = "root";
-        description = "User running the ACME client.";
-      };
-
       group = mkOption {
         type = types.str;
-        default = "root";
+        default = "acme";
         description = "Group running the ACME client.";
       };
 
-      allowKeysForGroup = mkOption {
-        type = types.bool;
-        default = false;
-        description = ''
-          Give read permissions to the specified group
-          (<option>security.acme.cert.&lt;name&gt;.group</option>) to read SSL private certificates.
-        '';
-      };
-
       postRun = mkOption {
         type = types.lines;
         default = "";
-        example = "systemctl reload nginx.service";
+        example = "cp full.pem backup.pem";
         description = ''
-          Commands to run after new certificates go live. Typically
-          the web server and other servers using certificates need to
-          be reloaded.
+          Commands to run after new certificates go live. Note that
+          these commands run as the acme user and configured group.
 
           Executed in the same directory with the new certificate.
         '';
@@ -82,18 +364,17 @@ let
         description = "Directory where certificate and other state is stored.";
       };
 
-      extraDomains = mkOption {
-        type = types.attrsOf (types.nullOr types.str);
-        default = {};
+      extraDomainNames = mkOption {
+        type = types.listOf types.str;
+        default = [];
         example = literalExample ''
-          {
-            "example.org" = null;
-            "mydomain.org" = null;
-          }
+          [
+            "example.org"
+            "mydomain.org"
+          ]
         '';
         description = ''
           A list of extra domain names, which are included in the one certificate to be issued.
-          Setting a distinct server root is deprecated and not functional in 20.03+
         '';
       };
 
@@ -176,24 +457,8 @@ let
     };
   };
 
-in
-
-{
-
-  ###### interface
-  imports = [
-    (mkRemovedOptionModule [ "security" "acme" "production" ] ''
-      Use security.acme.server to define your staging ACME server URL instead.
+in {
 
-      To use Let's Encrypt's staging server, use security.acme.server =
-      "https://acme-staging-v02.api.letsencrypt.org/directory".
-    ''
-    )
-    (mkRemovedOptionModule [ "security" "acme" "directory"] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.")
-    (mkRemovedOptionModule [ "security" "acme" "preDelay"] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
-    (mkRemovedOptionModule [ "security" "acme" "activationDelay"] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
-    (mkChangedOptionModule [ "security" "acme" "validMin"] [ "security" "acme" "validMinDays"] (config: config.security.acme.validMin / (24 * 3600)))
-  ];
   options = {
     security.acme = {
 
@@ -266,7 +531,7 @@ in
             "example.com" = {
               webroot = "/var/www/challenges/";
               email = "foo@example.com";
-              extraDomains = { "www.example.com" = null; "foo.example.com" = null; };
+              extraDomainNames = [ "www.example.com" "foo.example.com" ];
             };
             "bar.example.com" = {
               webroot = "/var/www/challenges/";
@@ -278,25 +543,40 @@ in
     };
   };
 
-  ###### implementation
+  imports = [
+    (mkRemovedOptionModule [ "security" "acme" "production" ] ''
+      Use security.acme.server to define your staging ACME server URL instead.
+
+      To use the let's encrypt staging server, use security.acme.server =
+      "https://acme-staging-v02.api.letsencrypt.org/directory".
+    ''
+    )
+    (mkRemovedOptionModule [ "security" "acme" "directory" ] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.")
+    (mkRemovedOptionModule [ "security" "acme" "preDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
+    (mkRemovedOptionModule [ "security" "acme" "activationDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal")
+    (mkChangedOptionModule [ "security" "acme" "validMin" ] [ "security" "acme" "validMinDays" ] (config: config.security.acme.validMin / (24 * 3600)))
+  ];
+
   config = mkMerge [
     (mkIf (cfg.certs != { }) {
 
+      # FIXME Most of these custom warnings and filters for security.acme.certs.* are required
+      # because using mkRemovedOptionModule/mkChangedOptionModule with attrsets isn't possible.
+      warnings = filter (w: w != "") (mapAttrsToList (cert: data: if data.extraDomains != "_mkMergedOptionModule" then ''
+        The option definition `security.acme.certs.${cert}.extraDomains` has changed
+        to `security.acme.certs.${cert}.extraDomainNames` and is now a list of strings.
+        Setting a custom webroot for extra domains is not possible, instead use separate certs.
+      '' else "") cfg.certs);
+
       assertions = let
-        certs = (mapAttrsToList (k: v: v) cfg.certs);
+        certs = attrValues cfg.certs;
       in [
         {
-          assertion = all (certOpts: certOpts.dnsProvider == null || certOpts.webroot == null) certs;
-          message = ''
-            Options `security.acme.certs.<name>.dnsProvider` and
-            `security.acme.certs.<name>.webroot` are mutually exclusive.
-          '';
-        }
-        {
           assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs;
           message = ''
             You must define `security.acme.certs.<name>.email` or
-            `security.acme.email` to register with the CA.
+            `security.acme.email` to register with the CA. Note that using
+            many different addresses for certs may trigger account rate limits.
           '';
         }
         {
@@ -307,184 +587,78 @@ in
             to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/
           '';
         }
-      ];
-
-      systemd.services = let
-          services = concatLists servicesLists;
-          servicesLists = mapAttrsToList certToServices cfg.certs;
-          certToServices = cert: data:
-              let
-                # StateDirectory must be relative, and will be created under /var/lib by systemd
-                lpath = "acme/${cert}";
-                apath = "/var/lib/${lpath}";
-                spath = "/var/lib/acme/.lego/${cert}";
-                keyName = builtins.replaceStrings ["*"] ["_"] data.domain;
-                requestedDomains = pipe ([ data.domain ] ++ (attrNames data.extraDomains)) [
-                  (domains: sort builtins.lessThan domains)
-                  (domains: concatStringsSep "," domains)
-                ];
-                fileMode = if data.allowKeysForGroup then "640" else "600";
-                globalOpts = [ "-d" data.domain "--email" data.email "--path" "." "--key-type" data.keyType ]
-                          ++ optionals (cfg.acceptTerms) [ "--accept-tos" ]
-                          ++ optionals (data.dnsProvider != null && !data.dnsPropagationCheck) [ "--dns.disable-cp" ]
-                          ++ concatLists (mapAttrsToList (name: root: [ "-d" name ]) data.extraDomains)
-                          ++ (if data.dnsProvider != null then [ "--dns" data.dnsProvider ] else [ "--http" "--http.webroot" data.webroot ])
-                          ++ optionals (cfg.server != null || data.server != null) ["--server" (if data.server == null then cfg.server else data.server)]
-                          ++ data.extraLegoFlags;
-                certOpts = optionals data.ocspMustStaple [ "--must-staple" ];
-                runOpts = escapeShellArgs (globalOpts ++ [ "run" ] ++ certOpts ++ data.extraLegoRunFlags);
-                renewOpts = escapeShellArgs (globalOpts ++
-                  [ "renew" "--days" (toString cfg.validMinDays) ] ++
-                  certOpts ++ data.extraLegoRenewFlags);
-                acmeService = {
-                  description = "Renew ACME Certificate for ${cert}";
-                  path = with pkgs; [ openssl ];
-                  after = [ "network.target" "network-online.target" ];
-                  wants = [ "network-online.target" ];
-                  wantedBy = mkIf (!config.boot.isContainer) [ "multi-user.target" ];
-                  serviceConfig = {
-                    Type = "oneshot";
-                    User = data.user;
-                    Group = data.group;
-                    PrivateTmp = true;
-                    StateDirectory = "acme/.lego/${cert} acme/.lego/accounts ${lpath}";
-                    StateDirectoryMode = if data.allowKeysForGroup then "750" else "700";
-                    WorkingDirectory = spath;
-                    # Only try loading the credentialsFile if the dns challenge is enabled
-                    EnvironmentFile = if data.dnsProvider != null then data.credentialsFile else null;
-                    ExecStart = pkgs.writeScript "acme-start" ''
-                      #!${pkgs.runtimeShell} -e
-                      test -L ${spath}/accounts -o -d ${spath}/accounts || ln -s ../accounts ${spath}/accounts
-                      LEGO_ARGS=(${runOpts})
-                      if [ -e ${spath}/certificates/${keyName}.crt ]; then
-                        REQUESTED_DOMAINS="${requestedDomains}"
-                        EXISTING_DOMAINS="$(openssl x509 -in ${spath}/certificates/${keyName}.crt -noout -ext subjectAltName | tail -n1 | sed -e 's/ *DNS://g')"
-                        if [ "''${REQUESTED_DOMAINS}" == "''${EXISTING_DOMAINS}" ]; then
-                          LEGO_ARGS=(${renewOpts})
-                        fi
-                      fi
-                      ${pkgs.lego}/bin/lego ''${LEGO_ARGS[@]}
-                    '';
-                    ExecStartPost =
-                      let
-                        script = pkgs.writeScript "acme-post-start" ''
-                          #!${pkgs.runtimeShell} -e
-                          cd ${apath}
-
-                          # Test that existing cert is older than new cert
-                          KEY=${spath}/certificates/${keyName}.key
-                          KEY_CHANGED=no
-                          if [ -e $KEY -a $KEY -nt key.pem ]; then
-                            KEY_CHANGED=yes
-                            cp -p ${spath}/certificates/${keyName}.key key.pem
-                            cp -p ${spath}/certificates/${keyName}.crt fullchain.pem
-                            cp -p ${spath}/certificates/${keyName}.issuer.crt chain.pem
-                            ln -sf fullchain.pem cert.pem
-                            cat key.pem fullchain.pem > full.pem
-                          fi
-
-                          chmod ${fileMode} *.pem
-                          chown '${data.user}:${data.group}' *.pem
-
-                          if [ "$KEY_CHANGED" = "yes" ]; then
-                            : # noop in case postRun is empty
-                            ${data.postRun}
-                          fi
-                        '';
-                      in
-                        "+${script}";
-                  };
-
-                };
-                selfsignedService = {
-                  description = "Create preliminary self-signed certificate for ${cert}";
-                  path = [ pkgs.openssl ];
-                  script =
-                    ''
-                      workdir="$(mktemp -d)"
-
-                      # Create CA
-                      openssl genrsa -des3 -passout pass:xxxx -out $workdir/ca.pass.key 2048
-                      openssl rsa -passin pass:xxxx -in $workdir/ca.pass.key -out $workdir/ca.key
-                      openssl req -new -key $workdir/ca.key -out $workdir/ca.csr \
-                        -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=Security Department/CN=example.com"
-                      openssl x509 -req -days 1 -in $workdir/ca.csr -signkey $workdir/ca.key -out $workdir/ca.crt
-
-                      # Create key
-                      openssl genrsa -des3 -passout pass:xxxx -out $workdir/server.pass.key 2048
-                      openssl rsa -passin pass:xxxx -in $workdir/server.pass.key -out $workdir/server.key
-                      openssl req -new -key $workdir/server.key -out $workdir/server.csr \
-                        -subj "/C=UK/ST=Warwickshire/L=Leamington/O=OrgName/OU=IT Department/CN=example.com"
-                      openssl x509 -req -days 1 -in $workdir/server.csr -CA $workdir/ca.crt \
-                        -CAkey $workdir/ca.key -CAserial $workdir/ca.srl -CAcreateserial \
-                        -out $workdir/server.crt
-
-                      # Copy key to destination
-                      cp $workdir/server.key ${apath}/key.pem
-
-                      # Create fullchain.pem (same format as "simp_le ... -f fullchain.pem" creates)
-                      cat $workdir/{server.crt,ca.crt} > "${apath}/fullchain.pem"
-
-                      # Create full.pem for e.g. lighttpd
-                      cat $workdir/{server.key,server.crt,ca.crt} > "${apath}/full.pem"
-
-                      # Give key acme permissions
-                      chown '${data.user}:${data.group}' "${apath}/"{key,fullchain,full}.pem
-                      chmod ${fileMode} "${apath}/"{key,fullchain,full}.pem
-                    '';
-                  serviceConfig = {
-                    Type = "oneshot";
-                    PrivateTmp = true;
-                    StateDirectory = lpath;
-                    User = data.user;
-                    Group = data.group;
-                  };
-                  unitConfig = {
-                    # Do not create self-signed key when key already exists
-                    ConditionPathExists = "!${apath}/key.pem";
-                  };
-                };
-              in (
-                [ { name = "acme-${cert}"; value = acmeService; } ]
-                ++ optional cfg.preliminarySelfsigned { name = "acme-selfsigned-${cert}"; value = selfsignedService; }
-              );
-          servicesAttr = listToAttrs services;
-        in
-          servicesAttr;
-
-      systemd.tmpfiles.rules =
-        map (data: "d ${data.webroot}/.well-known/acme-challenge - ${data.user} ${data.group}") (filter (data: data.webroot != null) (attrValues cfg.certs));
-
-      systemd.timers = let
-        # Allow systemd to pick a convenient time within the day
-        # to run the check.
-        # This allows the coalescing of multiple timer jobs.
-        # We divide by the number of certificates so that if you
-        # have many certificates, the renewals are distributed over
-        # the course of the day to avoid rate limits.
-        numCerts = length (attrNames cfg.certs);
-        _24hSecs = 60 * 60 * 24;
-        AccuracySec = "${toString (_24hSecs / numCerts)}s";
-      in flip mapAttrs' cfg.certs (cert: data: nameValuePair
-        ("acme-${cert}")
-        ({
-          description = "Renew ACME Certificate for ${cert}";
-          wantedBy = [ "timers.target" ];
-          timerConfig = {
-            OnCalendar = cfg.renewInterval;
-            Unit = "acme-${cert}.service";
-            Persistent = "yes";
-            inherit AccuracySec;
-            # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
-            RandomizedDelaySec = "24h";
-          };
-        })
-      );
-
-      systemd.targets.acme-selfsigned-certificates = mkIf cfg.preliminarySelfsigned {};
-      systemd.targets.acme-certificates = {};
-    })
+      ] ++ (builtins.concatLists (mapAttrsToList (cert: data: [
+        {
+          assertion = data.user == "_mkRemovedOptionModule";
+          message = ''
+            The option definition `security.acme.certs.${cert}.user' no longer has any effect; Please remove it.
+            Certificate user is now hard coded to the "acme" user. If you would
+            like another user to have access, consider adding them to the
+            "acme" group or changing security.acme.certs.${cert}.group.
+          '';
+        }
+        {
+          assertion = data.allowKeysForGroup == "_mkRemovedOptionModule";
+          message = ''
+            The option definition `security.acme.certs.${cert}.allowKeysForGroup' no longer has any effect; Please remove it.
+            All certs are readable by the configured group. If this is undesired,
+            consider changing security.acme.certs.${cert}.group to an unused group.
+          '';
+        }
+        # * in the cert value breaks building of systemd services, and makes
+        # referencing them as a user quite weird too. Best practice is to use
+        # the domain option.
+        {
+          assertion = ! hasInfix "*" cert;
+          message = ''
+            The cert option path `security.acme.certs.${cert}.dnsProvider`
+            cannot contain a * character.
+            Instead, set `security.acme.certs.${cert}.domain = "${cert}";`
+            and remove the wildcard from the path.
+          '';
+        }
+        {
+          assertion = data.dnsProvider == null || data.webroot == null;
+          message = ''
+            Options `security.acme.certs.${cert}.dnsProvider` and
+            `security.acme.certs.${cert}.webroot` are mutually exclusive.
+          '';
+        }
+      ]) cfg.certs));
 
+      users.users.acme = {
+        home = "/var/lib/acme";
+        group = "acme";
+        isSystemUser = true;
+      };
+
+      users.groups.acme = {};
+
+      systemd.services = {
+        "acme-fixperms" = userMigrationService;
+      } // (mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewService) certConfigs)
+        // (optionalAttrs (cfg.preliminarySelfsigned) ({
+        "acme-selfsigned-ca" = selfsignCAService;
+      } // (mapAttrs' (cert: conf: nameValuePair "acme-selfsigned-${cert}" conf.selfsignService) certConfigs)));
+
+      systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs;
+
+      # .lego and .lego/accounts specified to fix any incorrect permissions
+      systemd.tmpfiles.rules = [
+        "d /var/lib/acme/.lego - acme acme"
+        "d /var/lib/acme/.lego/accounts - acme acme"
+      ] ++ (unique (concatMap (conf: [
+          "d ${conf.accountDir} - acme acme"
+        ] ++ (optional (conf.webroot != null) "d ${conf.webroot}/.well-known/acme-challenge - acme ${conf.group}")
+      ) (attrValues certConfigs)));
+
+      # Create some targets which can be depended on to be "active" after cert renewals
+      systemd.targets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" {
+        wantedBy = [ "default.target" ];
+        wants = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ];
+        after = [ "acme-${cert}.service" "acme-selfsigned-${cert}.service" ];
+      }) certConfigs;
+    })
   ];
 
   meta = {
diff --git a/nixos/modules/security/acme.xml b/nixos/modules/security/acme.xml
index f802faee974..005eebd75c0 100644
--- a/nixos/modules/security/acme.xml
+++ b/nixos/modules/security/acme.xml
@@ -72,7 +72,7 @@ services.nginx = {
     "foo.example.com" = {
       <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
       <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true;
-      # All serverAliases will be added as <link linkend="opt-security.acme.certs._name_.extraDomains">extra domains</link> on the certificate.
+      # All serverAliases will be added as <link linkend="opt-security.acme.certs._name_.extraDomainNames">extra domain names</link> on the certificate.
       <link linkend="opt-services.nginx.virtualHosts._name_.serverAliases">serverAliases</link> = [ "bar.example.com" ];
       locations."/" = {
         <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.root">root</link> = "/var/www";
@@ -80,8 +80,8 @@ services.nginx = {
     };
 
     # We can also add a different vhost and reuse the same certificate
-    # but we have to append extraDomains manually.
-    <link linkend="opt-security.acme.certs._name_.extraDomains">security.acme.certs."foo.example.com".extraDomains."baz.example.com"</link> = null;
+    # but we have to append extraDomainNames manually.
+    <link linkend="opt-security.acme.certs._name_.extraDomainNames">security.acme.certs."foo.example.com".extraDomainNames</link> = [ "baz.example.com" ];
     "baz.example.com" = {
       <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true;
       <link linkend="opt-services.nginx.virtualHosts._name_.useACMEHost">useACMEHost</link> = "foo.example.com";
@@ -165,7 +165,7 @@ services.httpd = {
   # Since we have a wildcard vhost to handle port 80,
   # we can generate certs for anything!
   # Just make sure your DNS resolves them.
-  <link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains</link> = [ "mail.example.com" ];
+  <link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> = [ "mail.example.com" ];
 };
 </programlisting>
 
diff --git a/nixos/modules/services/networking/prosody.xml b/nixos/modules/services/networking/prosody.xml
index 7859cb1578b..14b7c60f1a0 100644
--- a/nixos/modules/services/networking/prosody.xml
+++ b/nixos/modules/services/networking/prosody.xml
@@ -65,7 +65,7 @@ services.prosody = {
    you'll need a single TLS certificate covering your main endpoint,
    the MUC one as well as the HTTP Upload one. We can generate such a
    certificate by leveraging the ACME
-   <link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains</link> module option.
+   <link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> module option.
  </para>
  <para>
    Provided the setup detailed in the previous section, you'll need the following acme configuration to generate
@@ -78,8 +78,7 @@ security.acme = {
     "example.org" = {
       <link linkend="opt-security.acme.certs._name_.webroot">webroot</link> = "/var/www/example.org";
       <link linkend="opt-security.acme.certs._name_.email">email</link> = "root@example.org";
-      <link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains."conference.example.org"</link> = null;
-      <link linkend="opt-security.acme.certs._name_.extraDomains">extraDomains."upload.example.org"</link> = null;
+      <link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> = [ "conference.example.org" "upload.example.org" ];
     };
   };
 };</programlisting>
diff --git a/nixos/modules/services/web-servers/apache-httpd/default.nix b/nixos/modules/services/web-servers/apache-httpd/default.nix
index fc4c2945394..90ea75dfa34 100644
--- a/nixos/modules/services/web-servers/apache-httpd/default.nix
+++ b/nixos/modules/services/web-servers/apache-httpd/default.nix
@@ -6,6 +6,8 @@ let
 
   cfg = config.services.httpd;
 
+  certs = config.security.acme.certs;
+
   runtimeDir = "/run/httpd";
 
   pkg = cfg.package.out;
@@ -26,6 +28,13 @@ let
 
   vhosts = attrValues cfg.virtualHosts;
 
+  # certName is used later on to determine systemd service names.
+  acmeEnabledVhosts = map (hostOpts: hostOpts // {
+    certName = if hostOpts.useACMEHost != null then hostOpts.useACMEHost else hostOpts.hostName;
+  }) (filter (hostOpts: hostOpts.enableACME || hostOpts.useACMEHost != null) vhosts);
+
+  dependentCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts);
+
   mkListenInfo = hostOpts:
     if hostOpts.listen != [] then hostOpts.listen
     else (
@@ -125,13 +134,13 @@ let
 
       useACME = hostOpts.enableACME || hostOpts.useACMEHost != null;
       sslCertDir =
-        if hostOpts.enableACME then config.security.acme.certs.${hostOpts.hostName}.directory
-        else if hostOpts.useACMEHost != null then config.security.acme.certs.${hostOpts.useACMEHost}.directory
+        if hostOpts.enableACME then certs.${hostOpts.hostName}.directory
+        else if hostOpts.useACMEHost != null then certs.${hostOpts.useACMEHost}.directory
         else abort "This case should never happen.";
 
-      sslServerCert = if useACME then "${sslCertDir}/full.pem" else hostOpts.sslServerCert;
+      sslServerCert = if useACME then "${sslCertDir}/fullchain.pem" else hostOpts.sslServerCert;
       sslServerKey = if useACME then "${sslCertDir}/key.pem" else hostOpts.sslServerKey;
-      sslServerChain = if useACME then "${sslCertDir}/fullchain.pem" else hostOpts.sslServerChain;
+      sslServerChain = if useACME then "${sslCertDir}/chain.pem" else hostOpts.sslServerChain;
 
       acmeChallenge = optionalString useACME ''
         Alias /.well-known/acme-challenge/ "${hostOpts.acmeRoot}/.well-known/acme-challenge/"
@@ -347,7 +356,6 @@ let
       cat ${php.phpIni} > $out
       echo "$options" >> $out
     '';
-
 in
 
 
@@ -647,14 +655,17 @@ in
       wwwrun.gid = config.ids.gids.wwwrun;
     };
 
-    security.acme.certs = mapAttrs (name: hostOpts: {
-      user = cfg.user;
-      group = mkDefault cfg.group;
-      email = if hostOpts.adminAddr != null then hostOpts.adminAddr else cfg.adminAddr;
-      webroot = hostOpts.acmeRoot;
-      extraDomains = genAttrs hostOpts.serverAliases (alias: null);
-      postRun = "systemctl reload httpd.service";
-    }) (filterAttrs (name: hostOpts: hostOpts.enableACME) cfg.virtualHosts);
+    security.acme.certs = let
+      acmePairs = map (hostOpts: nameValuePair hostOpts.hostName {
+        group = mkDefault cfg.group;
+        webroot = hostOpts.acmeRoot;
+        extraDomainNames = hostOpts.serverAliases;
+        # Use the vhost-specific email address if provided, otherwise let
+        # security.acme.email or security.acme.certs.<cert>.email be used.
+        email = mkOverride 2000 (if hostOpts.adminAddr != null then hostOpts.adminAddr else cfg.adminAddr);
+      # Filter for enableACME-only vhosts. Don't want to create dud certs
+      }) (filter (hostOpts: hostOpts.useACMEHost == null) acmeEnabledVhosts);
+    in listToAttrs acmePairs;
 
     environment.systemPackages = [
       apachectl
@@ -724,16 +735,12 @@ in
           "Z '${cfg.logDir}' - ${svc.User} ${svc.Group}"
         ];
 
-    systemd.services.httpd =
-      let
-        vhostsACME = filter (hostOpts: hostOpts.enableACME) vhosts;
-      in
-      { description = "Apache HTTPD";
-
+    systemd.services.httpd = {
+        description = "Apache HTTPD";
         wantedBy = [ "multi-user.target" ];
-        wants = concatLists (map (hostOpts: [ "acme-${hostOpts.hostName}.service" "acme-selfsigned-${hostOpts.hostName}.service" ]) vhostsACME);
-        after = [ "network.target" "fs.target" ] ++ map (hostOpts: "acme-selfsigned-${hostOpts.hostName}.service") vhostsACME;
-        before = map (hostOpts: "acme-${hostOpts.hostName}.service") vhostsACME;
+        wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) dependentCertNames);
+        after = [ "network.target" ] ++ map (certName: "acme-selfsigned-${certName}.service") dependentCertNames;
+        before = map (certName: "acme-${certName}.service") dependentCertNames;
 
         path = [ pkg pkgs.coreutils pkgs.gnugrep ];
 
@@ -767,5 +774,31 @@ in
         };
       };
 
+    # postRun hooks on cert renew can't be used to restart Apache since renewal
+    # runs as the unprivileged acme user. sslTargets are added to wantedBy + before
+    # which allows the acme-finished-$cert.target to signify the successful updating
+    # of certs end-to-end.
+    systemd.services.httpd-config-reload = let
+      sslServices = map (certName: "acme-${certName}.service") dependentCertNames;
+      sslTargets = map (certName: "acme-finished-${certName}.target") dependentCertNames;
+    in mkIf (sslServices != []) {
+      wantedBy = sslServices ++ [ "multi-user.target" ];
+      # Before the finished targets, after the renew services.
+      # This service might be needed for HTTP-01 challenges, but we only want to confirm
+      # certs are updated _after_ config has been reloaded.
+      before = sslTargets;
+      after = sslServices;
+      # Block reloading if not all certs exist yet.
+      # Happens when config changes add new vhosts/certs.
+      unitConfig.ConditionPathExists = map (certName: certs.${certName}.directory + "/fullchain.pem") dependentCertNames;
+      serviceConfig = {
+        Type = "oneshot";
+        TimeoutSec = 60;
+        ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active httpd.service";
+        ExecStartPre = "${pkg}/bin/apachectl configtest";
+        ExecStart = "/run/current-system/systemd/bin/systemctl reload httpd.service";
+      };
+    };
+
   };
 }
diff --git a/nixos/modules/services/web-servers/nginx/default.nix b/nixos/modules/services/web-servers/nginx/default.nix
index 461888c4cc4..975b56d4782 100644
--- a/nixos/modules/services/web-servers/nginx/default.nix
+++ b/nixos/modules/services/web-servers/nginx/default.nix
@@ -6,23 +6,23 @@ let
   cfg = config.services.nginx;
   certs = config.security.acme.certs;
   vhostsConfigs = mapAttrsToList (vhostName: vhostConfig: vhostConfig) virtualHosts;
-  acmeEnabledVhosts = filter (vhostConfig: vhostConfig.enableACME && vhostConfig.useACMEHost == null) vhostsConfigs;
+  acmeEnabledVhosts = filter (vhostConfig: vhostConfig.enableACME || vhostConfig.useACMEHost != null) vhostsConfigs;
+  dependentCertNames = unique (map (hostOpts: hostOpts.certName) acmeEnabledVhosts);
   virtualHosts = mapAttrs (vhostName: vhostConfig:
     let
       serverName = if vhostConfig.serverName != null
         then vhostConfig.serverName
         else vhostName;
+      certName = if vhostConfig.useACMEHost != null
+        then vhostConfig.useACMEHost
+        else serverName;
     in
     vhostConfig // {
-      inherit serverName;
-    } // (optionalAttrs vhostConfig.enableACME {
-      sslCertificate = "${certs.${serverName}.directory}/fullchain.pem";
-      sslCertificateKey = "${certs.${serverName}.directory}/key.pem";
-      sslTrustedCertificate = "${certs.${serverName}.directory}/full.pem";
-    }) // (optionalAttrs (vhostConfig.useACMEHost != null) {
-      sslCertificate = "${certs.${vhostConfig.useACMEHost}.directory}/fullchain.pem";
-      sslCertificateKey = "${certs.${vhostConfig.useACMEHost}.directory}/key.pem";
-      sslTrustedCertificate = "${certs.${vhostConfig.useACMEHost}.directory}/fullchain.pem";
+      inherit serverName certName;
+    } // (optionalAttrs (vhostConfig.enableACME || vhostConfig.useACMEHost != null) {
+      sslCertificate = "${certs.${certName}.directory}/fullchain.pem";
+      sslCertificateKey = "${certs.${certName}.directory}/key.pem";
+      sslTrustedCertificate = "${certs.${certName}.directory}/chain.pem";
     })
   ) cfg.virtualHosts;
   enableIPv6 = config.networking.enableIPv6;
@@ -691,12 +691,12 @@ in
     systemd.services.nginx = {
       description = "Nginx Web Server";
       wantedBy = [ "multi-user.target" ];
-      wants = concatLists (map (vhostConfig: ["acme-${vhostConfig.serverName}.service" "acme-selfsigned-${vhostConfig.serverName}.service"]) acmeEnabledVhosts);
-      after = [ "network.target" ] ++ map (vhostConfig: "acme-selfsigned-${vhostConfig.serverName}.service") acmeEnabledVhosts;
+      wants = concatLists (map (certName: [ "acme-finished-${certName}.target" ]) dependentCertNames);
+      after = [ "network.target" ] ++ map (certName: "acme-selfsigned-${certName}.service") dependentCertNames;
       # Nginx needs to be started in order to be able to request certificates
       # (it's hosting the acme challenge after all)
       # This fixes https://github.com/NixOS/nixpkgs/issues/81842
-      before = map (vhostConfig: "acme-${vhostConfig.serverName}.service") acmeEnabledVhosts;
+      before = map (certName: "acme-${certName}.service") dependentCertNames;
       stopIfChanged = false;
       preStart = ''
         ${cfg.preStart}
@@ -753,37 +753,41 @@ in
       source = configFile;
     };
 
-    systemd.services.nginx-config-reload = mkIf cfg.enableReload {
-      wants = [ "nginx.service" ];
-      wantedBy = [ "multi-user.target" ];
-      restartTriggers = [ configFile ];
-      # commented, because can cause extra delays during activate for this config:
-      #      services.nginx.virtualHosts."_".locations."/".proxyPass = "http://blabla:3000";
-      # stopIfChanged = false;
-      serviceConfig.Type = "oneshot";
-      serviceConfig.TimeoutSec = 60;
-      script = ''
-        if /run/current-system/systemd/bin/systemctl -q is-active nginx.service ; then
-          /run/current-system/systemd/bin/systemctl reload nginx.service
-        fi
-      '';
-      serviceConfig.RemainAfterExit = true;
+    # postRun hooks on cert renew can't be used to restart Nginx since renewal
+    # runs as the unprivileged acme user. sslTargets are added to wantedBy + before
+    # which allows the acme-finished-$cert.target to signify the successful updating
+    # of certs end-to-end.
+    systemd.services.nginx-config-reload = let
+      sslServices = map (certName: "acme-${certName}.service") dependentCertNames;
+      sslTargets = map (certName: "acme-finished-${certName}.target") dependentCertNames;
+    in mkIf (cfg.enableReload || sslServices != []) {
+      wants = optionals (cfg.enableReload) [ "nginx.service" ];
+      wantedBy = sslServices ++ [ "multi-user.target" ];
+      # Before the finished targets, after the renew services.
+      # This service might be needed for HTTP-01 challenges, but we only want to confirm
+      # certs are updated _after_ config has been reloaded.
+      before = sslTargets;
+      after = sslServices;
+      restartTriggers = optionals (cfg.enableReload) [ configFile ];
+      # Block reloading if not all certs exist yet.
+      # Happens when config changes add new vhosts/certs.
+      unitConfig.ConditionPathExists = optionals (sslServices != []) (map (certName: certs.${certName}.directory + "/fullchain.pem") dependentCertNames);
+      serviceConfig = {
+        Type = "oneshot";
+        TimeoutSec = 60;
+        ExecCondition = "/run/current-system/systemd/bin/systemctl -q is-active nginx.service";
+        ExecStart = "/run/current-system/systemd/bin/systemctl reload nginx.service";
+      };
     };
 
-    security.acme.certs = filterAttrs (n: v: v != {}) (
-      let
-        acmePairs = map (vhostConfig: { name = vhostConfig.serverName; value = {
-            user = cfg.user;
-            group = lib.mkDefault cfg.group;
-            webroot = vhostConfig.acmeRoot;
-            extraDomains = genAttrs vhostConfig.serverAliases (alias: null);
-            postRun = ''
-              /run/current-system/systemd/bin/systemctl reload nginx
-            '';
-          }; }) acmeEnabledVhosts;
-      in
-        listToAttrs acmePairs
-    );
+    security.acme.certs = let
+      acmePairs = map (vhostConfig: nameValuePair vhostConfig.serverName {
+        group = mkDefault cfg.group;
+        webroot = vhostConfig.acmeRoot;
+        extraDomainNames = vhostConfig.serverAliases;
+      # Filter for enableACME-only vhosts. Don't want to create dud certs
+      }) (filter (vhostConfig: vhostConfig.useACMEHost == null) acmeEnabledVhosts);
+    in listToAttrs acmePairs;
 
     users.users = optionalAttrs (cfg.user == "nginx") {
       nginx = {
diff --git a/nixos/tests/acme.nix b/nixos/tests/acme.nix
index a8188473721..37e82993b4e 100644
--- a/nixos/tests/acme.nix
+++ b/nixos/tests/acme.nix
@@ -1,29 +1,43 @@
 let
   commonConfig = ./common/acme/client;
 
-  dnsScript = {writeScript, dnsAddress, bash, curl}: writeScript "dns-hook.sh" ''
-    #!${bash}/bin/bash
+  dnsServerIP = nodes: nodes.dnsserver.config.networking.primaryIPAddress;
+
+  dnsScript = {pkgs, nodes}: let
+    dnsAddress = dnsServerIP nodes;
+  in pkgs.writeShellScript "dns-hook.sh" ''
     set -euo pipefail
     echo '[INFO]' "[$2]" 'dns-hook.sh' $*
     if [ "$1" = "present" ]; then
-      ${curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
+      ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'", "value": "'"$3"'"}' http://${dnsAddress}:8055/set-txt
     else
-      ${curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
+      ${pkgs.curl}/bin/curl --data '{"host": "'"$2"'"}' http://${dnsAddress}:8055/clear-txt
     fi
   '';
 
+  documentRoot = pkgs: pkgs.runCommand "docroot" {} ''
+    mkdir -p "$out"
+    echo hello world > "$out/index.html"
+  '';
+
+  vhostBase = pkgs: {
+    forceSSL = true;
+    locations."/".root = documentRoot pkgs;
+  };
+
 in import ./make-test-python.nix ({ lib, ... }: {
   name = "acme";
   meta.maintainers = lib.teams.acme.members;
 
-  nodes = rec {
+  nodes = {
+    # The fake ACME server which will respond to client requests
     acme = { nodes, lib, ... }: {
       imports = [ ./common/acme/server ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
+      networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
     };
 
+    # A fake DNS server which can be configured with records as desired
+    # Used to test DNS-01 challenge
     dnsserver = { nodes, pkgs, ... }: {
       networking.firewall.allowedTCPPorts = [ 8055 53 ];
       networking.firewall.allowedUDPPorts = [ 53 ];
@@ -39,112 +53,87 @@ in import ./make-test-python.nix ({ lib, ... }: {
       };
     };
 
-    acmeStandalone = { nodes, lib, config, pkgs, ... }: {
-      imports = [ commonConfig ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
-      networking.firewall.allowedTCPPorts = [ 80 ];
-      security.acme.certs."standalone.test" = {
-        webroot = "/var/lib/acme/acme-challenges";
-      };
-      systemd.targets."acme-finished-standalone.test" = {
-        after = [ "acme-standalone.test.service" ];
-        wantedBy = [ "acme-standalone.test.service" ];
-      };
-      services.nginx.enable = true;
-      services.nginx.virtualHosts."standalone.test" = {
-        locations."/.well-known/acme-challenge".root = "/var/lib/acme/acme-challenges";
-      };
-    };
-
-    webserver = { nodes, config, pkgs, lib, ... }: {
+    # A web server which will be the node requesting certs
+    webserver = { pkgs, nodes, lib, config, ... }: {
       imports = [ commonConfig ];
+      networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
       networking.firewall.allowedTCPPorts = [ 80 443 ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
-
-      # A target remains active. Use this to probe the fact that
-      # a service fired eventhough it is not RemainAfterExit
-      systemd.targets."acme-finished-a.example.test" = {
-        after = [ "acme-a.example.test.service" ];
-        wantedBy = [ "acme-a.example.test.service" ];
-      };
 
-      services.nginx.enable = true;
+      # OpenSSL will be used for more thorough certificate validation
+      environment.systemPackages = [ pkgs.openssl ];
 
-      services.nginx.virtualHosts."a.example.test" = {
+      # First tests configure a basic cert and run a bunch of openssl checks
+      services.nginx.enable = true;
+      services.nginx.virtualHosts."a.example.test" = (vhostBase pkgs) // {
         enableACME = true;
-        forceSSL = true;
-        locations."/".root = pkgs.runCommand "docroot" {} ''
-          mkdir -p "$out"
-          echo hello world > "$out/index.html"
-        '';
       };
 
-      security.acme.server = "https://acme.test/dir";
+      # Used to determine if service reload was triggered
+      systemd.targets.test-renew-nginx = {
+        wants = [ "acme-a.example.test.service" ];
+        after = [ "acme-a.example.test.service" "nginx-config-reload.service" ];
+      };
+
+      # Cert config changes will not cause the nginx configuration to change.
+      # This tests that the reload service is correctly triggered.
+      specialisation.cert-change.configuration = { pkgs, ... }: {
+        security.acme.certs."a.example.test".keyType = "ec384";
+      };
 
-      specialisation.second-cert.configuration = {pkgs, ...}: {
-        systemd.targets."acme-finished-b.example.test" = {
-          after = [ "acme-b.example.test.service" ];
-          wantedBy = [ "acme-b.example.test.service" ];
+      # Now adding an alias to ensure that the certs are updated
+      specialisation.nginx-aliases.configuration = { pkgs, ... }: {
+        services.nginx.virtualHosts."a.example.test" = {
+          serverAliases = [ "b.example.test" ];
         };
-        services.nginx.virtualHosts."b.example.test" = {
-          enableACME = true;
+      };
+
+      # Test using Apache HTTPD
+      specialisation.httpd-aliases.configuration = { pkgs, config, lib, ... }: {
+        services.nginx.enable = lib.mkForce false;
+        services.httpd.enable = true;
+        services.httpd.adminAddr = config.security.acme.email;
+        services.httpd.virtualHosts."c.example.test" = {
+          serverAliases = [ "d.example.test" ];
           forceSSL = true;
-          locations."/".root = pkgs.runCommand "docroot" {} ''
-            mkdir -p "$out"
-            echo hello world > "$out/index.html"
-          '';
+          enableACME = true;
+          documentRoot = documentRoot pkgs;
+        };
+
+        # Used to determine if service reload was triggered
+        systemd.targets.test-renew-httpd = {
+          wants = [ "acme-c.example.test.service" ];
+          after = [ "acme-c.example.test.service" "httpd-config-reload.service" ];
         };
       };
 
-      specialisation.dns-01.configuration = {pkgs, config, nodes, lib, ...}: {
+      # Validation via DNS-01 challenge
+      specialisation.dns-01.configuration = { pkgs, config, nodes, ... }: {
         security.acme.certs."example.test" = {
           domain = "*.example.test";
+          group = config.services.nginx.group;
           dnsProvider = "exec";
           dnsPropagationCheck = false;
-          credentialsFile = with pkgs; writeText "wildcard.env" ''
-            EXEC_PATH=${dnsScript { inherit writeScript bash curl; dnsAddress = nodes.dnsserver.config.networking.primaryIPAddress; }}
+          credentialsFile = pkgs.writeText "wildcard.env" ''
+            EXEC_PATH=${dnsScript { inherit pkgs nodes; }}
           '';
-          user = config.services.nginx.user;
-          group = config.services.nginx.group;
         };
-        systemd.targets."acme-finished-example.test" = {
-          after = [ "acme-example.test.service" ];
-          wantedBy = [ "acme-example.test.service" ];
-        };
-        systemd.services."acme-example.test" = {
-          before = [ "nginx.service" ];
-          wantedBy = [ "nginx.service" ];
-        };
-        services.nginx.virtualHosts."c.example.test" = {
-          forceSSL = true;
-          sslCertificate = config.security.acme.certs."example.test".directory + "/cert.pem";
-          sslTrustedCertificate = config.security.acme.certs."example.test".directory + "/full.pem";
-          sslCertificateKey = config.security.acme.certs."example.test".directory + "/key.pem";
-          locations."/".root = pkgs.runCommand "docroot" {} ''
-            mkdir -p "$out"
-            echo hello world > "$out/index.html"
-          '';
+
+        services.nginx.virtualHosts."dns.example.test" = (vhostBase pkgs) // {
+          useACMEHost = "example.test";
         };
       };
 
-      # When nginx depends on a service that is slow to start up, requesting used to fail
-      # certificates fail.  Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
-      specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ...}: {
+      # Validate service relationships by adding a slow start service to nginx' wants.
+      # Reproducer for https://github.com/NixOS/nixpkgs/issues/81842
+      specialisation.slow-startup.configuration = { pkgs, config, nodes, lib, ... }: {
         systemd.services.my-slow-service = {
           wantedBy = [ "multi-user.target" "nginx.service" ];
           before = [ "nginx.service" ];
           preStart = "sleep 5";
           script = "${pkgs.python3}/bin/python -m http.server";
         };
-        systemd.targets."acme-finished-d.example.com" = {
-          after = [ "acme-d.example.com.service" ];
-          wantedBy = [ "acme-d.example.com.service" ];
-        };
-        services.nginx.virtualHosts."d.example.com" = {
+
+        services.nginx.virtualHosts."slow.example.com" = {
           forceSSL = true;
           enableACME = true;
           locations."/".proxyPass = "http://localhost:8000";
@@ -152,11 +141,13 @@ in import ./make-test-python.nix ({ lib, ... }: {
       };
     };
 
-    client = {nodes, lib, ...}: {
+    # The client will be used to curl the webserver to validate configuration
+    client = {nodes, lib, pkgs, ...}: {
       imports = [ commonConfig ];
-      networking.nameservers = lib.mkForce [
-        nodes.dnsserver.config.networking.primaryIPAddress
-      ];
+      networking.nameservers = lib.mkForce [ (dnsServerIP nodes) ];
+
+      # OpenSSL will be used for more thorough certificate validation
+      environment.systemPackages = [ pkgs.openssl ];
     };
   };
 
@@ -167,73 +158,155 @@ in import ./make-test-python.nix ({ lib, ... }: {
     in
     # Note, wait_for_unit does not work for oneshot services that do not have RemainAfterExit=true,
     # this is because a oneshot goes from inactive => activating => inactive, and never
-    # reaches the active state. To work around this, we create some mock target units which
-    # get pulled in by the oneshot units. The target units linger after activation, and hence we
-    # can use them to probe that a oneshot fired. It is a bit ugly, but it is the best we can do
+    # reaches the active state. Targets do not have this issue.
+
     ''
+      has_switched = False
+
+
+      def switch_to(node, name):
+          global has_switched
+          if has_switched:
+              node.succeed(
+                  "${switchToNewServer}"
+              )
+          has_switched = True
+          node.succeed(
+              "/run/current-system/specialisation/{}/bin/switch-to-configuration test".format(
+                  name
+              )
+          )
+
+
+      # Ensures the issuer of our cert matches the chain
+      # and matches the issuer we expect it to be.
+      # It's a good validation to ensure the cert.pem and fullchain.pem
+      # are not still selfsigned afer verification
+      def check_issuer(node, cert_name, issuer):
+          for fname in ("cert.pem", "fullchain.pem"):
+              node.succeed(
+                  (
+                      """openssl x509 -noout -issuer -in /var/lib/acme/{cert_name}/{fname} \
+                        | tee /proc/self/fd/2 \
+                        | cut -d'=' -f2- \
+                        | grep "$(openssl x509 -noout -subject -in /var/lib/acme/{cert_name}/chain.pem \
+                        | cut -d'=' -f2-)\" \
+                        | grep -i '{issuer}'
+                      """
+                  ).format(cert_name=cert_name, issuer=issuer, fname=fname)
+              )
+
+
+      # Ensure cert comes before chain in fullchain.pem
+      def check_fullchain(node, cert_name):
+          node.succeed(
+              (
+                  """openssl crl2pkcs7 -nocrl -certfile /var/lib/acme/{cert_name}/fullchain.pem \
+                    | tee /proc/self/fd/2 \
+                    | openssl pkcs7 -print_certs -noout | head -1 | grep {cert_name}
+                  """
+              ).format(cert_name=cert_name)
+          )
+
+
+      def check_connection(node, domain):
+          node.succeed(
+              (
+                  """openssl s_client -brief -verify 2 -verify_return_error -CAfile /tmp/ca.crt \
+                    -servername {domain} -connect {domain}:443 < /dev/null 2>&1 \
+                    | tee /proc/self/fd/2
+                  """
+              ).format(domain=domain)
+          )
+
+
       client.start()
       dnsserver.start()
 
-      acme.wait_for_unit("default.target")
       dnsserver.wait_for_unit("pebble-challtestsrv.service")
+      client.wait_for_unit("default.target")
+
       client.succeed(
-          'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
-      )
-      client.succeed(
-          'curl --data \'{"host": "standalone.test", "addresses": ["${nodes.acmeStandalone.config.networking.primaryIPAddress}"]}\' http://${nodes.dnsserver.config.networking.primaryIPAddress}:8055/add-a'
+          'curl --data \'{"host": "acme.test", "addresses": ["${nodes.acme.config.networking.primaryIPAddress}"]}\' http://${dnsServerIP nodes}:8055/add-a'
       )
 
       acme.start()
-      acmeStandalone.start()
+      webserver.start()
 
       acme.wait_for_unit("default.target")
       acme.wait_for_unit("pebble.service")
 
-      with subtest("can request certificate with HTTPS-01 challenge"):
-          acmeStandalone.wait_for_unit("default.target")
-          acmeStandalone.succeed("systemctl start acme-standalone.test.service")
-          acmeStandalone.wait_for_unit("acme-finished-standalone.test.target")
-
-      client.wait_for_unit("default.target")
-
       client.succeed("curl https://acme.test:15000/roots/0 > /tmp/ca.crt")
       client.succeed("curl https://acme.test:15000/intermediate-keys/0 >> /tmp/ca.crt")
 
-      with subtest("Can request certificate for nginx service"):
+      with subtest("Can request certificate with HTTPS-01 challenge"):
           webserver.wait_for_unit("acme-finished-a.example.test.target")
-          client.succeed(
-              "curl --cacert /tmp/ca.crt https://a.example.test/ | grep -qF 'hello world'"
-          )
+          check_fullchain(webserver, "a.example.test")
+          check_issuer(webserver, "a.example.test", "pebble")
+          check_connection(client, "a.example.test")
 
-      with subtest("Can add another certificate for nginx service"):
-          webserver.succeed(
-              "/run/current-system/specialisation/second-cert/bin/switch-to-configuration test"
-          )
-          webserver.wait_for_unit("acme-finished-b.example.test.target")
+      with subtest("Can generate valid selfsigned certs"):
+          webserver.succeed("systemctl clean acme-a.example.test.service --what=state")
+          webserver.succeed("systemctl start acme-selfsigned-a.example.test.service")
+          check_fullchain(webserver, "a.example.test")
+          check_issuer(webserver, "a.example.test", "minica")
+          # Will succeed if nginx can load the certs
+          webserver.succeed("systemctl start nginx-config-reload.service")
+
+      with subtest("Can reload nginx when timer triggers renewal"):
+          # These syncs are required because of weird scenarios where the cert files
+          # were not actually changed when the checks run.
+          webserver.succeed("sync")
+          webserver.succeed("systemctl start test-renew-nginx.target")
+          webserver.succeed("sync")
+          check_issuer(webserver, "a.example.test", "pebble")
+          check_connection(client, "a.example.test")
+
+      with subtest("Can reload web server when cert configuration changes"):
+          switch_to(webserver, "cert-change")
+          webserver.wait_for_unit("acme-finished-a.example.test.target")
           client.succeed(
-              "curl --cacert /tmp/ca.crt https://b.example.test/ | grep -qF 'hello world'"
+              """openssl s_client -CAfile /tmp/ca.crt -connect a.example.test:443 < /dev/null \
+                | openssl x509 -noout -text | grep -i Public-Key | grep 384
+          """
           )
 
+      with subtest("Can request certificate with HTTPS-01 when nginx startup is delayed"):
+          switch_to(webserver, "slow-startup")
+          webserver.wait_for_unit("acme-finished-slow.example.com.target")
+          check_issuer(webserver, "slow.example.com", "pebble")
+          check_connection(client, "slow.example.com")
+
+      with subtest("Can request certificate for vhost + aliases (nginx)"):
+          switch_to(webserver, "nginx-aliases")
+          webserver.wait_for_unit("acme-finished-a.example.test.target")
+          check_issuer(webserver, "a.example.test", "pebble")
+          check_connection(client, "a.example.test")
+          check_connection(client, "b.example.test")
+
+      with subtest("Can request certificates for vhost + aliases (apache-httpd)"):
+          switch_to(webserver, "httpd-aliases")
+          webserver.wait_for_unit("acme-finished-c.example.test.target")
+          check_issuer(webserver, "c.example.test", "pebble")
+          check_connection(client, "c.example.test")
+          check_connection(client, "d.example.test")
+
+      with subtest("Can reload httpd when timer triggers renewal"):
+          # Switch to selfsigned first
+          webserver.succeed("systemctl clean acme-c.example.test.service --what=state")
+          webserver.succeed("systemctl start acme-selfsigned-c.example.test.service")
+          webserver.succeed("sync")
+          check_issuer(webserver, "c.example.test", "minica")
+          webserver.succeed("systemctl start httpd-config-reload.service")
+          webserver.succeed("systemctl start test-renew-httpd.target")
+          webserver.succeed("sync")
+          check_issuer(webserver, "c.example.test", "pebble")
+          check_connection(client, "c.example.test")
+
       with subtest("Can request wildcard certificates using DNS-01 challenge"):
-          webserver.succeed(
-              "${switchToNewServer}"
-          )
-          webserver.succeed(
-              "/run/current-system/specialisation/dns-01/bin/switch-to-configuration test"
-          )
+          switch_to(webserver, "dns-01")
           webserver.wait_for_unit("acme-finished-example.test.target")
-          client.succeed(
-              "curl --cacert /tmp/ca.crt https://c.example.test/ | grep -qF 'hello world'"
-          )
-
-      with subtest("Can request certificate of nginx when startup is delayed"):
-          webserver.succeed(
-              "${switchToNewServer}"
-          )
-          webserver.succeed(
-              "/run/current-system/specialisation/slow-startup/bin/switch-to-configuration test"
-          )
-          webserver.wait_for_unit("acme-finished-d.example.com.target")
-          client.succeed("curl --cacert /tmp/ca.crt https://d.example.com/")
+          check_issuer(webserver, "example.test", "pebble")
+          check_connection(client, "dns.example.test")
     '';
 })
diff --git a/nixos/tests/common/acme/client/default.nix b/nixos/tests/common/acme/client/default.nix
index 80893da0252..1e9885e375c 100644
--- a/nixos/tests/common/acme/client/default.nix
+++ b/nixos/tests/common/acme/client/default.nix
@@ -1,15 +1,14 @@
 { lib, nodes, pkgs, ... }:
-
 let
-  acme-ca = nodes.acme.config.test-support.acme.caCert;
-in
+  caCert = nodes.acme.config.test-support.acme.caCert;
+  caDomain = nodes.acme.config.test-support.acme.caDomain;
 
-{
+in {
   security.acme = {
-    server = "https://acme.test/dir";
+    server = "https://${caDomain}/dir";
     email = "hostmaster@example.test";
     acceptTerms = true;
   };
 
-  security.pki.certificateFiles = [ acme-ca ];
+  security.pki.certificateFiles = [ caCert ];
 }
diff --git a/nixos/tests/common/acme/server/default.nix b/nixos/tests/common/acme/server/default.nix
index 1a0ee882572..4d8e664c4e1 100644
--- a/nixos/tests/common/acme/server/default.nix
+++ b/nixos/tests/common/acme/server/default.nix
@@ -3,7 +3,7 @@
 #   config.test-support.acme.caCert
 #
 # This value can be used inside the configuration of other test nodes to inject
-# the snakeoil certificate into security.pki.certificateFiles or into package
+# the test certificate into security.pki.certificateFiles or into package
 # overlays.
 #
 # Another value that's needed if you don't use a custom resolver (see below for
@@ -50,19 +50,13 @@
 # Also make sure that whenever you use a resolver from a different test node
 # that it has to be started _before_ the ACME service.
 { config, pkgs, lib, ... }:
-
-
 let
-  snakeOilCerts = import ./snakeoil-certs.nix;
-
-  wfeDomain = "acme.test";
-  wfeCertFile = snakeOilCerts.${wfeDomain}.cert;
-  wfeKeyFile = snakeOilCerts.${wfeDomain}.key;
+  testCerts = import ./snakeoil-certs.nix {
+    minica = pkgs.minica;
+    mkDerivation = pkgs.stdenv.mkDerivation;
+  };
+  domain = testCerts.domain;
 
-  siteDomain = "acme.test";
-  siteCertFile = snakeOilCerts.${siteDomain}.cert;
-  siteKeyFile = snakeOilCerts.${siteDomain}.key;
-  pebble = pkgs.pebble;
   resolver = let
     message = "You need to define a resolver for the acme test module.";
     firstNS = lib.head config.networking.nameservers;
@@ -71,8 +65,9 @@ let
   pebbleConf.pebble = {
     listenAddress = "0.0.0.0:443";
     managementListenAddress = "0.0.0.0:15000";
-    certificate = snakeOilCerts.${wfeDomain}.cert;
-    privateKey = snakeOilCerts.${wfeDomain}.key;
+    # These certs and keys are used for the Web Front End (WFE)
+    certificate = testCerts.${domain}.cert;
+    privateKey = testCerts.${domain}.key;
     httpPort = 80;
     tlsPort = 443;
     ocspResponderURL = "http://0.0.0.0:4002";
@@ -80,18 +75,30 @@ let
   };
 
   pebbleConfFile = pkgs.writeText "pebble.conf" (builtins.toJSON pebbleConf);
-  pebbleDataDir = "/root/pebble";
 
 in {
   imports = [ ../../resolver.nix ];
 
-  options.test-support.acme.caCert = lib.mkOption {
-    type = lib.types.path;
-    description = ''
-      A certificate file to use with the <literal>nodes</literal> attribute to
-      inject the snakeoil CA certificate used in the ACME server into
-      <option>security.pki.certificateFiles</option>.
-    '';
+  options.test-support.acme = with lib; {
+    caDomain = mkOption {
+      type = types.str;
+      readOnly = true;
+      default = domain;
+      description = ''
+        A domain name to use with the <literal>nodes</literal> attribute to
+        identify the CA server.
+      '';
+    };
+    caCert = mkOption {
+      type = types.path;
+      readOnly = true;
+      default = testCerts.ca.cert;
+      description = ''
+        A certificate file to use with the <literal>nodes</literal> attribute to
+        inject the test CA certificate used in the ACME server into
+        <option>security.pki.certificateFiles</option>.
+      '';
+    };
   };
 
   config = {
@@ -99,35 +106,32 @@ in {
       resolver.enable = let
         isLocalResolver = config.networking.nameservers == [ "127.0.0.1" ];
       in lib.mkOverride 900 isLocalResolver;
-      acme.caCert = snakeOilCerts.ca.cert;
     };
 
     # This has priority 140, because modules/testing/test-instrumentation.nix
     # already overrides this with priority 150.
     networking.nameservers = lib.mkOverride 140 [ "127.0.0.1" ];
-    networking.firewall.enable = false;
+    networking.firewall.allowedTCPPorts = [ 80 443 15000 4002 ];
 
     networking.extraHosts = ''
-      127.0.0.1 ${wfeDomain}
-      ${config.networking.primaryIPAddress} ${wfeDomain} ${siteDomain}
+      127.0.0.1 ${domain}
+      ${config.networking.primaryIPAddress} ${domain}
     '';
 
     systemd.services = {
       pebble = {
         enable = true;
         description = "Pebble ACME server";
-        requires = [ ];
         wantedBy = [ "network.target" ];
-        preStart = ''
-          mkdir ${pebbleDataDir}
-        '';
-        script = ''
-          cd ${pebbleDataDir}
-          ${pebble}/bin/pebble -config ${pebbleConfFile}
-        '';
+
         serviceConfig = {
+          RuntimeDirectory = "pebble";
+          WorkingDirectory = "/run/pebble";
+
           # Required to bind on privileged ports.
           AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
+
+          ExecStart = "${pkgs.pebble}/bin/pebble -config ${pebbleConfFile}";
         };
       };
     };
diff --git a/nixos/tests/common/acme/server/mkcerts.nix b/nixos/tests/common/acme/server/mkcerts.nix
deleted file mode 100644
index c9616bf9672..00000000000
--- a/nixos/tests/common/acme/server/mkcerts.nix
+++ /dev/null
@@ -1,69 +0,0 @@
-{ pkgs ? import <nixpkgs> {}
-, lib ? pkgs.lib
-, domains ? [ "acme.test" ]
-}:
-
-pkgs.runCommand "acme-snakeoil-ca" {
-  nativeBuildInputs = [ pkgs.openssl ];
-} ''
-  addpem() {
-    local file="$1"; shift
-    local storeFileName="$(IFS=.; echo "$*")"
-
-    echo -n "  " >> "$out"
-
-    # Every following argument is an attribute, so let's recurse and check
-    # every attribute whether it must be quoted and write it into $out.
-    while [ -n "$1" ]; do
-      if expr match "$1" '^[a-zA-Z][a-zA-Z0-9]*$' > /dev/null; then
-        echo -n "$1" >> "$out"
-      else
-        echo -n '"' >> "$out"
-        echo -n "$1" | sed -e 's/["$]/\\&/g' >> "$out"
-        echo -n '"' >> "$out"
-      fi
-      shift
-      [ -z "$1" ] || echo -n . >> "$out"
-    done
-
-    echo " = builtins.toFile \"$storeFileName\" '''" >> "$out"
-    sed -e 's/^/    /' "$file" >> "$out"
-
-    echo "  ''';" >> "$out"
-  }
-
-  echo '# Generated via mkcert.sh in the same directory.' > "$out"
-  echo '{' >> "$out"
-
-  openssl req -newkey rsa:4096 -x509 -sha256 -days 36500 \
-    -subj '/CN=Snakeoil CA' -nodes -out ca.pem -keyout ca.key
-
-  addpem ca.key ca key
-  addpem ca.pem ca cert
-
-  ${lib.concatMapStrings (fqdn: let
-    opensslConfig = pkgs.writeText "snakeoil.cnf" ''
-      [req]
-      default_bits = 4096
-      prompt = no
-      default_md = sha256
-      req_extensions = req_ext
-      distinguished_name = dn
-      [dn]
-      CN = ${fqdn}
-      [req_ext]
-      subjectAltName = DNS:${fqdn}
-    '';
-  in ''
-    export OPENSSL_CONF=${lib.escapeShellArg opensslConfig}
-    openssl genrsa -out snakeoil.key 4096
-    openssl req -new -key snakeoil.key -out snakeoil.csr
-    openssl x509 -req -in snakeoil.csr -sha256 -set_serial 666 \
-      -CA ca.pem -CAkey ca.key -out snakeoil.pem -days 36500 \
-      -extfile "$OPENSSL_CONF" -extensions req_ext
-    addpem snakeoil.key ${lib.escapeShellArg fqdn} key
-    addpem snakeoil.pem ${lib.escapeShellArg fqdn} cert
-  '') domains}
-
-  echo '}' >> "$out"
-''
diff --git a/nixos/tests/common/acme/server/mkcerts.sh b/nixos/tests/common/acme/server/mkcerts.sh
deleted file mode 100755
index cc7f8ca650d..00000000000
--- a/nixos/tests/common/acme/server/mkcerts.sh
+++ /dev/null
@@ -1,6 +0,0 @@
-#!/usr/bin/env nix-shell
-#!nix-shell -p nix bash -i bash
-set -e
-cd "$(dirname "$0")"
-storepath="$(nix-build --no-out-link mkcerts.nix)"
-cat "$storepath" > snakeoil-certs.nix
diff --git a/nixos/tests/common/acme/server/snakeoil-certs.nix b/nixos/tests/common/acme/server/snakeoil-certs.nix
index 7325b027c7e..4b6a38b8fa3 100644
--- a/nixos/tests/common/acme/server/snakeoil-certs.nix
+++ b/nixos/tests/common/acme/server/snakeoil-certs.nix
@@ -1,172 +1,37 @@
-# Generated via mkcert.sh in the same directory.
-{
-  ca.key = builtins.toFile "ca.key" ''
-    -----BEGIN PRIVATE KEY-----
-    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
-    s9o9Sr49cibZ3CbMjCSNE3AOeVE1/Q==
-    -----END PRIVATE KEY-----
-  '';
-  ca.cert = builtins.toFile "ca.cert" ''
-    -----BEGIN CERTIFICATE-----
-    MIIFDzCCAvegAwIBAgIUX0P6NfX4gRUpFz+TNV/f26GHokgwDQYJKoZIhvcNAQEL
-    BQAwFjEUMBIGA1UEAwwLU25ha2VvaWwgQ0EwIBcNMjAwODI0MDc0MjEyWhgPMjEy
-    MDA3MzEwNzQyMTJaMBYxFDASBgNVBAMMC1NuYWtlb2lsIENBMIICIjANBgkqhkiG
-    9w0BAQEFAAOCAg8AMIICCgKCAgEA3dRuzhaVyKB5668XcPoYiVQ2Op0AwyhbfRQS
-    sJb+8doQr1+258rx2i+x+tXPvNDHBjyEq0J9/jI7kWbqsGsePQ3XTV2xreFqhpce
-    CwHiN+zoDVFM/X6Da9qww1e+OL8KdePzw9X956qVnzoM87IfK8WnNJmeUnvR8TiK
-    SOzUTbhyY4+x1meGMXjr4tvPa5qbtCT3KduZ48O4ehTXx7vBhqd+FI+sAMbm3Ucn
-    1YlfM/RTH24JS7gPYtTokstzUKavFcARvrv9Iguz0LEI8Uq2oELtubHwE2CKxSUO
-    GQr4dJ8aijHsjzwYunQHDKWtgGQKrar/gsFnaN+gbUuV4gu6wKYGJ/srXikB0m5w
-    ohtlv26Em34PZUZN8ONXREhDQgA1Woy29xKL76dLQTvwQC7nb1o8wD26V5XlTa+p
-    4FrRpbGUwuy1w4AanVtipc6WRnM/j1twE5cIqf2RUbewNebuXVZFj1aD7kNMNU8P
-    oPXcVhUzZN4ftneyvOyMUG3L6sXiA0hFRTK8aNHuAhtrnTHHpWcAEZeojUwBE2ts
-    yVtF7QHRlTPh2NUv5FOlPSd57mw7bQJADAAYz8Moyxu4X8lqlB3/cj7OPRrH7QFb
-    yMvFyHrtLIQ9QRtdf1c78KVAokXPm7blotnq2eGXz6EOWKEvYLeEviKc+0zxgZfg
-    FJPRztkCAwEAAaNTMFEwHQYDVR0OBBYEFNhBZxryvykCjfPO85xB3wof2enAMB8G
-    A1UdIwQYMBaAFNhBZxryvykCjfPO85xB3wof2enAMA8GA1UdEwEB/wQFMAMBAf8w
-    DQYJKoZIhvcNAQELBQADggIBAEZwlsQ+3yd1MVxLRy9RjoA8hI7iWBNmvPUyNjlb
-    l/L9N+dZgdx9G5h/KPRUyzvUc/uk/ZxTWVPIOp13WI65qwsBKrwvYKiXiwzjt+9V
-    CKDRc1sOghTSXk4FD3L5UcKvTQ2lRcFsqxbkopEwQWhoCuhe4vFyt3Nx8ZGLCBUD
-    3I5zMHtO8FtpZWKJPw46Yc1kasv0nlfly/vUbnErYfgjWX1hgWUcRgYdKwO4sOZ7
-    KbNma0WUsX5mWhXo4Kk7D15wATHO+j9s+j8m86duBL3A4HzpTo1DhHvBi0dkg0CO
-    XuSdByIzVLIPh3yhCHN1loRCP2rbzKM8IQeU/X5Q4UJeC/x9ew8Kk+RKXoHc8Y2C
-    JQO1DxuidyDJRhbb98wZo2YfIsdWQGjYZBe1XQRwBD28JnB+Rb9shml6lORWQn9y
-    P/STo9uWm5zvOCfqwbnCoetljDweItINx622G9SafBwPZc3o79oL7QSl8DgCtN6g
-    p0wGIlIBx+25w/96PqZcrYb8B7/uBHJviiKjBXDoIJWNiNRhW5HaFjeJdSKq7KIL
-    I/PO9KuHafif36ksG69X02Rio2/cTjgjEW1hGHcDRyyJWWaj7bd2eWuouh6FF22b
-    PA6FGY4vewDPnbLKLaix2ZIKxtedUDOH/qru3Mv58IFXmQ4iyM8oC8aOxYSQLZDn
-    1yJD
-    -----END CERTIFICATE-----
-  '';
-  "acme.test".key = builtins.toFile "acme.test.key" ''
-    -----BEGIN RSA PRIVATE KEY-----
-    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
-    -----END RSA PRIVATE KEY-----
-  '';
-  "acme.test".cert = builtins.toFile "acme.test.cert" ''
-    -----BEGIN CERTIFICATE-----
-    MIIEwDCCAqigAwIBAgICApowDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLU25h
-    a2VvaWwgQ0EwIBcNMjAwODI0MDc0MjEzWhgPMjEyMDA3MzEwNzQyMTNaMBQxEjAQ
-    BgNVBAMMCWFjbWUudGVzdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
-    AN3SZeAchx0XKm2c25bMxyEuXhFt03F075ZagtZ3nRixlPXZwGG3aQ1GIb1s/8vQ
-    H0WdXfI2M9NY2bbecVmBtEKEFHZAnPUDz4dAMa5d/HBpYgeiGSGNKn7Vvc6y0oAj
-    lJ1y/+OSakqQ+ewxzEkoiZd4aDqOJHgWjETf1Cuhdw8Q/52RLIfsgQYdo7N6sZXh
-    MapF/CJ5oyvaRvI+6muAXuS4eIiCwR2xgDGsTO1YjQIOCoFsoHefuKgfOiRbRP3C
-    fq9w+tgEY1WsVOnjzi9AMEv78eZbOgxUAu/tQqRgjXpoCz83HO9LNFdEgJxe98R2
-    KkVoyRbbpsr8k5IkFi4NMw0DroFndwfQA7miBMdp74j8qYQLYzN2Sqy9szdWiH6s
-    28Sfy/E0BWtlj7D1Nvos2v5vZDHy12eFzxf0icUPuclR/q8ceTlq12dd5wjMnlaG
-    l0KH5hu6GbzkG7bk/a+Q0GXfVjdD9xt7MtP3TZ5TVZ/Lggb1yU0IUSrMtRREmXYH
-    BTbaSRBuwURvRnOotFd/Wkkc0oWciiUgUIZXNBTE5V+OORGLITyoxAv/krqvcMvy
-    7i4yJJKgW1YOOteyJYmy89Hw2P8q2apH0qoQlbFp62ttU8tzKbw/sJ7JE87hq7wl
-    U3tynFqfsJHh9Mb4a/BV1+p5+PdmAn72UiEztxRfqlrfAgMBAAGjGDAWMBQGA1Ud
-    EQQNMAuCCWFjbWUudGVzdDANBgkqhkiG9w0BAQsFAAOCAgEAM5WrCpBOmLrZ1QX8
-    l6vxVXwoI8pnqyy3cbAm3aLRPbw4gb0Ot90Pv/LoMhP0fkrNOKwH/FGRjSXyti0X
-    TheKrP7aEf6XL2/Xnb8rK2jYMQo6YJU9T+wBJA6Q+GBrc8SE75KfOi5NWJr8T4Ju
-    Etb+G05hXClrN19VFzIoz3L4kRV+xNMialcOT3xQfHtXCQUgwAWpPlwcJA/Jf60m
-    XsfwQwk2Ir16wq+Lc3y+mQ7d/dbG+FVrngFk4qN2B9M/Zyv4N9ZBbqeDUn3mYtJE
-    FeJrwHgmwH6slf1gBN3gxUKRW7Bvzxk548NdmLOyN+Y4StsqbOaYGtShUJA7f1Ng
-    qQqdgvxZ9MNwwMv9QVDZEnaaew3/oWOSmQGAai4hrc7gLMLJmIxzgfd5P6Dr06e4
-    2zwsMuI8Qh/IDqu/CfmFYvaua0FEeyAtpoID9Y/KPM7fu9bJuxjZ6kqLVFkEi9nF
-    /rCMchcSA8N2z/vLPabpNotO7OYH3VD7aQGTfCL82dMlp1vwZ39S3Z1TFLLh3MZ+
-    BYcAv8kUvCV6kIdPAXvJRSQOJUlJRV7XiI2mwugdDzMx69wQ0Zc1e4WyGfiSiVYm
-    ckSJ/EkxuwT/ZYLqCAKSFGMlFhad9g1Zyvd67XgfZq5p0pJTtGxtn5j8QHy6PM6m
-    NbjvWnP8lDU8j2l3eSG58S14iGs=
-    -----END CERTIFICATE-----
-  '';
+# Minica can provide a CA key and cert, plus a key
+# and cert for our fake CA server's Web Front End (WFE).
+{ minica, mkDerivation }:
+let
+  domain = "acme.test";
+
+  selfSignedCertData = mkDerivation {
+    name = "test-certs";
+    buildInputs = [ minica ];
+    phases = [ "buildPhase" "installPhase" ];
+
+    buildPhase = ''
+      mkdir ca
+      minica \
+        --ca-key ca/key.pem \
+        --ca-cert ca/cert.pem \
+        --domains ${domain}
+      chmod 600 ca/*
+      chmod 640 ${domain}/*.pem
+    '';
+
+    installPhase = ''
+      mkdir -p $out
+      mv ${domain} ca $out/
+    '';
+  };
+in {
+  inherit domain;
+  ca = {
+    cert = "${selfSignedCertData}/ca/cert.pem";
+    key = "${selfSignedCertData}/ca/key.pem";
+  };
+  "${domain}" = {
+    cert = "${selfSignedCertData}/${domain}/cert.pem";
+    key = "${selfSignedCertData}/${domain}/key.pem";
+  };
 }
diff --git a/nixos/tests/postfix-raise-smtpd-tls-security-level.nix b/nixos/tests/postfix-raise-smtpd-tls-security-level.nix
index b3c2156122d..5fad1fed75b 100644
--- a/nixos/tests/postfix-raise-smtpd-tls-security-level.nix
+++ b/nixos/tests/postfix-raise-smtpd-tls-security-level.nix
@@ -1,6 +1,3 @@
-let
-  certs = import ./common/acme/server/snakeoil-certs.nix;
-in
 import ./make-test-python.nix {
   name = "postfix";
 
diff --git a/nixos/tests/postfix.nix b/nixos/tests/postfix.nix
index b0674ca3a0d..37ae76afec1 100644
--- a/nixos/tests/postfix.nix
+++ b/nixos/tests/postfix.nix
@@ -1,5 +1,6 @@
 let
   certs = import ./common/acme/server/snakeoil-certs.nix;
+  domain = certs.domain;
 in
 import ./make-test-python.nix {
   name = "postfix";
@@ -11,8 +12,8 @@ import ./make-test-python.nix {
       enableSubmission = true;
       enableSubmissions = true;
       sslCACert = certs.ca.cert;
-      sslCert = certs."acme.test".cert;
-      sslKey = certs."acme.test".key;
+      sslCert = certs.${domain}.cert;
+      sslKey = certs.${domain}.key;
       submissionsOptions = {
           smtpd_sasl_auth_enable = "yes";
           smtpd_client_restrictions = "permit";
@@ -25,7 +26,7 @@ import ./make-test-python.nix {
     ];
 
     networking.extraHosts = ''
-      127.0.0.1 acme.test
+      127.0.0.1 ${domain}
     '';
 
     environment.systemPackages = let
@@ -33,7 +34,7 @@ import ./make-test-python.nix {
         #!${pkgs.python3.interpreter}
         import smtplib
 
-        with smtplib.SMTP('acme.test') as smtp:
+        with smtplib.SMTP('${domain}') as smtp:
           smtp.sendmail('root@localhost', 'alice@localhost', 'Subject: Test\n\nTest data.')
           smtp.quit()
       '';
@@ -45,7 +46,7 @@ import ./make-test-python.nix {
 
         ctx = ssl.create_default_context()
 
-        with smtplib.SMTP('acme.test') as smtp:
+        with smtplib.SMTP('${domain}') as smtp:
           smtp.ehlo()
           smtp.starttls(context=ctx)
           smtp.ehlo()
@@ -60,7 +61,7 @@ import ./make-test-python.nix {
 
         ctx = ssl.create_default_context()
 
-        with smtplib.SMTP_SSL(host='acme.test', context=ctx) as smtp:
+        with smtplib.SMTP_SSL(host='${domain}', context=ctx) as smtp:
           smtp.sendmail('root@localhost', 'alice@localhost', 'Subject: Test SMTPS\n\nTest data.')
           smtp.quit()
       '';