Servers
View as MarkdownDNS-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:
go run ./tools/dnscatalog -traefik v3.7.16 -o pkg/agentprotocol/dns_lego_catalog.goProviders 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-challengewith 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#
- Servers, Proxy, DNS-01 certificates: add a credential (administrator), then pick it for the provider's resolver, then add a wildcard certificate.
- 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.
- Add a wildcard domain to a service (
*.example.com, or pick the resolver for it); it is served byHostRegexpand covered by the certificate. Point a wildcardArecord 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.payloadcarries 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 its0600state file like uploaded certificate keys, so the proxy can be recreated offline. docker inspecton 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".
defaultGeneratedCertis 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).