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-ansdisables 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¶
credentialsFileformat is provider-specific. It's passed straight tosecurity.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.
dnsProvideris 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;
}