Skip to content

acme-dns01-wildcard

Modules

A thin NixOS module (acmeCerts) over security.acme.certs for issuing DNS-01 wildcard certificates — with the one fix that makes them actually work behind a split-horizon / local-caching resolver.

The problem

DNS-01 is the only ACME challenge that can issue *.example.com wildcards. The flow is: lego writes a _acme-challenge.example.com TXT record via your DNS provider's API, then waits for the record to propagate before telling Let's Encrypt to validate.

That wait is a DNS lookup — and lego does it through the host's configured resolver. If that resolver is anything other than a plain public recursive resolver, the check breaks:

  • a split-horizon setup (internal unbound/dnsmasq, VPN resolver, Active Directory DNS) serves the internal view of the zone, which has no ACME TXT;
  • a caching resolver may hold a stale negative answer;
  • a resolver that shadows the public zone simply never sees the record.

lego then concludes the record "never propagated" and the issuance times out — even though the TXT is live on the public authoritative nameservers the whole time. This failure is maddening because the record is genuinely correct; only the checker's viewpoint is wrong.

The fix (the load-bearing trick)

Two lego flags, applied to every cert:

--dns.propagation-disable-ans
--dns.resolvers=ns1.provider.net:53,ns2.provider.net:53,127.0.0.1:53
  • --dns.propagation-disable-ans disables lego's built-in "authoritative completion" pre-check.
  • --dns.resolvers=... pins the resolvers lego queries for the TXT record to the zone's real authoritative nameservers. The propagation check now asks the servers that actually hold the record, bypassing the local/split view entirely.

On the flag name. Older writeups (and older versions of this module) use --dns.disable-cp. lego now labels that one (deprecated) use dns.propagation-disable-ans instead, and nixpkgs' own ACME module emits --dns.propagation-disable-ans when dnsPropagationCheck = false. The two are the same underlying toggle under two names, so this is a pure rename with no behaviour change — nothing about issuance, propagation waiting, or resolver pinning differs. This module emits the current name.

Note that the module never touches the native dnsPropagationCheck option to achieve this: it hardcodes dnsPropagationCheck = true at the security.acme.certs level and always drives the disable through its own extraLegoFlags.

dnsPropagationCheck stays on — you still want lego to wait until the record is visible before validation, or Let's Encrypt races ahead and fails. The flags change how that check looks up the record, not whether it waits.

Usage

Import the module and declare certs by primary domain:

{ config, ... }:
{
  imports = [ ./modules/acme-dns01-wildcard ];

  # ACME account email + TOS (standard NixOS ACME config)
  security.acme = {
    acceptTerms = true;
    defaults.email = "admin@example.com";
  };

  acmeCerts."example.com" = {
    wildcard        = true;                     # issues example.com + *.example.com
    dnsProvider     = "digitalocean";           # any lego provider code
    credentialsFile = config.age.secrets.dns-token.path;
    resolvers = [
      "ns1.digitalocean.com:53"
      "ns2.digitalocean.com:53"
      "ns3.digitalocean.com:53"
      "127.0.0.1:53"
    ];
  };
}

The reverse proxy reads the result from security.acme.certs."example.com" as usual (e.g. services.nginx.virtualHosts."example.com".useACMEHost = "example.com").

Options

Each acmeCerts.<domain> entry accepts:

Option Default Purpose
wildcard false Add a *.<domain> SAN.
extraDomainNames [] Extra SANs on the same certificate.
group "nginx" Group that owns the cert files (so the proxy can read them).
dnsProvider "digitalocean" lego DNS provider code.
credentialsFile (required) Env file with the provider API token — wire from agenix/sops-nix/plain path.
resolvers [] Authoritative resolvers (host:port) lego queries for the TXT record. Set this when split-horizon is in play.
disableCompletePropagationCheck true Emit --dns.propagation-disable-ans.
extraLegoFlags [] Raw extra flags.

Notes / caveats

  • credentialsFile format is provider-specific. It's passed straight to security.acme.certs.<domain>.environmentFile, so it must set the environment variables the chosen lego provider expects (e.g. DO_AUTH_TOKEN=... for DigitalOcean, CF_DNS_API_TOKEN=... for Cloudflare).
  • Get the provider code right. dnsProvider is a lego provider code, not a friendly name; check lego's provider list.
  • Not just wildcards. The resolver-pinning fix helps any DNS-01 issuance on a host with a non-public resolver, wildcard or not.
  • If you have no split-horizon, leaving resolvers = [] and relying on the system resolver is fine — but pinning authoritative resolvers is harmless and makes issuance robust against later resolver changes.
  • This module is secret-manager agnostic: it stores no token names and makes no provider assumptions. Every cert brings its own credentials, provider and resolvers.

Source

modules/acme-dns01-wildcard/default.nix
# acme-dns01-wildcard
#
# A thin `acmeCerts` wrapper over `security.acme.certs` that makes DNS-01
# wildcard certificates painless — and, crucially, makes lego's propagation
# check survive a split-horizon / local-caching resolver.
#
# The load-bearing trick lives in `extraLegoFlags`:
#
#   --dns.propagation-disable-ans  disable lego's own "authoritative nameserver"
#                                  completion pre-check (the modern flag; lego's
#                                  older `--dns.disable-cp` is now deprecated —
#                                  see the 2026-07-28 README note)
#   --dns.resolvers=...            pin the resolvers lego queries for the ACME
#                                  TXT record to the zone's real authoritative
#                                  nameservers (+ optionally 127.0.0.1)
#
# Without this, lego resolves the `_acme-challenge` TXT through the host's
# configured resolver. Behind split-horizon DNS (a local unbound/dnsmasq,
# a VPN resolver, or a caching resolver that shadows the public zone) that
# lookup either returns the internal view or a stale/empty answer, so lego
# concludes the record "never propagated" and the issuance times out — even
# though the TXT is live on the public authoritative servers. Pointing lego
# straight at the authoritative resolvers sidesteps the local view entirely.
#
# Import as a NixOS module, then declare certs by domain name:
#
#   acmeCerts."example.com" = {
#     wildcard        = true;                       # adds *.example.com
#     credentialsFile = config.age.secrets.dns-token.path;
#     resolvers       = [ "ns1.provider.net:53" "ns2.provider.net:53" ];
#   };
#
# This module intentionally carries no secret-name table and no provider
# assumptions: every cert names its own credentials file, DNS provider and
# authoritative resolvers. Wire the credentialsFile to whatever secret
# manager you use (agenix/sops-nix/plain path).

{ config, lib, ... }:

let
  cfg = config.acmeCerts;
in
{
  options.acmeCerts = lib.mkOption {
    description = ''
      Declarative DNS-01 certificates keyed by primary domain. Each entry is
      expanded into a `security.acme.certs.<domain>` with the split-horizon-safe
      lego flags applied.
    '';
    default = { };
    type =
      with lib.types;
      attrsOf (submodule {
        options = {
          wildcard = lib.mkOption {
            type = bool;
            default = false;
            description = "Add a `*.<domain>` SAN to the certificate.";
          };

          extraDomainNames = lib.mkOption {
            type = listOf str;
            default = [ ];
            example = [ "www.example.com" "api.example.com" ];
            description = "Additional Subject Alternative Names for the certificate.";
          };

          group = lib.mkOption {
            type = str;
            default = "nginx";
            description = ''
              Group that owns the issued certificate files. Defaults to the web
              server group so the reverse proxy can read them.
            '';
          };

          dnsProvider = lib.mkOption {
            type = str;
            default = "digitalocean";
            example = "cloudflare";
            description = ''
              lego DNS provider code used for the DNS-01 challenge. See the lego
              documentation for the full list of provider codes.
            '';
          };

          credentialsFile = lib.mkOption {
            type = path;
            example = "/run/secrets/dns-api-token.env";
            description = ''
              Path to the environment file holding the DNS provider's API
              credentials (passed to `security.acme.certs.<domain>.environmentFile`).
              Provide it from your secret manager of choice — e.g.
              `config.age.secrets.dns-token.path` (agenix),
              `config.sops.secrets.dns-token.path` (sops-nix), or a plain path.
            '';
          };

          resolvers = lib.mkOption {
            type = listOf str;
            default = [ ];
            example = [ "ns1.provider.net:53" "ns2.provider.net:53" "127.0.0.1:53" ];
            description = ''
              Authoritative resolvers lego should query for the ACME TXT record,
              as `host:port` entries. THE key setting: point these at the zone's
              real authoritative nameservers so the propagation check bypasses a
              local/split-horizon resolver that would otherwise return the wrong
              view and stall issuance. When empty, lego uses the system resolver
              (fine only when there is no split-horizon in play).
            '';
          };

          disableCompletePropagationCheck = lib.mkOption {
            type = bool;
            default = true;
            description = ''
              Pass lego's `--dns.propagation-disable-ans` to skip its built-in
              authoritative completion pre-check. Combined with a pinned
              `resolvers` list this is what keeps the propagation check from
              failing behind a local or split-horizon resolver. Leave enabled
              unless you have a reason not to.
            '';
          };

          extraLegoFlags = lib.mkOption {
            type = listOf str;
            default = [ ];
            description = "Additional raw flags appended to the lego invocation.";
          };
        };
      });
  };

  config.security.acme.certs = builtins.mapAttrs (domain: attrs: {
    inherit (attrs) group dnsProvider;
    environmentFile = attrs.credentialsFile;

    # Keep the propagation check ON — the wait is what prevents Let's Encrypt
    # from validating before the TXT exists. The propagation-disable-ans flag
    # + pinned resolvers below change *how* that check is performed, not whether.
    dnsPropagationCheck = true;

    extraDomainNames =
      (lib.optional attrs.wildcard "*.${domain}") ++ attrs.extraDomainNames;

    extraLegoFlags =
      (lib.optional attrs.disableCompletePropagationCheck "--dns.propagation-disable-ans")
      ++ (lib.optional (attrs.resolvers != [ ])
        "--dns.resolvers=${lib.concatStringsSep "," attrs.resolvers}")
      ++ attrs.extraLegoFlags;
  }) cfg;
}