DNS Providers Guide¶
DNS-01 challenges prove domain ownership by creating TXT records under
_acme-challenge.<domain>. lacme ships with providers for Cloudflare,
AWS Route 53, and external hook scripts, plus a protocol for writing
your own.
Cloudflare¶
Create an API Token¶
- Go to Cloudflare Dashboard > API Tokens
- Click Create Token
- Use the Edit zone DNS template, or create a custom token with:
- Permissions: Zone > DNS > Edit
- Zone Resources: Include > Specific zone > your domain
- Copy the token
Find Your Zone ID¶
The Zone ID is displayed on your domain's Overview page in the Cloudflare dashboard, in the right sidebar under API.
Usage¶
from lacme import Client, FileStore, DNS01Handler
from lacme.challenges.providers.cloudflare import CloudflareDNSProvider
provider = CloudflareDNSProvider(
api_token="your-cloudflare-api-token",
zone_id="your-zone-id",
)
handler = DNS01Handler(provider=provider)
async with Client(
store=FileStore("~/.lacme"),
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = await client.issue(
["example.com", "*.example.com"],
challenge_type="dns-01",
)
# Clean up the HTTP client used by the provider
await provider.close()
Tip
Use environment variables to keep tokens out of source code:
import os
provider = CloudflareDNSProvider(
api_token=os.environ["LACME_CLOUDFLARE_TOKEN"],
zone_id=os.environ["LACME_CLOUDFLARE_ZONE_ID"],
)
The CLI also supports these environment variables -- see CLI Reference.
How It Works¶
The CloudflareDNSProvider:
- Creates a TXT record via
POST /zones/{zone_id}/dns_recordswith TTL 120 - Tracks the record ID returned by Cloudflare
- Deletes the record via
DELETE /zones/{zone_id}/dns_records/{id}after validation
Error responses are sanitized to avoid leaking API tokens in logs or stack traces.
Route 53¶
IAM Policy¶
Create an IAM user or role with the following minimum permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets",
"route53:GetChange"
],
"Resource": [
"arn:aws:route53:::hostedzone/YOUR_HOSTED_ZONE_ID",
"arn:aws:route53:::change/*"
]
}
]
}
Find Your Hosted Zone ID¶
Or find it in the Route 53 console under Hosted zones.
Configure Credentials¶
Route 53 uses boto3, which reads credentials from the standard chain:
- Environment variables (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY) - Shared credentials file (
~/.aws/credentials) - IAM role (if running on EC2/ECS/Lambda)
Usage¶
from lacme import Client, FileStore, DNS01Handler
from lacme.challenges.providers.route53 import Route53DNSProvider
provider = Route53DNSProvider(
hosted_zone_id="Z0123456789ABCDEFGHIJ",
)
handler = DNS01Handler(provider=provider)
async with Client(
store=FileStore("~/.lacme"),
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = await client.issue(
["example.com", "*.example.com"],
challenge_type="dns-01",
)
Note
Route53DNSProvider uses boto3 (a synchronous library) internally. Calls
to create_txt_record and delete_txt_record are automatically run in a
thread executor via asyncio.run_in_executor to avoid blocking the event
loop.
Install the AWS extra: pip install lacme[aws]
How It Works¶
The Route53DNSProvider:
- Creates a TXT record via
UPSERTinChangeResourceRecordSetswith TTL 120 - Deletes the record via
DELETEinChangeResourceRecordSetsafter validation
The UPSERT action creates the record if it does not exist, or replaces it if
it does. This is idempotent and safe for retries.
Hook (External Script)¶
The hook provider delegates DNS record management to external scripts. This is useful for DNS providers that do not have a dedicated lacme integration.
Usage¶
from lacme import DNS01Handler
from lacme.challenges.providers.hook import HookDNSProvider
provider = HookDNSProvider(
create_command="/usr/local/bin/dns-create.sh",
delete_command="/usr/local/bin/dns-delete.sh",
timeout=30.0, # seconds (default)
)
handler = DNS01Handler(provider=provider)
Script Interface¶
Both scripts receive two positional arguments:
For example, when issuing a certificate for *.example.com:
# Create is called with:
/usr/local/bin/dns-create.sh _acme-challenge.example.com dGVzdC12YWx1ZQ...
# Delete is called with:
/usr/local/bin/dns-delete.sh _acme-challenge.example.com dGVzdC12YWx1ZQ...
Scripts must exit with code 0 on success. Any non-zero exit code causes the challenge to fail. stderr output is captured and included in the error message.
Example Hook Script¶
#!/bin/bash
# dns-create.sh -- Create a TXT record via your DNS API
set -euo pipefail
DOMAIN="$1"
VALUE="$2"
curl -s -X POST "https://api.mydns.example/records" \
-H "Authorization: Bearer ${DNS_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"type\": \"TXT\", \"name\": \"${DOMAIN}\", \"content\": \"${VALUE}\", \"ttl\": 120}"
String vs List Commands¶
Commands can be passed as strings (split with shlex.split) or lists:
# String form -- split automatically
provider = HookDNSProvider(
create_command="python /opt/dns/create.py --verbose",
delete_command="python /opt/dns/delete.py --verbose",
)
# List form -- exact argv
provider = HookDNSProvider(
create_command=["python", "/opt/dns/create.py", "--verbose"],
delete_command=["python", "/opt/dns/delete.py", "--verbose"],
)
Timeout¶
If a hook script does not complete within the timeout (default 30 seconds),
lacme kills the process and raises a RuntimeError:
provider = HookDNSProvider(
create_command="/usr/local/bin/slow-dns-create.sh",
delete_command="/usr/local/bin/slow-dns-delete.sh",
timeout=120.0, # 2 minutes
)
Warning
The HookDNSProvider validates that both commands exist (via shutil.which)
at construction time. A FileNotFoundError is raised immediately if a
command is not found on PATH.
Custom Provider¶
Implement the DNSProvider protocol to integrate any DNS service:
from lacme.challenges.dns01 import DNSProvider
class MyDNSProvider:
"""Custom DNS provider for MyDNS service."""
def __init__(self, api_key: str, domain: str) -> None:
self._api_key = api_key
self._domain = domain
async def create_txt_record(self, domain: str, value: str) -> None:
"""Create a TXT record.
Args:
domain: Full record name (e.g., '_acme-challenge.example.com')
value: Base64url-encoded SHA-256 digest to set as the TXT value
"""
import httpx
async with httpx.AsyncClient() as client:
resp = await client.post(
f"https://api.mydns.example/v1/records",
headers={"Authorization": f"Bearer {self._api_key}"},
json={
"type": "TXT",
"name": domain,
"content": value,
"ttl": 120,
},
)
resp.raise_for_status()
async def delete_txt_record(self, domain: str, value: str) -> None:
"""Delete a TXT record.
Args:
domain: Full record name
value: The TXT value to remove (used to identify the record)
"""
import httpx
async with httpx.AsyncClient() as client:
resp = await client.delete(
f"https://api.mydns.example/v1/records",
headers={"Authorization": f"Bearer {self._api_key}"},
params={"name": domain, "content": value},
)
resp.raise_for_status()
Then use it with DNS01Handler:
from lacme import DNS01Handler
provider = MyDNSProvider(api_key="secret", domain="example.com")
handler = DNS01Handler(provider=provider)
Note
The DNSProvider protocol is @runtime_checkable, so isinstance(obj,
DNSProvider) returns True for any object with matching
create_txt_record and delete_txt_record methods -- no explicit
inheritance needed.
DNS01Handler Configuration¶
The DNS01Handler wraps a DNSProvider and adds propagation waiting:
from lacme import DNS01Handler
handler = DNS01Handler(
provider=provider,
propagation_delay=10.0, # seconds to wait if no checker (default: 10)
propagation_timeout=120.0, # max seconds to poll checker (default: 120)
propagation_interval=5.0, # seconds between checker polls (default: 5)
propagation_checker=my_checker, # optional async callback
)
Parameters¶
| Parameter | Default | Description |
|---|---|---|
propagation_delay |
10.0 |
Fixed sleep (seconds) when no checker is set |
propagation_timeout |
120.0 |
Maximum time to wait for propagation checker |
propagation_interval |
5.0 |
Interval between propagation checker polls |
propagation_checker |
None |
Async callback to verify DNS propagation |
Propagation Checker¶
Without a propagation_checker, DNS01Handler sleeps for propagation_delay
seconds after creating the record. With a checker, it polls until the record is
visible or the timeout is reached:
import dns.asyncresolver
async def check_propagation(domain: str, expected_value: str) -> bool:
"""Check if the TXT record has propagated to public DNS."""
try:
answers = await dns.asyncresolver.resolve(domain, "TXT")
for rdata in answers:
for txt in rdata.strings:
if txt.decode() == expected_value:
return True
except dns.asyncresolver.NXDOMAIN:
pass
return False
handler = DNS01Handler(
provider=provider,
propagation_checker=check_propagation,
propagation_timeout=180.0,
)
Tip
If the propagation checker times out, lacme raises ACMETimeoutError.
Increase propagation_timeout if your DNS provider is slow to propagate.
Wildcard Certificates¶
Wildcard certificates (*.example.com) always require DNS-01 validation.
lacme automatically strips the *. prefix when constructing the challenge
record name:
| Domain | Challenge Record Name |
|---|---|
example.com |
_acme-challenge.example.com |
*.example.com |
_acme-challenge.example.com |
*.sub.example.com |
_acme-challenge.sub.example.com |
This means that a certificate for both example.com and *.example.com creates
a single _acme-challenge.example.com TXT record. Most ACME servers handle this
by requiring two separate TXT records with the same name (one per authorization).