Skip to content

DNS-01 and wildcard certificates

Let's Encrypt validates a wildcard (*.example.com) only through the DNS-01 challenge: Traefik creates a TXT record at _acme-challenge.<domain> through your DNS provider's API. NebulaCtrl supports every DNS provider of lego, the ACME client inside Traefik: 19 with a form and credential help (Cloudflare, Amazon Route 53, Hetzner, DigitalOcean, Gandi, OVHcloud, Porkbun, Namecheap, Google Cloud DNS, Azure DNS, deSEC, Linode, Vultr, Scaleway, Bunny, DNSimple, IONOS, GoDaddy and Netcup) and any other lego provider through a generic form (provider code plus name/value variables).

Concepts#

Thing What it is
Credential The provider's API variables, sealed with the instance secret key. Scope team (usable on every server) or server. Created by administrators; secret values are write-only.
Resolver One per provider and server (Traefik certificatesresolvers.nc-dns-<provider>). It is static proxy configuration with the credential as the proxy container's environment.
Wildcard certificate *.<domain> (and the base domain with "Include the base domain") requested through a resolver. Optional: also cover generated domains and serve as the default certificate (defaultGeneratedCert, so unknown hosts get a valid certificate instead of Traefik's self-signed one).
Domain resolver A single domain can name a resolver instead (useful for hosts that are not reachable on port 80).

A resolver needs the server's ACME email. The environment is process-wide, so there is one credential per provider per server; for two accounts of one provider delegate _acme-challenge with a CNAME (Traefik documents this).

Variables (verified against go-acme/lego v5.5.2, vendored by Traefik v3.7.16)#

Where the names come from. Traefik v3.7.16's go.mod requires github.com/go-acme/lego/v5 v5.5.2 and its ACME provider calls dns.NewDNSChallengeProviderByName (pkg/provider/acme/provider.go). Lego reads each provider's credentials from the process environment, with the names in providers/dns/<code>/<code>.toml at tag v5.5.2; the lego documentation renders that file at https://go-acme.github.io/lego/dns/<code>/. The catalog in pkg/agentprotocol/dns_lego_catalog.go is generated from those files by tools/dnscatalog, and TestCatalogMatchesPinnedTraefik fails when proxy.TraefikImage moves without regenerating it:

sh
go run ./tools/dnscatalog -traefik v3.7.16 -o pkg/agentprotocol/dns_lego_catalog.go

Providers with a form (R = required; OneOf = set every variable of one method):

Provider (code) Variables Lego page
Cloudflare (cloudflare) CF_DNS_API_TOKEN R (Zone:Zone:Read + Zone:DNS:Edit), CF_ZONE_API_TOKEN cloudflare
Amazon Route 53 (route53) AWS_ACCESS_KEY_ID R, AWS_SECRET_ACCESS_KEY R, AWS_REGION R, AWS_HOSTED_ZONE_ID route53
Hetzner (hetzner) HETZNER_API_TOKEN R hetzner
DigitalOcean (digitalocean) DO_AUTH_TOKEN R digitalocean
Gandi (gandiv5) GANDIV5_PERSONAL_ACCESS_TOKEN R gandiv5
OVHcloud (ovh) OVH_ENDPOINT; OneOf: OVH_APPLICATION_KEY + OVH_APPLICATION_SECRET + OVH_CONSUMER_KEY, or OVH_CLIENT_ID + OVH_CLIENT_SECRET, or OVH_ACCESS_TOKEN ovh
Porkbun (porkbun) PORKBUN_API_KEY R, PORKBUN_SECRET_API_KEY R porkbun
Namecheap (namecheap) NAMECHEAP_API_USER R, NAMECHEAP_API_KEY R namecheap
Google Cloud DNS (gcloud) GCE_SERVICE_ACCOUNT R (the key JSON, stored on one line), GCE_PROJECT, GCE_ZONE_ID gcloud
Azure DNS (azuredns) AZURE_CLIENT_ID R, AZURE_CLIENT_SECRET R, AZURE_TENANT_ID R, AZURE_SUBSCRIPTION_ID, AZURE_RESOURCE_GROUP azuredns
deSEC (desec) DESEC_TOKEN R desec
Linode (linode) LINODE_TOKEN R linode
Vultr (vultr) VULTR_API_KEY R vultr
Scaleway (scaleway) SCW_SECRET_KEY R, SCW_ACCESS_KEY, SCW_PROJECT_ID scaleway
Bunny (bunny) BUNNY_API_KEY R bunny
DNSimple (dnsimple) DNSIMPLE_OAUTH_TOKEN R (an account token; a user token is rejected) dnsimple
IONOS (ionos) IONOS_API_KEY R (<prefix>.<secret>) ionos
GoDaddy (godaddy) GODADDY_API_KEY R, GODADDY_API_SECRET R godaddy
Netcup (netcup) NETCUP_CUSTOMER_NUMBER R, NETCUP_API_KEY R, NETCUP_API_PASSWORD R netcup

Each form has a credential help panel: a button to the provider's token page (prefilled where the provider allows it: Cloudflare and OVHcloud), the permissions to select and the provider's own docs. The pages were opened on 2026-10-27; they are listed in apps/web/src/lib/dns-provider-guides.ts.

Provider caveats worth knowing:

  • GoDaddy: lego's provider authenticates with a classic developer key. GoDaddy's developer docs call it "scheduled for retirement" and the v3 Domains APIs need a Personal Access Token instead. Expect this provider to stop working when GoDaddy turns classic keys off; delegate _acme-challenge with a CNAME to another provider or move the zone.
  • Namecheap: API access is opt-in and the calling IP must be on the account's allowed list. The proxy calls from the server's public IPv4 address.
  • Gandi: a Personal Access Token expires after at most one year; the credential needs rotating.
  • OVHcloud: use one authentication method only; lego refuses a mix.
  • Google Cloud DNS: paste the whole service account key file; it is compacted to one line.

Any other lego provider#

Add credential → Any other lego provider. Type the provider's lego code (the form suggests the other 200 or so from the catalog), then add name/value variables. The form links the lego page of the code and lists every variable lego reads for it. The control plane checks each name against the catalog for that code and refuses anything else with 422 invalid_credential, so a credential can never put TRAEFIK_*, proxy or loader variables (HTTPS_PROXY, LD_PRELOAD) in the proxy container's environment. Left out of the catalog on purpose: the exec and manual providers (they run a program or wait on stdin) and variables that read a file (*_FILE, *_PATH: the proxy container has nothing to read). Values of variables whose name does not say they are an identifier or a setting (_ID, _USER, _REGION, _ENDPOINT, _URL, _TTL, _TIMEOUT, …) are write-only. A generic credential needs at least one variable; which ones depends on the authentication method, so read the provider's lego page.

GET /api/v1/dns/providers lists the providers with a form; GET /api/v1/dns/lego-providers lists the rest with their variables. Agents older than this feature (no dns01-lego capability) are refused for every provider but the original four (422 agent_too_old).

The proxy container has one environment: two resolvers cannot both set the same variable name (for example Route 53 and Lightsail both read AWS_ACCESS_KEY_ID). Staging the second resolver fails with 422 dns_variable_clash; use a different provider for one of them.

Only these names can ever reach the proxy container; the agent drops everything else.

Set it up#

  1. Servers, Proxy, DNS-01 certificates: add a credential (administrator), then pick it for the provider's resolver, then add a wildcard certificate.
  2. Deploy proxy. The plan lists the changes and says the proxy container is recreated (about a second of dropped connections). A rotated credential shows as "credential rotated" and recreates it too. Nothing reaches the server before this.
  3. Add a wildcard domain to a service (*.example.com, or pick the resolver for it); it is served by HostRegexp and covered by the certificate. Point a wildcard A record at the server.

The certificate's status and expiry come from the proxy itself: the agent opens a TLS connection with the wildcard's SNI and reads the certificate Traefik serves. "Waiting for certificate" means the proxy still serves its default certificate; an error shows Traefik's ACME log line for the resolver. Renewal is Traefik's job (30 days before expiry). Use Let's Encrypt staging on a resolver while testing; staging certificates are not trusted by browsers.

How secrets travel#

  • The database holds the values only sealed. agent_commands.payload carries variable names; the values are attached to the outgoing frame at send time (dns01:<credential>:<NAME>) and written to the container environment by the agent. The agent keeps them in its 0600 state file like uploaded certificate keys, so the proxy can be recreated offline.
  • docker inspect on the host shows the environment: Docker access is root-equivalent anyway (see the threat model). Use API tokens limited to the zone.

Limits#

  • Resolver and credential changes are an intentional proxy restart; they are never hot.
  • Observed state uses the agent's TLS handshake; a CA outage looks like "waiting".
  • defaultGeneratedCert is rendered through the REST provider; verify it against Let's Encrypt staging before relying on it (the anchor router that requests the wildcard is the primary mechanism).