Skip to main content
Version: next

DNS-01 providers

Overview

DNS-01 is the ACME challenge type that proves domain control by publishing a _acme-challenge TXT record. It is the only challenge that can issue wildcard certificates, and it works for hosts that are not reachable from the internet.

TokenTimer's agent ships native solvers for 11 providers with zero npm dependencies: HTTP providers use the runtime's own fetch, request signing (Route 53 SigV4, the Google Cloud JWT, the OVH signature, Exoscale EXO2-HMAC-SHA256) is computed with Node's crypto, and RFC 2136 speaks the DNS wire protocol directly with a TSIG HMAC.

Credentials never leave the host

DNS provider credentials are read from 0600-mode files on the agent host, referenced by path from the agent's config.json. They are never passed on the command line (where other local processes could read them from the process table), never sent to TokenTimer, and never written to logs. The control plane stores only provider names for allowlisting and routing. This is the same zero-custody rule that applies to private keys.

How a challenge runs

certbot and acme.sh do not talk to your DNS provider directly. They call back into the agent's DNS hook, which:

  1. resolves which provider owns the challenge zone,
  2. publishes the TXT record through the provider's API,
  3. waits for propagation by polling resolvers until the record is visible,
  4. lets the ACME client trigger validation,
  5. removes the record afterwards.

Step 3 is what makes DNS-01 reliable in practice. Skipping it is the single most common cause of flaky DNS-01 setups, because the CA often queries before a freshly written record has propagated.

Concurrent challenges on the same name (an apex plus its wildcard, for example) are handled without clobbering each other: providers that replace whole record sets read the existing values first and write the union.

Zone resolution

The challenge hostname is not assumed to be the zone. With a single configured provider and no explicit mapping, the agent discovers the zone via DNS NS lookup or the provider's own zone list. On hosts serving multiple providers, zoneProviderMap routes zones explicitly, and the longest matching zone wins on a dot boundary (so internal.example.net does not accidentally match example.net):

{
"dnsProviders": {
"cloudflare": { "credentialsFile": "/etc/tokentimer-agent/dns/cloudflare.json" },
"rfc2136": { "credentialsFile": "/etc/tokentimer-agent/dns/rfc2136.json" },
"zoneProviderMap": {
"example.com": "cloudflare",
"internal.example.net": "rfc2136"
}
}
}

Propagation tuning

The optional top-level dnsPropagation block controls the wait after publishing and after cleanup:

{
"dnsPropagation": {
"timeoutMs": 120000,
"intervalMs": 2000,
"resolvers": ["1.1.1.1", "8.8.8.8"],
"checkAuthoritative": true,
"verificationMode": "all",
"quorumCount": null
}
}
FieldDefaultMeaning
timeoutMs120000Give up waiting after this long
intervalMs2000Poll interval between checks
resolvers[] (none)Extra recursive resolvers to query, on top of the authoritative check
checkAuthoritativetrueQuery the zone's authoritative nameservers
verificationModeallall requires every check to agree; quorum requires quorumCount of them
quorumCountnullRequired positive integer in quorum mode

By default no public resolvers are queried: the wait relies on the authoritative nameservers, which is the earliest reliable signal. Add resolvers when you also want to confirm that recursive resolvers have picked the record up.

Use quorum when one of several configured resolvers is consistently slow or flaky and all makes issuance unreliable. Use all (the default) when you want the strictest guarantee that the record is visible everywhere before validation.

Provider credentials

Each provider gets its own JSON credentials file. Create it with tight permissions before pointing the agent at it:

sudo install -d -m 0700 -o tokentimer-agent -g tokentimer-agent /etc/tokentimer-agent/dns
sudo install -m 0600 -o tokentimer-agent -g tokentimer-agent /dev/null \
/etc/tokentimer-agent/dns/cloudflare.json

The agent refuses a credentials file that is group- or other-readable on POSIX hosts, so a permissions mistake fails loudly instead of silently widening access.

Grant the narrowest scope each provider supports. A DNS-01 credential only ever needs to create and delete TXT records in the zones you use for certificates.

Cloudflare

{ "apiToken": "<scoped API token>" }

Use a scoped API token with Zone.DNS:Edit, not a global API key. Optional zoneId skips the zone lookup; without it the zone is resolved by name.

Amazon Route 53

{
"accessKeyId": "...",
"secretAccessKey": "...",
"sessionToken": "optional",
"hostedZoneId": "optional",
"region": "us-east-1"
}

Without hostedZoneId the agent calls ListHostedZonesByName. An IAM policy limited to route53:ChangeResourceRecordSets on the specific hosted zone plus route53:GetChange is sufficient.

Azure DNS

{
"tenantId": "...",
"clientId": "...",
"clientSecret": "...",
"subscriptionId": "...",
"resourceGroup": "..."
}

Uses the client-credentials flow. Assign the DNS Zone Contributor role, scoped to the zone rather than the subscription where possible.

Google Cloud DNS

{
"client_email": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...",
"project_id": "...",
"managedZone": "optional"
}

These are the standard service-account JSON fields, so you can use the downloaded key file as-is. The service-account key signs the OAuth JWT locally and never leaves the host.

note

This file contains a service-account private key. That is a DNS API credential, not certificate key material, so it is expected here. It is still the most sensitive file in this list: keep it 0600 and rotate it like any other cloud credential.

RFC 2136 (BIND, Knot, and compatible)

{
"server": "ns1.internal.example.net",
"keyName": "acme-update",
"keySecretBase64": "...",
"port": 53,
"keyAlgorithm": "hmac-sha256"
}

Dynamic DNS updates authenticated with TSIG (hmac-sha1, -sha224, -sha256, -sha384, -sha512). Restrict the TSIG key server-side to _acme-challenge TXT updates in the zones you need. This is the usual choice for internal zones that no public API can reach.

acme-dns

{
"baseUrl": "https://acme-dns.example.com",
"username": "...",
"password": "...",
"subdomain": "..."
}

All four values come from the acme-dns /register call. acme-dns is a good fit when you want to delegate _acme-challenge via CNAME and hand out a credential that can do nothing except answer challenges.

Cleanup is intentionally a no-op

acme-dns rotates its two TXT slots automatically, so there is nothing to delete. The provider declares cleanupVerifiable: false, the agent skips the usual wait-for-record-removal poll, and evidence records cleanup_not_applicable. A "cleanup did not happen" reading of that evidence is expected behaviour, not a failure.

OVHcloud

{
"applicationKey": "...",
"applicationSecret": "...",
"consumerKey": "...",
"endpoint": "https://eu.api.ovh.com/1.0"
}

Regional endpoints: https://eu.api.ovh.com/1.0 (default), https://ca.api.ovh.com/1.0, https://us.api.ovhcloud.com/1.0. Every mutation is followed by a zone refresh call, because OVH does not serve a changed zone until it is refreshed.

Host clock matters

OVH requests are signed with the host's local time. A significantly skewed clock produces signature failures. Keep NTP working on the agent host (which the agent's clock-drift reporting also depends on).

Hetzner

{ "apiToken": "<Hetzner Console / Cloud project API token>" }
Use the Cloud project token, not the legacy DNS token

This provider uses the Hetzner Cloud API (api.hetzner.cloud/v1) with Authorization: Bearer. A legacy Hetzner DNS Console token (dns.hetzner.com, Auth-API-Token header) is a different credential and is not supported. Using the wrong one fails authentication with no way for the agent to tell you which token type you pasted.

Optional zoneId skips the zone lookup. Record changes use value-specific add_records / remove_records actions, so concurrent challenges never clobber each other.

Infomaniak

{ "apiToken": "<API token with domain scope>" }

Create the token with the domain scope. Infomaniak wraps every response in a { result, data } envelope, and the agent treats a non-success result as a failure even when the HTTP status is 200, so partial failures are not mistaken for success.

Exoscale

{
"apiKey": "...",
"apiSecret": "...",
"apiEndpoint": "https://api-ch-gva-2.exoscale.com/v2"
}

Requests are EXO2-HMAC-SHA256 signed. Exoscale DNS is global, so any zone endpoint works. Mutations are applied asynchronously on Exoscale's side: the accepted operation counts as success and the propagation wait covers the apply window.

PowerDNS

{
"apiUrl": "https://pdns.example.com:8081",
"apiKey": "...",
"serverId": "localhost"
}

apiUrl must be https:. A plain-HTTP loopback endpoint is allowed only with an explicit opt-in, because the API key would otherwise travel in clear text:

{
"apiUrl": "http://127.0.0.1:8081",
"apiKey": "...",
"allowInsecureLocalHttp": true
}

Embedded credentials in the URL and hash fragments are always rejected. The agent handles PowerDNS's trailing-dot names and quoted TXT content for you, and merges parallel challenges into one record set rather than overwriting.

Policy allowlisting

Configuring a provider is not enough on its own. The agent's local policy is default-deny, so the provider id and the DNS zones must also be allowed:

{
"policy": {
"allowedDnsProviders": ["cloudflare", "rfc2136"],
"allowedDnsZones": ["example.com", "internal.example.net"]
}
}

Provider ids are matched exactly. Valid ids are: cloudflare, route53, azure-dns, google-cloud-dns, rfc2136, acme-dns, ovhcloud, hetzner, infomaniak, exoscale, powerdns.

Local policy always wins over control-plane intent: a job asking for a provider or zone the host has not allowed is rejected locally, and the rejection is reported back as evidence.

Troubleshooting

SymptomLikely causeFix
Provider rejected before any API callProvider id not in policy.allowedDnsProviders, or zone not in allowedDnsZonesAdd both; ids are exact-match
Agent refuses to read the credentials fileFile is group- or other-readablechmod 0600 and set the owner to the agent user
Propagation timeoutSlow provider, or a resolver that lagsRaise timeoutMs, or switch to verificationMode: "quorum" with a quorumCount
Hetzner authentication fails with a valid-looking tokenLegacy DNS Console token used instead of a Cloud project tokenCreate a Cloud project API token (see Hetzner)
OVH signature errorsHost clock skewFix NTP on the agent host
PowerDNS rejects the URLapiUrl uses http: without allowInsecureLocalHttpUse https:, or opt in explicitly for a loopback endpoint
Wrong zone chosen on a multi-provider hostZone discovery picked a different zone than intendedAdd an explicit zoneProviderMap entry
acme-dns evidence says cleanup not applicableExpected: acme-dns rotates its TXT slotsNo action needed